From bfa5af5a7213e5b098d845b1728a9e734bf354fb Mon Sep 17 00:00:00 2001 From: Hussain Badshah Date: Mon, 21 Apr 2025 15:26:07 +0530 Subject: [PATCH 01/23] WI 706016 [706023] Removed secrets. --- .github/ISSUE_TEMPLATE/bug.yml | 41 + .github/ISSUE_TEMPLATE/documentation.yml | 41 + .github/ISSUE_TEMPLATE/feature.yml | 41 + .github/ISSUE_TEMPLATE/maintenance.yml | 41 + .github/actions/import-gpg-key/action.yaml | 24 + .github/workflows/dependencies-update.yaml | 58 + .github/workflows/license-compliance.yml | 44 + .github/workflows/maven-build.yml | 74 + .github/workflows/maven-deploy.yml | 76 + CODE_OF_CONDUCT.md | 3 + CONTRIBUTING.md | 54 + DEPENDENCIES | 0 LICENSE | 201 +++ NOTICE.md | 44 + README.md | 376 ++++- SECURITY.md | 21 + checkstyle-suppressions.xml | 11 + harman_checks.xml | 422 ++++++ images/img.png | Bin 0 -> 107029 bytes images/logo.png | Bin 0 -> 17097 bytes pom.xml | 1259 +++++++++++++++++ release_notes.txt | 3 + .../stream/base/AbstractLauncher.java | 274 ++++ .../stream/base/ConfigChangeListener.java | 56 + .../base/IgniteEventStreamProcessor.java | 55 + .../stream/base/KafkaProducerInstance.java | 137 ++ .../analytics/stream/base/KafkaSslConfig.java | 123 ++ .../stream/base/KafkaStateAgentListener.java | 50 + .../stream/base/KafkaStateListener.java | 276 ++++ .../stream/base/KafkaStreamsLauncher.java | 735 ++++++++++ .../stream/base/KafkaStreamsProcessor.java | 301 ++++ .../base/KafkaStreamsProcessorContext.java | 448 ++++++ .../ecsp/analytics/stream/base/Launcher.java | 309 ++++ .../stream/base/LauncherProvider.java | 57 + .../analytics/stream/base/PropertyNames.java | 970 +++++++++++++ .../analytics/stream/base/SequenceBuffer.java | 124 ++ .../base/SequenceBufferTreeMapImpl.java | 195 +++ .../stream/base/SimplePropertiesLoader.java | 98 ++ .../stream/base/StreamBaseConstant.java | 79 ++ .../stream/base/StreamProcessingContext.java | 143 ++ .../stream/base/StreamProcessor.java | 160 +++ .../stream/base/StreamProcessorFilter.java | 58 + .../analytics/stream/base/TickListener.java | 49 + .../ecsp/analytics/stream/base/WallClock.java | 137 ++ .../base/context/StreamBaseSpringContext.java | 84 ++ ...heBackedInMemoryBatchCompleteCallBack.java | 52 + .../analytics/stream/base/dao/GenericDAO.java | 94 ++ .../analytics/stream/base/dao/SinkNode.java | 101 ++ .../base/dao/impl/ConnectionException.java | 52 + .../stream/base/dao/impl/KafkaSinkNode.java | 188 +++ .../stream/base/dao/impl/MongoSinkNode.java | 206 +++ .../PropBasedDiscoveryServiceImpl.java | 170 +++ .../discovery/SPIDiscoveryServiceImpl.java | 84 ++ .../StreamProcessorDiscoveryService.java | 73 + .../BackdoorKafkaConsumerException.java | 60 + .../exception/ClassNotFoundException.java | 59 + .../exception/ClientConnectionException.java | 58 + .../exception/ClientInterruptedException.java | 59 + ...essagingMqttClientTrustStoreException.java | 59 + .../base/exception/HeaderUpdateException.java | 58 + .../InputStreamMaxSizeExceededException.java | 59 + .../exception/InvalidKeyOrValueException.java | 59 + .../InvalidMetricSpecifiedException.java | 58 + .../InvalidSequenceBlockException.java | 58 + .../InvalidServiceNameException.java | 59 + .../InvalidSourceTopicException.java | 59 + .../base/exception/InvalidStoreException.java | 58 + .../exception/InvalidTargetIDException.java | 59 + .../exception/InvalidVehicleIDException.java | 58 + .../exception/MaxRetriesFailedException.java | 59 + .../base/exception/MqttTopicException.java | 59 + .../base/exception/ObjectUtilsException.java | 58 + .../OfflineBufferEntriesException.java | 59 + .../exception/PropertyNotFoundException.java | 58 + .../exception/UnableToReadFileException.java | 59 + .../UnsupportedTimeUnitException.java | 58 + .../healthcheck/KafkaTopicsHealthMonitor.java | 334 +++++ .../stream/base/http/HttpClient.java | 294 ++++ .../stream/base/http/HttpClientFactory.java | 146 ++ .../stream/base/idgen/MessageIdGenerator.java | 47 + .../base/idgen/MessageIdPartGenerator.java | 47 + .../internal/GlobalMessageIdGenerator.java | 198 +++ .../base/idgen/internal/IdGenConstants.java | 62 + .../base/idgen/internal/SequenceBlock.java | 148 ++ .../base/idgen/internal/SequenceBlockDAO.java | 49 + .../idgen/internal/SequenceBlockDAOImpl.java | 52 + .../idgen/internal/SequenceBlockService.java | 54 + .../internal/SequenceBlockServiceImpl.java | 198 +++ .../internal/ShortCounterIdPartGenerator.java | 82 ++ .../ShortHashCodeIdPartGenerator.java | 98 ++ .../kafka/internal/BackdoorKafkaConsumer.java | 1017 +++++++++++++ .../BackdoorKafkaConsumerCallback.java | 78 + .../internal/BackdoorKafkaTopicOffset.java | 89 ++ .../BackdoorKafkaTopicOffsetDAOMongoImpl.java | 81 ++ .../base/kafka/internal/MutationId.java | 50 + .../base/kafka/internal/OffsetMetadata.java | 131 ++ .../KafkaStreamsThreadStatusPrinter.java | 207 +++ .../support/LoggingStateRestoreListener.java | 100 ++ .../reporter/ConsoleMetricReporter.java | 192 +++ .../metrics/reporter/CumulativeLogger.java | 161 +++ .../HarmanRocksDBMetricsExporter.java | 195 +++ ...kaStreamsOffsetManagementDAOMongoImpl.java | 93 ++ .../base/offset/KafkaStreamsTopicOffset.java | 80 ++ .../offset/OffsetManagementDaoMongoImpl.java | 89 ++ .../stream/base/offset/OffsetManager.java | 284 ++++ .../stream/base/offset/TopicOffset.java | 236 +++ .../parser/DeviceConnectionStatusParser.java | 72 + .../base/parser/EventParseException.java | 65 + .../stream/base/parser/EventParser.java | 125 ++ .../stream/base/parser/EventWrapperBase.java | 112 ++ .../base/parser/EventWrapperForMap.java | 615 ++++++++ .../base/parser/EventWrapperForSequence.java | 564 ++++++++ .../stream/base/parser/GenericValue.java | 251 ++++ .../stream/base/platform/IgnitePlatform.java | 61 + .../base/platform/MqttTopicNameGenerator.java | 61 + .../base/platform/utils/PlatformUtils.java | 93 ++ .../DeviceMessagingAgentPostProcessor.java | 182 +++ .../DeviceMessagingAgentPreProcessor.java | 233 +++ .../stream/base/processors/MessageFilter.java | 60 + .../base/processors/MessgeFilterAgent.java | 123 ++ .../base/processors/MsgSeqPreProcessor.java | 439 ++++++ .../ProtocolTranslatorPostProcessor.java | 301 ++++ .../ProtocolTranslatorPreProcessor.java | 887 ++++++++++++ .../SchedulerAgentPostProcessor.java | 289 ++++ .../processors/TaskContextInitializer.java | 169 +++ .../stream/base/stores/CacheBypass.java | 441 ++++++ .../stream/base/stores/CacheEntity.java | 209 +++ .../stream/base/stores/CacheKeyConverter.java | 67 + .../base/stores/CachedMapStateStore.java | 384 +++++ .../stores/CachedSortedMapStateStore.java | 381 +++++ .../base/stores/GenericMapStateStore.java | 183 +++ .../base/stores/GenericMapStateStoreBase.java | 240 ++++ .../stores/GenericSortedMapStateStore.java | 242 ++++ .../base/stores/HarmanPersistentKVStore.java | 307 ++++ ...armanPersistentPrimitiveMapValueStore.java | 177 +++ .../base/stores/HarmanRocksDBStore.java | 1052 ++++++++++++++ .../stores/HarmanRocksDBStoreSupplier.java | 112 ++ .../stream/base/stores/JsonStateStore.java | 154 ++ .../base/stores/MapObjectStateStore.java | 334 +++++ .../base/stores/MutableKeyValueStore.java | 160 +++ .../stream/base/stores/ObjectStateStore.java | 193 +++ .../stream/base/stores/Operation.java | 58 + .../base/stores/SerializedKVIterator.java | 129 ++ .../base/stores/SortedKeyValueStore.java | 80 ++ .../stream/base/utils/CompressionJack.java | 356 +++++ .../base/utils/ConnectionStatusRetriever.java | 24 + .../stream/base/utils/Constants.java | 375 +++++ .../stream/base/utils/DLQHandler.java | 401 ++++++ .../stream/base/utils/DMATLSFactory.java | 118 ++ ...efaultDeviceConnectionStatusRetriever.java | 234 +++ .../DefaultMqttTopicNameGeneratorImpl.java | 236 +++ .../stream/base/utils/Dispatcher.java | 58 + .../base/utils/ForcedHealthCheckEvent.java | 80 ++ .../base/utils/HiveMqMqttDispatcher.java | 336 +++++ .../base/utils/InternalCacheConstants.java | 68 + .../stream/base/utils/JsonUtils.java | 321 +++++ .../stream/base/utils/KafkaDispatcher.java | 269 ++++ .../stream/base/utils/KafkaSslUtils.java | 171 +++ .../stream/base/utils/KafkaTestUtils.java | 389 +++++ .../stream/base/utils/MqttConfig.java | 342 +++++ .../stream/base/utils/MqttDispatcher.java | 751 ++++++++++ .../stream/base/utils/MqttHealthMonitor.java | 157 ++ .../utils/NoMqttClientFoundException.java | 70 + .../stream/base/utils/ObjectUtils.java | 162 +++ .../stream/base/utils/PahoMqttDispatcher.java | 361 +++++ .../analytics/stream/base/utils/Pair.java | 181 +++ .../stream/base/utils/RetryUtils.java | 122 ++ .../stream/base/utils/ThreadUtils.java | 123 ++ .../analytics/stream/base/utils/Triplet.java | 128 ++ .../stream/threadlocal/ContextKey.java | 71 + .../threadlocal/TaskContextHandler.java | 147 ++ .../utils/VehicleProfileClientApiUtil.java | 140 ++ .../utils/VehicleProfileData.java | 91 ++ .../utils/VehicleProfileEntity.java | 68 + .../stream/dma/config/DMAConfigResolver.java | 59 + .../dma/config/DefaultDMAConfigResolver.java | 71 + .../stream/dma/config/DefaultEventConfig.java | 61 + .../config/DefaultEventConfigProvider.java | 63 + .../ecsp/stream/dma/config/EventConfig.java | 61 + .../dma/config/EventConfigProvider.java | 75 + .../ecsp/stream/dma/dao/DMAConstants.java | 128 ++ ...RetryBucketDAOCacheBackedInMemoryImpl.java | 154 ++ .../stream/dma/dao/DMARetryRecordDAO.java | 65 + ...RetryRecordDAOCacheBackedInMemoryImpl.java | 102 ++ .../dma/dao/DMCacheEntityDAOMongoImpl.java | 82 ++ .../dma/dao/DMNextTtlExpirationTimer.java | 123 ++ .../dma/dao/DMNextTtlExpirationTimerDAO.java | 49 + .../dao/DMNextTtlExpirationTimerDAOImpl.java | 73 + .../stream/dma/dao/DMOfflineBufferEntry.java | 325 +++++ .../dma/dao/DMOfflineBufferEntryDAO.java | 98 ++ .../dao/DMOfflineBufferEntryDAOMongoImpl.java | 262 ++++ .../stream/dma/dao/DeviceConnStatusDAO.java | 70 + .../dma/dao/DeviceMessagingException.java | 56 + .../dao/DeviceStatusAPIInMemoryService.java | 79 ++ .../DeviceStatusAPIInMemoryServiceImpl.java | 132 ++ ...eviceStatusDaoCacheBackedInMemoryImpl.java | 144 ++ .../dma/dao/DeviceStatusDaoInMemoryCache.java | 73 + .../stream/dma/dao/DeviceStatusService.java | 109 ++ .../dma/dao/DeviceStatusServiceImpl.java | 340 +++++ .../stream/dma/dao/DmaRetryBucketDao.java | 79 ++ .../dma/dao/ShoulderTapRetryBucketDAO.java | 80 ++ .../ShoulderTapRetryBucketDAOCacheImpl.java | 157 ++ .../dma/dao/ShoulderTapRetryRecordDAO.java | 65 + .../ShoulderTapRetryRecordDAOCacheImpl.java | 104 ++ .../dma/dao/key/AbstractRetryBucketKey.java | 99 ++ .../stream/dma/dao/key/DeviceStatusKey.java | 89 ++ .../stream/dma/dao/key/RetryBucketKey.java | 110 ++ .../stream/dma/dao/key/RetryRecordKey.java | 207 +++ .../stream/dma/dao/key/RetryVehicleIdKey.java | 165 +++ .../dao/key/ShoulderTapRetryBucketKey.java | 112 ++ .../dma/handler/DMABackdoorKafkaConsumer.java | 84 ++ .../handler/DefaultPostDispatchHandler.java | 97 ++ .../DeviceConnectionStatusHandler.java | 872 ++++++++++++ .../dma/handler/DeviceHeaderUpdater.java | 183 +++ .../dma/handler/DeviceMessageHandler.java | 82 ++ .../dma/handler/DeviceMessageUtils.java | 101 ++ .../dma/handler/DeviceMessageValidator.java | 107 ++ .../handler/DeviceMessagingHandlerChain.java | 383 +++++ .../DeviceStatusBackDoorKafkaConsumer.java | 411 ++++++ .../stream/dma/handler/DispatchHandler.java | 200 +++ .../handler/FilterDMOfflineBufferEntry.java | 66 + .../MaxFailuresUncaughtExceptionHandler.java | 166 +++ .../NoFilterDMOfflineBufferEntryImpl.java | 69 + .../ecsp/stream/dma/handler/RetryHandler.java | 938 ++++++++++++ .../DeviceFetchConnectionStatusProducer.java | 129 ++ .../DeviceMessagingEventScheduler.java | 308 ++++ .../shouldertap/DeviceShoulderTapInvoker.java | 67 + .../DeviceShoulderTapRetryHandler.java | 555 ++++++++ .../shouldertap/DeviceShoulderTapService.java | 168 +++ .../DummyShoulderTapInvokerImpl.java | 80 ++ ...lderTapInvokerVehicleNotificationImpl.java | 70 + .../ShoulderTapInvokerWAMImpl.java | 444 ++++++ .../resources/application-base.properties | 335 +++++ src/main/resources/sample_sequence_files.txt | 11 + .../java/org/apache/kafka/test/TestUtils.java | 248 ++++ .../stream/base/CacheMapStateStoreTest.java | 415 ++++++ .../base/CachedSortedMapStateStoreTest.java | 284 ++++ .../stream/base/DataUsageMetricsTest.java | 292 ++++ .../stream/base/EmbeddedMQTTServerTest.java | 80 ++ .../stream/base/GenericMapStateStoreTest.java | 160 +++ .../base/GenericSortedMapStateStoreTest.java | 220 +++ ...nPersistentPrimitiveMapValueStoreTest.java | 132 ++ .../base/KafkaProducerInstanceTest.java | 146 ++ .../KafkaStateListnerHealthMonitorTest.java | 66 + .../base/KafkaStreamsLauncherMockTest.java | 139 ++ .../stream/base/KafkaStreamsLauncherTest.java | 925 ++++++++++++ .../KafkaStreamsMaxUncaughtExceptionTest.java | 263 ++++ ...kaStreamsMaxUncaughtReplaceThreadTest.java | 215 +++ .../KafkaStreamsMaxUncaughtShutdownTest.java | 281 ++++ .../stream/base/ProcessorChainingTest.java | 597 ++++++++ .../stream/base/PrometheusMetricsTest.java | 889 ++++++++++++ .../base/SimplePropertiesLoaderTest.java | 133 ++ .../base/StreamProcessorFilterTest.java | 611 ++++++++ .../ecsp/analytics/stream/base/TestKryo.java | 113 ++ .../stream/base/ThreadLocalTest.java | 122 ++ .../stream/base/constants/TestConstants.java | 261 ++++ .../context/StreamBaseSpringContextTest.java | 97 ++ .../base/dao/impl/KafkaSinkNodeTest.java | 183 +++ .../base/dao/impl/MockKafkaPartitioner.java | 48 + .../base/dao/impl/MongoSinkNodeTest.java | 103 ++ .../healthcheck/KafkaTopicsMonitorTest.java | 244 ++++ .../stream/base/http/HttpClientTest.java | 288 ++++ ...GlobalMessageGeneratorIntegrationTest.java | 125 ++ .../internal/GlobalMessageGeneratorTest.java | 114 ++ ...quenceBlockServiceImplIntegrationTest.java | 131 ++ .../SequenceBlockServiceImplTest.java | 177 +++ .../stream/base/kafka/EmbeddedKafka.java | 273 ++++ .../stream/base/kafka/EmbeddedZookeeper.java | 104 ++ .../base/kafka/SingleNodeKafkaCluster.java | 387 +++++ .../BackDoorKafkaConsumerIntegrationTest.java | 434 ++++++ .../BackDoorKafkaConsumerMockTest.java | 490 +++++++ .../internal/BackDoorKafkaConsumerTest.java | 259 ++++ ...kdoorKafkaTopicOffsetDAOMongoImplTest.java | 120 ++ .../BackdoorKafkaTopicOffsetTest.java | 183 +++ .../KafkaStreamsThreadStatusPrinterTest.java | 116 ++ .../LoggingStateRestoreListenerTest.java | 68 + .../reporter/CumulativeLoggerUnitTest.java | 116 ++ .../HarmanRocksDBMetricsExporterTest.java | 165 +++ .../base/mqtt/DeviceMessageUtilsTest.java | 102 ++ .../mqtt/HiveMQEmbeddedMQTTServerTest.java | 159 +++ .../stream/base/mqtt/HiveMQTestContainer.java | 187 +++ .../stream/base/mqtt/MqttServer.java | 92 ++ .../stream/base/mqtt/MqttTLSServer.java | 91 ++ ...reamsOffsetManagementDAOMongoImplTest.java | 135 ++ .../offset/KafkaStreamsTopicOffsetTest.java | 158 +++ .../offset/OffsetManagerIntegrationTest.java | 262 ++++ .../stream/base/offset/OffsetManagerTest.java | 259 ++++ .../stream/base/parser/EventWrapperTest.java | 308 ++++ .../DeviceMessagingAgentPreProcessorTest.java | 167 +++ ...eMQMqttDispatcherIntegrationTopicTest.java | 232 +++ ...cherWithoutToDeviceForSubServicesTest.java | 192 +++ .../processors/MessageBaseFilterImpl.java | 65 + .../base/processors/MessageGenerator.java | 204 +++ .../processors/MessgeFilterAgentTest.java | 120 ++ .../MqttDispatcherIntegrationTest.java | 265 ++++ ...MqttDispatcherPlatformIntegrationTest.java | 282 ++++ ...rPlatformInvalidConfigIntegrationTest.java | 303 ++++ .../MqttDispatcherSSLIntegrationTest.java | 248 ++++ ...tDispatcherSSLPlatformIntegrationTest.java | 250 ++++ ...cherWithoutToDeviceForSubServicesTest.java | 193 +++ ...cherWithoutTopicPrefixIntegrationTest.java | 265 ++++ .../processors/MsgSeqPreProcessorTest.java | 683 +++++++++ .../ProtocolTranslatorPreProcessorTest.java | 491 +++++++ .../SchedulerAgentPostProcessorTest.java | 357 +++++ .../base/processors/TestStreamProcessor.java | 208 +++ ...TestStreamProcessorIntegrationTesting.java | 239 ++++ .../TestStreamProcessorMultiForwards.java | 221 +++ .../stores/CacheBypassIntegrationTest.java | 467 ++++++ .../stream/base/stores/CacheBypassTest.java | 481 +++++++ .../stream/base/stores/CacheEntityTest.java | 294 ++++ .../base/stores/HarmanRocksDBStoreTest.java | 268 ++++ .../base/utils/CompressionJackTest.java | 175 +++ .../CompressionJackWithThresholdTest.java | 87 ++ .../stream/base/utils/DLQHandlerTest.java | 319 +++++ .../base/utils/DLQReprocessingTest.java | 233 +++ .../utils/DLQRetryHandlerFailureTest.java | 669 +++++++++ .../base/utils/DLQRetryHandlerTest.java | 912 ++++++++++++ .../DeviceConnectionStatusRetrieverTest.java | 169 +++ .../stream/base/utils/EmbeddedMQTTServer.java | 223 +++ ...ispatcherHealthMontiorIntegrationTest.java | 214 +++ ...tiorMultipleDispatcherIntegrationTest.java | 114 ++ ...HiveMQMqttDispatcherHealthMontiorTest.java | 259 ++++ ...iveMQMqttDispatcherIntegrationSSLTest.java | 225 +++ .../HiveMQMqttDispatcherIntegrationTest.java | 223 +++ .../base/utils/HiveMQMqttDispatcherTest.java | 421 ++++++ .../stream/base/utils/JsonUtilsTest.java | 360 +++++ .../base/utils/KafkaDispatcherTest.java | 360 +++++ .../KafkaStreamsApplicationTestBase.java | 587 ++++++++ ...ispatcherHealthMontiorIntegrationTest.java | 246 ++++ ...tiorMultipleDispatcherIntegrationTest.java | 111 ++ .../MqttDispatcherHealthMontiorTest.java | 264 ++++ .../stream/base/utils/MqttDispatcherTest.java | 451 ++++++ .../stream/base/utils/ObjectUtilsTest.java | 182 +++ .../analytics/stream/base/utils/PairTest.java | 92 ++ .../stream/base/utils/TripletTest.java | 79 ++ .../VehicleProfileClientApiUtilTest.java | 123 ++ .../redis/EmbeddedRedisSentinelServer.java | 114 ++ .../ecsp/cache/redis/EmbeddedRedisServer.java | 102 ++ .../ecsp/dao/utils/EmbeddedMongoDB.java | 122 ++ .../dma/ConnectionStatusHandlerTest.java | 624 ++++++++ .../dma/ConnectionStatusParserTestImpl.java | 63 + ...yBucketDAOCacheBackedInMemoryImplTest.java | 371 +++++ ...yRecordDAOCacheBackedInMemoryImplTest.java | 196 +++ ...yBucketDAOCacheBackedInMemoryImplTest.java | 369 +++++ .../dma/DMNextTtlExpirationTimerTest.java | 65 + .../dma/DMOfflineBufferIntegrationTest.java | 348 +++++ ...eBufferMultipleDevicesIntegrationTest.java | 312 ++++ .../dma/DMOfflineBufferServiceImplTest.java | 394 ++++++ .../dma/DefaultDMAConfigResolverTest.java | 70 + .../stream/dma/DeviceConnStatusDAOTest.java | 157 ++ .../dma/DeviceConnStatusServiceTest.java | 247 ++++ ...eConnStatusServiceWithSubServicesTest.java | 116 ++ .../DeviceFetchConnStatusIntegrationTest.java | 326 +++++ ...viceFetchConnectionStatusProducerTest.java | 121 ++ .../DeviceMessagingEventSchedulerTest.java | 216 +++ ...eviceStatusAPIInMemoryServiceImplTest.java | 122 ++ .../dma/DeviceStatusServiceImplTest.java | 145 ++ .../dma/KafkaDispatcherIntegrationTest.java | 389 +++++ .../stream/dma/MsgIdAndCorrIdUpdaterTest.java | 227 +++ .../ecsp/stream/dma/MsgIdUpdaterTest.java | 235 +++ .../NoFilterDMOfflineBufferEntryImplTest.java | 136 ++ .../dma/ShortHashCodeIdPartGeneratorTest.java | 90 ++ .../dma/SynchronizationIntegrationTest.java | 301 ++++ ...izationIntegrationWithSubServicesTest.java | 154 ++ .../dma/TestFilterDMOfflineEntryTest.java | 318 +++++ .../dma/dao/key/DeviceStatusKeyTest.java | 123 ++ .../dma/dao/key/RetryBucketKeyTest.java | 122 ++ .../dma/dao/key/RetryRecordKeyTest.java | 156 ++ .../dma/dao/key/RetryVehicleIdKeyTest.java | 107 ++ .../key/ShoulderTapRetryBucketKeyTest.java | 125 ++ .../handler/DMAConfigResolverTestImpl.java | 72 + .../dma/handler/DMAFeedbackTopicTest.java | 272 ++++ ...DeviceConnectionStatusHandlerUnitTest.java | 1005 +++++++++++++ .../DeviceMessagingHandlerChainTest.java | 318 +++++ ...sBackDoorKafkaConsumerIntegrationTest.java | 199 +++ ...ceStatusBackDoorKafkaConsumerUnitTest.java | 124 ++ .../dma/handler/DispatchHandlerTest.java | 145 ++ .../handler/EventConfigProviderTestImpl.java | 65 + .../dma/handler/EventConfigTestImpl.java | 59 + ...xFailuresUncaughtExceptionHandlerTest.java | 85 ++ .../handler/RetryHandlerIntegrationTest.java | 1023 ++++++++++++++ .../stream/dma/handler/RetryHandlerTest.java | 862 +++++++++++ .../stream/dma/handler/RetryTestEvent.java | 82 ++ .../ecsp/stream/dma/handler/RetryTestKey.java | 71 + .../TestFilterDMOfflineBufferEntryImpl.java | 73 + .../stream/dma/handler/TestKVIterator.java | 124 ++ ...houlderTapRetryHandlerIntegrationTest.java | 533 +++++++ .../DeviceShoulderTapRetryHandlerTest.java | 590 ++++++++ .../DeviceShoulderTapServiceTest.java | 295 ++++ .../DummyShoulderTapInvokerImplTest.java | 90 ++ ...TapInvokerVehicleNotificationImplTest.java | 95 ++ .../ShoulderTapInvokerWAMImplTest.java | 418 ++++++ ...houlderTapRetryRecordDAOCacheImplTest.java | 85 ++ .../SchedulerAgentIntegrationTest.java | 504 +++++++ .../java/redis/embedded/RedisCluster408.java | 104 ++ .../java/redis/embedded/RedisSentinel408.java | 67 + .../java/redis/embedded/RedisServer408.java | 84 ++ .../application-base-test.properties | 172 +++ src/test/resources/application.properties | 2 + src/test/resources/aws-props.properties | 12 + .../resources/backdoor-dao-test.properties | 81 ++ .../resources/cache-bypass-test.properties | 86 ++ src/test/resources/client-truststore.jks | Bin 0 -> 4061 bytes src/test/resources/client.jks | Bin 0 -> 1483 bytes .../dlq-reprocessing-test.properties | 63 + .../dma-backdoor-consumer-test.properties | 103 ++ ...connection-status-handler-test2.properties | 87 ++ ...a-connectionstatus-handler-test.properties | 85 ++ ...-handler-fetch-conn-status-test.properties | 209 +++ .../dma-handler-sub-services-test.properties | 92 ++ .../resources/dma-handler-test.properties | 93 ++ ...ma-offline-multiple-device-test.properties | 164 +++ .../resources/dma-offline-test.properties | 159 +++ .../resources/dma-shouldertap-test.properties | 106 ++ .../dma-test-kafka-dispatch.properties | 164 +++ .../filter-dma-offline-test.properties | 161 +++ src/test/resources/hivemq-keystore.jks | Bin 0 -> 7086 bytes .../hivemq-mqtt-health-monitor.properties | 40 + .../hivemq-test-mqtt-sub-services.properties | 38 + ...-test-mqtt-without-topic-prefix.properties | 44 + .../resources/hivemq-test-mqtt.properties | 39 + .../resources/hivemq-test-ssl-mqtt.properties | 58 + .../integration-test-application.properties | 133 ++ src/test/resources/kafka.client.keystore.jks | Bin 0 -> 4066 bytes .../resources/kafka.client.truststore.jks | Bin 0 -> 1430 bytes src/test/resources/logback.xml | 60 + .../messageid-generator-test.properties | 78 + src/test/resources/mongo-sink-node.properties | 9 + src/test/resources/moquette.conf | 88 ++ .../resources/mqtt-health-monitor.properties | 40 + src/test/resources/mqtt.conf | 2 + src/test/resources/mqtt_ssl.conf | 6 + .../notification-alerts-template-message.json | 21 + .../resources/offsetmanager-test.properties | 87 ++ src/test/resources/password_file.conf | 2 + src/test/resources/redis-server.exe | Bin 0 -> 1925632 bytes src/test/resources/redis-server.pdb | Bin 0 -> 11833344 bytes .../resources/scheduler-agent-test.properties | 164 +++ src/test/resources/server.jks | Bin 0 -> 3930 bytes .../resources/stream-base-test.properties | 79 ++ .../resources/stream-base-test2.properties | 76 + ...tream-base-vehicle-profile-test.properties | 72 + .../test-mqtt-empty-to-device.properties | 36 + .../test-mqtt-platform-invalid.properties | 40 + .../resources/test-mqtt-platform.properties | 44 + .../test-mqtt-ssl-platform.properties | 55 + src/test/resources/test-mqtt-ssl.properties | 46 + .../test-mqtt-sub-services.properties | 39 + .../test-mqtt-without-topic-prefix.properties | 42 + src/test/resources/test-mqtt.properties | 37 + .../topics-health-monitor-test.properties | 132 ++ src/test/resources/topics.txt | 1 + 452 files changed, 85091 insertions(+), 2 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/documentation.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yml create mode 100644 .github/ISSUE_TEMPLATE/maintenance.yml create mode 100644 .github/actions/import-gpg-key/action.yaml create mode 100644 .github/workflows/dependencies-update.yaml create mode 100644 .github/workflows/license-compliance.yml create mode 100644 .github/workflows/maven-build.yml create mode 100644 .github/workflows/maven-deploy.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 DEPENDENCIES create mode 100644 LICENSE create mode 100644 NOTICE.md create mode 100644 SECURITY.md create mode 100644 checkstyle-suppressions.xml create mode 100644 harman_checks.xml create mode 100644 images/img.png create mode 100644 images/logo.png create mode 100644 pom.xml create mode 100644 release_notes.txt create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/AbstractLauncher.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/ConfigChangeListener.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/IgniteEventStreamProcessor.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaProducerInstance.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaSslConfig.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStateAgentListener.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStateListener.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsLauncher.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsProcessor.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsProcessorContext.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/Launcher.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/LauncherProvider.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/PropertyNames.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/SequenceBuffer.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/SequenceBufferTreeMapImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/SimplePropertiesLoader.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamBaseConstant.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessingContext.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessor.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessorFilter.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/TickListener.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/WallClock.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/context/StreamBaseSpringContext.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/CacheBackedInMemoryBatchCompleteCallBack.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/GenericDAO.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/SinkNode.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/ConnectionException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/KafkaSinkNode.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/MongoSinkNode.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/discovery/PropBasedDiscoveryServiceImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/discovery/SPIDiscoveryServiceImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/discovery/StreamProcessorDiscoveryService.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/BackdoorKafkaConsumerException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ClassNotFoundException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ClientConnectionException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ClientInterruptedException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/DeviceMessagingMqttClientTrustStoreException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/HeaderUpdateException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InputStreamMaxSizeExceededException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidKeyOrValueException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidMetricSpecifiedException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidSequenceBlockException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidServiceNameException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidSourceTopicException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidStoreException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidTargetIDException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidVehicleIDException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/MaxRetriesFailedException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/MqttTopicException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ObjectUtilsException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/OfflineBufferEntriesException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/PropertyNotFoundException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/UnableToReadFileException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/UnsupportedTimeUnitException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/healthcheck/KafkaTopicsHealthMonitor.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/http/HttpClient.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/http/HttpClientFactory.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/MessageIdGenerator.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/MessageIdPartGenerator.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/GlobalMessageIdGenerator.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/IdGenConstants.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlock.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockDAO.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockDAOImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockService.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockServiceImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/ShortCounterIdPartGenerator.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/ShortHashCodeIdPartGenerator.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaConsumer.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaConsumerCallback.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffset.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffsetDAOMongoImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/MutationId.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/OffsetMetadata.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/KafkaStreamsThreadStatusPrinter.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/LoggingStateRestoreListener.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/ConsoleMetricReporter.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/CumulativeLogger.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/HarmanRocksDBMetricsExporter.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsOffsetManagementDAOMongoImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsTopicOffset.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManagementDaoMongoImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManager.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/TopicOffset.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/DeviceConnectionStatusParser.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventParseException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventParser.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperBase.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperForMap.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperForSequence.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/GenericValue.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/platform/IgnitePlatform.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/platform/MqttTopicNameGenerator.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/platform/utils/PlatformUtils.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/DeviceMessagingAgentPostProcessor.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/DeviceMessagingAgentPreProcessor.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/MessageFilter.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/MessgeFilterAgent.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/MsgSeqPreProcessor.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/ProtocolTranslatorPostProcessor.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/ProtocolTranslatorPreProcessor.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/SchedulerAgentPostProcessor.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/TaskContextInitializer.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheBypass.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheEntity.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheKeyConverter.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CachedMapStateStore.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CachedSortedMapStateStore.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/GenericMapStateStore.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/GenericMapStateStoreBase.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/GenericSortedMapStateStore.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanPersistentKVStore.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanPersistentPrimitiveMapValueStore.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanRocksDBStore.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanRocksDBStoreSupplier.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/JsonStateStore.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/MapObjectStateStore.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/MutableKeyValueStore.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/ObjectStateStore.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/Operation.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/SerializedKVIterator.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/SortedKeyValueStore.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/CompressionJack.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ConnectionStatusRetriever.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Constants.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQHandler.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DMATLSFactory.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DefaultDeviceConnectionStatusRetriever.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DefaultMqttTopicNameGeneratorImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Dispatcher.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ForcedHealthCheckEvent.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMqMqttDispatcher.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/InternalCacheConstants.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/JsonUtils.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaDispatcher.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaSslUtils.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaTestUtils.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttConfig.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcher.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttHealthMonitor.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/NoMqttClientFoundException.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ObjectUtils.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/PahoMqttDispatcher.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Pair.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/RetryUtils.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ThreadUtils.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Triplet.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/threadlocal/ContextKey.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/threadlocal/TaskContextHandler.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileClientApiUtil.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileData.java create mode 100644 src/main/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileEntity.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/config/DMAConfigResolver.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/config/DefaultDMAConfigResolver.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/config/DefaultEventConfig.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/config/DefaultEventConfigProvider.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/config/EventConfig.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/config/EventConfigProvider.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DMAConstants.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DMARetryBucketDAOCacheBackedInMemoryImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DMARetryRecordDAO.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DMARetryRecordDAOCacheBackedInMemoryImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DMCacheEntityDAOMongoImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DMNextTtlExpirationTimer.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DMNextTtlExpirationTimerDAO.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DMNextTtlExpirationTimerDAOImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DMOfflineBufferEntry.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DMOfflineBufferEntryDAO.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DMOfflineBufferEntryDAOMongoImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceConnStatusDAO.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceMessagingException.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusAPIInMemoryService.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusAPIInMemoryServiceImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusDaoCacheBackedInMemoryImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusDaoInMemoryCache.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusService.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusServiceImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/DmaRetryBucketDao.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryBucketDAO.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryBucketDAOCacheImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryRecordDAO.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryRecordDAOCacheImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/key/AbstractRetryBucketKey.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/key/DeviceStatusKey.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/key/RetryBucketKey.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/key/RetryRecordKey.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/key/RetryVehicleIdKey.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/dao/key/ShoulderTapRetryBucketKey.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/handler/DMABackdoorKafkaConsumer.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/handler/DefaultPostDispatchHandler.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceConnectionStatusHandler.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceHeaderUpdater.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessageHandler.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessageUtils.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessageValidator.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessagingHandlerChain.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceStatusBackDoorKafkaConsumer.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/handler/DispatchHandler.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/handler/FilterDMOfflineBufferEntry.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/handler/MaxFailuresUncaughtExceptionHandler.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/handler/NoFilterDMOfflineBufferEntryImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/handler/RetryHandler.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/presencemanager/DeviceFetchConnectionStatusProducer.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/scheduler/DeviceMessagingEventScheduler.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapInvoker.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapRetryHandler.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapService.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DummyShoulderTapInvokerImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerVehicleNotificationImpl.java create mode 100644 src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerWAMImpl.java create mode 100644 src/main/resources/application-base.properties create mode 100644 src/main/resources/sample_sequence_files.txt create mode 100644 src/test/java/org/apache/kafka/test/TestUtils.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/CacheMapStateStoreTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/CachedSortedMapStateStoreTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/DataUsageMetricsTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/EmbeddedMQTTServerTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/GenericMapStateStoreTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/GenericSortedMapStateStoreTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/HarmanPersistentPrimitiveMapValueStoreTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaProducerInstanceTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStateListnerHealthMonitorTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsLauncherMockTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsLauncherTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsMaxUncaughtExceptionTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsMaxUncaughtReplaceThreadTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsMaxUncaughtShutdownTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/ProcessorChainingTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/PrometheusMetricsTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/SimplePropertiesLoaderTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessorFilterTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/TestKryo.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/ThreadLocalTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/constants/TestConstants.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/context/StreamBaseSpringContextTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/KafkaSinkNodeTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/MockKafkaPartitioner.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/MongoSinkNodeTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/healthcheck/KafkaTopicsMonitorTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/http/HttpClientTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/GlobalMessageGeneratorIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/GlobalMessageGeneratorTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockServiceImplIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockServiceImplTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/EmbeddedKafka.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/EmbeddedZookeeper.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/SingleNodeKafkaCluster.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackDoorKafkaConsumerIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackDoorKafkaConsumerMockTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackDoorKafkaConsumerTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffsetDAOMongoImplTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffsetTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/KafkaStreamsThreadStatusPrinterTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/LoggingStateRestoreListenerTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/CumulativeLoggerUnitTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/HarmanRocksDBMetricsExporterTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/DeviceMessageUtilsTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/HiveMQEmbeddedMQTTServerTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/HiveMQTestContainer.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/MqttServer.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/MqttTLSServer.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsOffsetManagementDAOMongoImplTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsTopicOffsetTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManagerIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManagerTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/DeviceMessagingAgentPreProcessorTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/HiveMQMqttDispatcherIntegrationTopicTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/HiveMQMqttDispatcherWithoutToDeviceForSubServicesTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MessageBaseFilterImpl.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MessageGenerator.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MessgeFilterAgentTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherPlatformIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherPlatformInvalidConfigIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherSSLIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherSSLPlatformIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherWithoutToDeviceForSubServicesTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherWithoutTopicPrefixIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MsgSeqPreProcessorTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/ProtocolTranslatorPreProcessorTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/SchedulerAgentPostProcessorTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/TestStreamProcessor.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/TestStreamProcessorIntegrationTesting.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/TestStreamProcessorMultiForwards.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheBypassIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheBypassTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheEntityTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanRocksDBStoreTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/CompressionJackTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/CompressionJackWithThresholdTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQHandlerTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQReprocessingTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQRetryHandlerFailureTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQRetryHandlerTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DeviceConnectionStatusRetrieverTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/EmbeddedMQTTServer.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherHealthMontiorIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherHealthMontiorMultipleDispatcherIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherHealthMontiorTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherIntegrationSSLTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/JsonUtilsTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaDispatcherTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaStreamsApplicationTestBase.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherHealthMontiorIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherHealthMontiorMultipleDispatcherIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherHealthMontiorTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/ObjectUtilsTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/PairTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/TripletTest.java create mode 100644 src/test/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileClientApiUtilTest.java create mode 100644 src/test/java/org/eclipse/ecsp/cache/redis/EmbeddedRedisSentinelServer.java create mode 100644 src/test/java/org/eclipse/ecsp/cache/redis/EmbeddedRedisServer.java create mode 100644 src/test/java/org/eclipse/ecsp/dao/utils/EmbeddedMongoDB.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/ConnectionStatusHandlerTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/ConnectionStatusParserTestImpl.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DMARetryBucketDAOCacheBackedInMemoryImplTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DMARetryRecordDAOCacheBackedInMemoryImplTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DMAShoulderTapRetryBucketDAOCacheBackedInMemoryImplTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DMNextTtlExpirationTimerTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DMOfflineBufferIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DMOfflineBufferMultipleDevicesIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DMOfflineBufferServiceImplTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DefaultDMAConfigResolverTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DeviceConnStatusDAOTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DeviceConnStatusServiceTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DeviceConnStatusServiceWithSubServicesTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DeviceFetchConnStatusIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DeviceFetchConnectionStatusProducerTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DeviceMessagingEventSchedulerTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DeviceStatusAPIInMemoryServiceImplTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/DeviceStatusServiceImplTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/KafkaDispatcherIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/MsgIdAndCorrIdUpdaterTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/MsgIdUpdaterTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/NoFilterDMOfflineBufferEntryImplTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/ShortHashCodeIdPartGeneratorTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/SynchronizationIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/SynchronizationIntegrationWithSubServicesTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/TestFilterDMOfflineEntryTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/dao/key/DeviceStatusKeyTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/dao/key/RetryBucketKeyTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/dao/key/RetryRecordKeyTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/dao/key/RetryVehicleIdKeyTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/dao/key/ShoulderTapRetryBucketKeyTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/DMAConfigResolverTestImpl.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/DMAFeedbackTopicTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceConnectionStatusHandlerUnitTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessagingHandlerChainTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceStatusBackDoorKafkaConsumerIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceStatusBackDoorKafkaConsumerUnitTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/DispatchHandlerTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/EventConfigProviderTestImpl.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/EventConfigTestImpl.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/MaxFailuresUncaughtExceptionHandlerTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryHandlerIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryHandlerTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryTestEvent.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryTestKey.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/TestFilterDMOfflineBufferEntryImpl.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/handler/TestKVIterator.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapRetryHandlerIntegrationTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapRetryHandlerTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapServiceTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DummyShoulderTapInvokerImplTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerVehicleNotificationImplTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerWAMImplTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapRetryRecordDAOCacheImplTest.java create mode 100644 src/test/java/org/eclipse/ecsp/stream/scheduler/SchedulerAgentIntegrationTest.java create mode 100644 src/test/java/redis/embedded/RedisCluster408.java create mode 100644 src/test/java/redis/embedded/RedisSentinel408.java create mode 100644 src/test/java/redis/embedded/RedisServer408.java create mode 100644 src/test/resources/application-base-test.properties create mode 100644 src/test/resources/application.properties create mode 100644 src/test/resources/aws-props.properties create mode 100644 src/test/resources/backdoor-dao-test.properties create mode 100644 src/test/resources/cache-bypass-test.properties create mode 100644 src/test/resources/client-truststore.jks create mode 100644 src/test/resources/client.jks create mode 100644 src/test/resources/dlq-reprocessing-test.properties create mode 100644 src/test/resources/dma-backdoor-consumer-test.properties create mode 100644 src/test/resources/dma-connection-status-handler-test2.properties create mode 100644 src/test/resources/dma-connectionstatus-handler-test.properties create mode 100644 src/test/resources/dma-handler-fetch-conn-status-test.properties create mode 100644 src/test/resources/dma-handler-sub-services-test.properties create mode 100644 src/test/resources/dma-handler-test.properties create mode 100644 src/test/resources/dma-offline-multiple-device-test.properties create mode 100644 src/test/resources/dma-offline-test.properties create mode 100644 src/test/resources/dma-shouldertap-test.properties create mode 100644 src/test/resources/dma-test-kafka-dispatch.properties create mode 100644 src/test/resources/filter-dma-offline-test.properties create mode 100644 src/test/resources/hivemq-keystore.jks create mode 100644 src/test/resources/hivemq-mqtt-health-monitor.properties create mode 100644 src/test/resources/hivemq-test-mqtt-sub-services.properties create mode 100644 src/test/resources/hivemq-test-mqtt-without-topic-prefix.properties create mode 100644 src/test/resources/hivemq-test-mqtt.properties create mode 100644 src/test/resources/hivemq-test-ssl-mqtt.properties create mode 100644 src/test/resources/integration-test-application.properties create mode 100644 src/test/resources/kafka.client.keystore.jks create mode 100644 src/test/resources/kafka.client.truststore.jks create mode 100644 src/test/resources/logback.xml create mode 100644 src/test/resources/messageid-generator-test.properties create mode 100644 src/test/resources/mongo-sink-node.properties create mode 100644 src/test/resources/moquette.conf create mode 100644 src/test/resources/mqtt-health-monitor.properties create mode 100644 src/test/resources/mqtt.conf create mode 100644 src/test/resources/mqtt_ssl.conf create mode 100644 src/test/resources/notification-alerts-template-message.json create mode 100644 src/test/resources/offsetmanager-test.properties create mode 100644 src/test/resources/password_file.conf create mode 100644 src/test/resources/redis-server.exe create mode 100644 src/test/resources/redis-server.pdb create mode 100644 src/test/resources/scheduler-agent-test.properties create mode 100644 src/test/resources/server.jks create mode 100644 src/test/resources/stream-base-test.properties create mode 100644 src/test/resources/stream-base-test2.properties create mode 100644 src/test/resources/stream-base-vehicle-profile-test.properties create mode 100644 src/test/resources/test-mqtt-empty-to-device.properties create mode 100644 src/test/resources/test-mqtt-platform-invalid.properties create mode 100644 src/test/resources/test-mqtt-platform.properties create mode 100644 src/test/resources/test-mqtt-ssl-platform.properties create mode 100644 src/test/resources/test-mqtt-ssl.properties create mode 100644 src/test/resources/test-mqtt-sub-services.properties create mode 100644 src/test/resources/test-mqtt-without-topic-prefix.properties create mode 100644 src/test/resources/test-mqtt.properties create mode 100644 src/test/resources/topics-health-monitor-test.properties create mode 100644 src/test/resources/topics.txt diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..375f3bd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,41 @@ +name: Bug +description: File a bug report +title: "[BUG]: " +labels: ["Type: Bug", "Status: Triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you do? What happened? What did you expect to happen? + placeholder: Put your description of the bug here. + validations: + required: true + - type: textarea + id: versions + attributes: + label: Versions + description: What versions of the relevant software are you running? + placeholder: 1.0.0 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant log output + description: | + Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + Please check your logs before submission to ensure sensitive information is redacted. + render: shell + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](./CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 0000000..1990961 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,41 @@ +name: Documentation +description: Update or add documentation +title: "[DOCS]: " +labels: ["Type: Documentation", "Status: Triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill this out! + - type: textarea + id: describe-need + attributes: + label: Describe the need + description: What do you wish was different about our docs? + placeholder: Describe the need for documentation updates here. + validations: + required: true + - type: input + id: library_version + attributes: + label: Version + description: Do these docs apply to a specific version? + placeholder: 1.1.1 + validations: + required: false + - type: textarea + id: logs + attributes: + label: Relevant log output + description: | + Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + Please check your logs before submission to ensure sensitive information is redacted. + render: shell + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this documentation issue, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..3bf43c6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,41 @@ +name: Feature +description: Suggest an idea for a new feature or enhancement +title: "[FEAT]: " +labels: ["Type: Feature", "Status: Triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill this out! + - type: textarea + id: describe-need + attributes: + label: Describe the need + description: What do you want to happen? What problem are you trying to solve? + placeholder: Describe the need for the feature. + validations: + required: true + - type: input + id: library_version + attributes: + label: Library Version + description: Does this feature suggestion apply to a specific version? + placeholder: 1.0.0 + validations: + required: false + - type: textarea + id: logs + attributes: + label: Relevant log output + description: | + Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + Please check your logs before submission to ensure sensitive information is redacted. + render: shell + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this feature request, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/ISSUE_TEMPLATE/maintenance.yml b/.github/ISSUE_TEMPLATE/maintenance.yml new file mode 100644 index 0000000..5eff93c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/maintenance.yml @@ -0,0 +1,41 @@ +name: Maintenance +description: Dependencies, cleanup, refactoring, reworking of code +title: "[MAINT]: " +labels: ["Type: Maintenance", "Status: Triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill this out! + - type: textarea + id: describe-need + attributes: + label: Describe the need + description: What do you want to happen? + placeholder: Describe the maintenance need here. + validations: + required: true + - type: input + id: library_version + attributes: + label: Library Version + description: Does this maintenance apply to a specific version? + placeholder: v1.0.0 + validations: + required: false + - type: textarea + id: logs + attributes: + label: Relevant log output + description: | + Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + Please check your logs before submission to ensure sensitive information is redacted. + render: shell + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this request, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/actions/import-gpg-key/action.yaml b/.github/actions/import-gpg-key/action.yaml new file mode 100644 index 0000000..18f554b --- /dev/null +++ b/.github/actions/import-gpg-key/action.yaml @@ -0,0 +1,24 @@ +name: "Import GPG Key" +description: "Imports a GPG key given in the input" +inputs: + gpg-private-key: + required: true + description: "The GPG Private Key in plain text. Can be a sub-key." +runs: + using: "composite" + steps: + - name: List Keys + shell: bash + run: | + gpg -K --keyid-format=long + + - name: Import GPG Private Key + shell: bash + run: | + echo "use-agent" >> ~/.gnupg/gpg.conf + echo "pinentry-mode loopback" >> ~/.gnupg/gpg.conf + echo -e "${{ inputs.gpg-private-key }}" | gpg --import --batch + for fpr in $(gpg --list-keys --with-colons | awk -F: '/fpr:/ {print $10}' | sort -u); + do + echo -e "5\\ny\\n" | gpg --batch --command-fd 0 --expert --edit-key $fpr trust; + done \ No newline at end of file diff --git a/.github/workflows/dependencies-update.yaml b/.github/workflows/dependencies-update.yaml new file mode 100644 index 0000000..e159df7 --- /dev/null +++ b/.github/workflows/dependencies-update.yaml @@ -0,0 +1,58 @@ +name: "Update DEPENDENCIES file" + +on: + push: + branches: [ "*" ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'zulu' + cache: maven + + - name: Generate Dependencies file + run: mvn org.eclipse.dash:license-tool-plugin:license-check -Ddash.summary=DEPENDENCIES -P dash + + - name: Check if file was changed + run: | + if git diff --name-only ${{ github.base_ref }}...${{ github.sha }} | grep -e 'DEPENDENCIES'; then + echo "The file was changed" + echo "was_file_changed=true" >> "$GITHUB_ENV" + git + else + echo "The file was not changed" + echo "was_file_changed=false" >> "$GITHUB_ENV" + fi + + - name: Configure Git + if: ${{ env.was_file_changed }} == 'true' + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + + - name: Create pull request + if: ${{ env.was_file_changed }} == 'true' + uses: peter-evans/create-pull-request@v6 + with: + add-paths: | + DEPENDENCIES + token: ${{ secrets.ECSP_BOT_PAT }} + branch: chore/update-DEPENDENCIES + commit-message: "chore(dependencies): Update DEPENDENCIES" + delete-branch: true + title: Update DEPENDENCIES + body: | + This PR updates the DEPENDENCIES \ No newline at end of file diff --git a/.github/workflows/license-compliance.yml b/.github/workflows/license-compliance.yml new file mode 100644 index 0000000..969b36c --- /dev/null +++ b/.github/workflows/license-compliance.yml @@ -0,0 +1,44 @@ +name: License Compliance + +on: + push: + branches: [ "*" ] + paths-ignore: + - '**/NOTICE' + - '**/NOTICE.md' + - '**/CODE_OF_CONDUCT.md' + - '**/CONTRIBUTING.md' + - '**/SECURITY.md' + pull_request: + branches: [ "*" ] + workflow_dispatch: + +permissions: + pull-requests: read + contents: write + +jobs: + check-licenses: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'zulu' + cache: maven + server-id: github # Value of the distributionManagement/repository/id field of the pom.xml + settings-path: ${{ github.workspace }} # location for the settings.xml file + - name: Allow dash.sh to be executed + run: chmod +x ./eclipse-dash/dash.sh + - name: Generate List of dependencies + run: ./eclipse-dash/dash.sh + env: + GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }} + - name: Archive DEPENDENCIES file + if: always() + uses: actions/upload-artifact@v4 + with: + name: LICENSE_INFO + path: DEPENDENCIES diff --git a/.github/workflows/maven-build.yml b/.github/workflows/maven-build.yml new file mode 100644 index 0000000..4902b59 --- /dev/null +++ b/.github/workflows/maven-build.yml @@ -0,0 +1,74 @@ +# This workflow will build a package using Maven and run sonar scan on it + +name: Maven Packaging and Sonar Analysis + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + pull-requests: read + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'zulu' + + - name: Build with Maven + run: mvn clean -B package --file pom.xml + + check_sonar_configured: + runs-on: ubuntu-latest + steps: + - name: check_sonar_configured + run: | + echo "Checking if sonar is configured: ${{ env.SONAR_CONFIGURED }}" + env: + SONAR_CONFIGURED: ${{ secrets.SONAR_TOKEN != '' }} + outputs: + sonar_configured: ${{ env.SONAR_CONFIGURED }} + + + analysis_with_sonar_cloud: + needs: [check_sonar_configured] + # No need to run if we cannot use the sonar token + if: >- + needs.check_sonar_configured.outputs.sonar_configured == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + pull-requests: read + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'zulu' + server-id: github + settings-path: ${{ github.workspace }} + + - name: Analyze with SonarCloud + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + mvn --update-snapshots verify \ + org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \ + -Dsonar.projectKey=eclipse-ecsp_tranformers -Dsonar.organization=eclipse-ecsp \ + -Dcheckstyle.skip -Dpmd.skip=true diff --git a/.github/workflows/maven-deploy.yml b/.github/workflows/maven-deploy.yml new file mode 100644 index 0000000..a805da7 --- /dev/null +++ b/.github/workflows/maven-deploy.yml @@ -0,0 +1,76 @@ +# This workflow will deploy JAR to Maven Central repository + +name: Maven Deploy + +on: + release: + types: [created] + workflow_dispatch: + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + secret-presence: + runs-on: ubuntu-latest + outputs: + HAS_OSSRH: ${{ steps.secret-presence.outputs.HAS_OSSRH }} + steps: + - name: Check whether secrets exist + id: secret-presence + run: | + [ ! -z "${{ secrets.GPG_PASSPHRASE }}" ] && + [ ! -z "${{ secrets.GPG_PRIVATE_KEY }}" ] && + [ ! -z "${{ secrets.OSSRH_USERNAME }}" ] && + [ ! -z "${{ secrets.OSSRH_PASSWORD }}" ] && + echo "HAS_OSSRH=true" >> $GITHUB_OUTPUT + exit 0 + + publish-to-sonatype: + name: "Publish artifacts to OSSRH Snapshots / MavenCentral" + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + needs: [ secret-presence ] + + if: | + needs.secret-presence.outputs.HAS_OSSRH + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'zulu' + settings-path: ${{ github.workspace }} + + - uses: ./.github/actions/import-gpg-key + name: "Import GPG Key" + with: + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + + - name: Configure Maven settings + run: | + mkdir -p $HOME/.m2 + echo " + + + ossrh + ${{ secrets.OSSRH_USERNAME }} + ${{ secrets.OSSRH_PASSWORD }} + + + " > $HOME/.m2/settings.xml + + - name: Copy License information + run: | + mkdir -p streambase/src/main/resources/META-INF/ + cp LICENSE NOTICE.md DEPENDENCIES SECURITY.md streambase/src/main/resources/META-INF/ + + - name: Publish version + run: |- + VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) + mvn clean deploy -s $HOME/.m2/settings.xml -Dgpg.passphrase="${{ secrets.GPG_PASSPHRASE }}" -Prelease -Drevision=$VERSION \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..951dd81 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,3 @@ +# Eclipse Foundation Community Code of Conduct + +This project has adopted the [Eclipse Foundation Community Code of Conduct](https://raw.githubusercontent.com/eclipse/.github/master/CODE_OF_CONDUCT.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c5ac112 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# How to contribute + +Support and contributions from the open source community are essential for keeping +`eclipse-ecsp/streambase` up to date and always improving! There are a few guidelines that we need +contributors to follow to keep the project consistent, as well as allow us to keep +maintaining `eclipse-ecsp/streambase` in a reasonable amount of time. + +Please note that this project is released with a [Contributor Code of Conduct][coc]. + +By participating in this project you agree to abide by its terms. + +[coc]: ./CODE_OF_CONDUCT.template + +## Creating an Issue + +Before you create a new Issue: + +1. Please make sure there is no [open issue](https://github.com/eclipse-ecsp/streambase/issues) yet. +2. If it is a bug report, include the steps to reproduce the issue and please create a reproducible test case. +3. If it is a feature request, please share the motivation for the new feature and how you would implement it. +4. Please include links to the corresponding GitHub documentation. + +## Tests + +If you want to submit a bug fix or new feature, make sure that all tests are passing. + +```mvn clean test``` + +Or run a specific module tests: (example module-1) + +``` +mvn clean test -pl :module-1 +``` + +## Making Changes + +- Create a topic branch from the main branch. +- Check for unnecessary whitespace / changes with `git diff --check` before committing. +- Keep git commit messages clear and appropriate. Ideally follow commit conventions described below. + +## Submitting the Pull Request + +- Push your changes to your topic branch on your fork of the repo. +- Submit a pull request from your topic branch to the [main](https://github.com/eclipse-ecsp/streambase) branch on the `eclipse-ecsp/streambase` repository. +- Be sure to tag any issues your pull request is taking care of / contributing to. \* Adding "Closes #123" +to a pull request description will auto close the issue once the pull request is merged in. + + +## Merging a PR and Shipping a release (maintainers only) + +- A PR can only be merged into main branch by a maintainer if: CI is passing, approved by another maintainer and is up-to-date with the default branch. +- Ensure that the PR is tagged with related [issue](https://github.com/eclipse-ecsp/streambase/issues) it intends to resolve. +- Change log for all the PRs merged since the last release should be included in the release notes. +- Automatically generated release notes is configured for the repo and must be used while creating a new release tag. diff --git a/DEPENDENCIES b/DEPENDENCIES new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..31e4e63 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 HARMAN International Pvt. Ltd. + + 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. diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..5493aed --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,44 @@ +# Notices for eclipse-ecsp/streambase + +This content is produced and maintained by the Eclipse Connected Services Platform Project. + +* Project home: [https://projects.eclipse.org/projects/automotive.ecsp](https://projects.eclipse.org/projects/automotive.ecsp) + +## Trademarks + +eclipse-ecsp/streambase is a trademark of the Eclipse Foundation. + +## Copyright + +All content is the property of the respective authors or their employers. For +more information regarding authorship of content, please consult the listed +source code repository logs. + +## Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Apache License, Version 2.0 which is available at +https://www.apache.org/licenses/LICENSE-2.0. + +SPDX-License-Identifier: Apache-2.0 + +## Source Code + +The project maintains the following source code repositories: + +https://github.com/eclipse-ecsp/streambase + +## Third-party Content + +This project leverages the following third party content. + +stream-base(3.0.4)
      License: Apache-2.0
      Project: https://github.com/eclipse-ecsp/streambase


gson(2.8.9)
      License: Apache-2.0
      Project: https://github.com/google/gson/gson/


embedded-redis(0.6)
      License: Apache-2.0
      Project: https://github.com/kstyrc/embedded-redis


okhttp(4.12.0)
      License: Apache-2.0
      Project: https://github.com/square/okhttp


okio(3.6.0)
      License: Apache-2.0
      Project: https://github.com/square/okio/


okio-jvm(3.6.0)
      License: Apache-2.0
      Project: https://github.com/square/okio/


kotlin-stdlib-common(1.9.10)
      License: Apache-2.0
      Project: https://github.com/JetBrains/kotlin


kotlin-stdlib-jdk8(1.8.21)
      License: Apache-2.0
      Project: https://github.com/JetBrains/kotlin


kotlin-stdlib(1.8.21)
      License: Apache-2.0
      Project: https://github.com/JetBrains/kotlin


kotlin-stdlib-jdk7(1.8.21)
      License: Apache-2.0
      Project: https://github.com/JetBrains/kotlin


mockwebserver(4.12.0)
      License: Apache-2.0
      Project: https://github.com/square/okhttp


junit(4.13.2)
      License: EPL-1.0
      Project: https://github.com/junit-team/junit4


hamcrest-core(1.3)
      License: BSD-3-Clause
      Project: https://github.com/hamcrest/JavaHamcrest/hamcrest-core


kafka-streams(3.6.2)
      License: Apache-2.0
      Project:


rocksdbjni(7.9.2)
      License: Apache-2.0
      Project: scm:git:https://github.com/facebook/rocksdb.git


jackson-annotations(2.13.5)
      License: Apache-2.0
      Project: http://github.com/FasterXML/jackson-annotations


slf4j-api(2.0.13)
      License: MIT
      Project: https://github.com/qos-ch/slf4j/slf4j-parent/slf4j-api


junit-jupiter-api(5.5.2)
      License: EPL-2.0
      Project: https://github.com/junit-team/junit5


apiguardian-api(1.1.0)
      License: Apache-2.0
      Project: https://github.com/apiguardian-team/apiguardian


opentest4j(1.2.0)
      License: Apache-2.0
      Project: https://github.com/ota4j-team/opentest4j


junit-platform-commons(1.5.2)
      License: EPL-2.0
      Project: https://github.com/junit-team/junit5


junit-jupiter-engine(5.5.2)
      License: EPL-2.0
      Project: https://github.com/junit-team/junit5


junit-platform-engine(1.5.2)
      License: EPL-2.0
      Project: https://github.com/junit-team/junit5


junit-jupiter-migrationsupport(5.5.2)
      License: EPL-2.0
      Project: https://github.com/junit-team/junit5


junit-vintage-engine(5.5.2)
      License: EPL-2.0
      Project: https://github.com/junit-team/junit5


kryo-shaded(4.0.2)
      License: BSD-3-Clause
      Project: https://github.com/EsotericSoftware/kryo/kryo-shaded


minlog(1.3.0)
      License: BSD-3-Clause
      Project: https://github.com/EsotericSoftware/minlog


objenesis(2.5.1)
      License: Apache-2.0
      Project: https://github.com/easymock/objenesis/objenesis


kafka-clients(3.6.2)
      License: Apache-2.0
      Project:


zstd-jni(1.5.5-1)
      License: BSD-2-Clause
      Project:


lz4-java(1.8.0)
      License: Apache-2.0
      Project: git://github.com/lz4/lz4-java.git


snappy-java(1.1.10.5)
      License: Apache-2.0
      Project: https://github.com/xerial/snappy-java


kafka-clients(3.6.2)
      License: Apache-2.0
      Project:


kafka_2.13(3.6.2)
      License: Apache-2.0
      Project:


kafka-server-common(3.6.2)
      License: Apache-2.0
      Project:


pcollections(4.0.1)
      License: MIT
      Project: https://github.com/hrldcpr/pcollections


kafka-group-coordinator(3.6.2)
      License: Apache-2.0
      Project:


kafka-storage-api(3.6.2)
      License: Apache-2.0
      Project:


kafka-tools-api(3.6.2)
      License: Apache-2.0
      Project:


kafka-raft(3.6.2)
      License: Apache-2.0
      Project:


kafka-storage(3.6.2)
      License: Apache-2.0
      Project:


caffeine(2.9.3)
      License: Apache-2.0
      Project: https://github.com/ben-manes/caffeine


argparse4j(0.7.0)
      License: MIT
      Project: https://github.com/tatsuhiro-t/argparse4j


commons-validator(1.7)
      License: Apache-2.0
      Project: https://gitbox.apache.org/repos/asf/commons-validator


commons-beanutils(1.9.4)
      License: Apache-2.0
      Project: http://svn.apache.org/viewvc/commons/proper/beanutils/tags/BEANUTILS_1_9_3_RC3


commons-digester(2.1)
      License: Apache-2.0
      Project: http://svn.apache.org/viewvc/commons/proper/digester/tags/DIGESTER_2_1_RC2


commons-logging(1.2)
      License: Apache-2.0
      Project: http://svn.apache.org/repos/asf/commons/proper/logging/trunk


commons-collections(3.2.2)
      License: Apache-2.0
      Project: http://svn.apache.org/viewvc/commons/proper/collections/trunk


jackson-module-scala_2.13(2.13.5)
      License: Apache-2.0
      Project: https://github.com/FasterXML/jackson-module-scala


paranamer(2.8)
      License: BSD-4-Clause
      Project: https://github.com/paul-hammant/paranamer/paranamer


jackson-dataformat-csv(2.13.5)
      License: Apache-2.0
      Project: http://github.com/FasterXML/jackson-dataformats-text/jackson-dataformat-csv


jackson-datatype-jdk8(2.13.5)
      License: Apache-2.0
      Project: http://github.com/FasterXML/jackson-modules-java8/jackson-datatype-jdk8


jopt-simple(5.0.4)
      License: MIT
      Project: https://github.com/jopt-simple/jopt-simple


jose4j(0.9.4)
      License: Apache-2.0
      Project: https://bitbucket.org/b_c/jose4j


metrics-core(2.2.0)
      License: Apache-2.0
      Project: http://github.com/codahale/metrics/metrics-core/


scala-collection-compat_2.13(2.10.0)
      License: Apache-2.0
      Project: https://github.com/scala/scala-collection-compat


scala-java8-compat_2.13(1.0.2)
      License: Apache-2.0
      Project: https://github.com/scala/scala-java8-compat


scala-reflect(2.13.11)
      License: Apache-2.0
      Project: https://github.com/scala/scala


scala-logging_2.13(3.9.4)
      License: Apache-2.0
      Project: https://github.com/lightbend/scala-logging


commons-cli(1.4)
      License: Apache-2.0
      Project: http://svn.apache.org/viewvc/commons/proper/cli/trunk/


kafka_2.13(3.6.2)
      License: Apache-2.0
      Project:


kafka-metadata(3.6.2)
      License: Apache-2.0
      Project:


jackson-databind(2.13.5)
      License: Apache-2.0
      Project: http://github.com/FasterXML/jackson-databind


jackson-core(2.13.5)
      License: Apache-2.0
      Project: http://github.com/FasterXML/jackson-core


zookeeper(3.9.2)
      License: Apache-2.0
      Project: https://gitbox.apache.org/repos/asf/zookeeper.git/zookeeper


zookeeper-jute(3.9.2)
      License: Apache-2.0
      Project: https://gitbox.apache.org/repos/asf/zookeeper.git/zookeeper-jute


audience-annotations(0.12.0)
      License: Apache-2.0
      Project: https://github.com/apache/yetus.git/audience-annotations


netty-tcnative-boringssl-static(2.0.61.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty-tcnative/netty-tcnative-boringssl-static


netty-tcnative-classes(2.0.61.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty-tcnative/netty-tcnative-classes


netty-tcnative-boringssl-static(2.0.61.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty-tcnative/netty-tcnative-boringssl-static


netty-tcnative-boringssl-static(2.0.61.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty-tcnative/netty-tcnative-boringssl-static


netty-tcnative-boringssl-static(2.0.61.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty-tcnative/netty-tcnative-boringssl-static


netty-tcnative-boringssl-static(2.0.61.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty-tcnative/netty-tcnative-boringssl-static


netty-tcnative-boringssl-static(2.0.61.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty-tcnative/netty-tcnative-boringssl-static


curator-test(5.3.0)
      License: Apache-2.0
      Project: https://github.com/apache/curator.git/curator-test


kafka-streams-scala_2.13(3.6.2)
      License: Apache-2.0
      Project:


scala-library(2.13.14)
      License: Apache-2.0
      Project: https://github.com/scala/scala


kafka-metadata(3.6.2)
      License: Apache-2.0
      Project:


metrics-core(3.2.6)
      License: Apache-2.0
      Project: http://github.com/dropwizard/metrics/metrics-core/


joda-time(2.10.4)
      License: Apache-2.0
      Project: https://github.com/JodaOrg/joda-time


kryo-serializers(0.45)
      License: Apache-2.0
      Project:


kryo(5.0.0-RC1)
      License: BSD-3-Clause
      Project: https://github.com/EsotericSoftware/kryo/kryo


reflectasm(1.11.7)
      License: BSD-3-Clause
      Project: https://github.com/EsotericSoftware/reflectasm


simpleclient(0.6.0)
      License: Apache-2.0
      Project:


simpleclient_hotspot(0.6.0)
      License: Apache-2.0
      Project:


simpleclient_httpserver(0.6.0)
      License: Apache-2.0
      Project:


simpleclient_common(0.6.0)
      License: Apache-2.0
      Project:


simpleclient_servlet(0.6.0)
      License: Apache-2.0
      Project:


jaxb-api(2.2.8)
      License: CDDL-1.1
      Project: http://java.net/projects/jsr222/sources/svn/show/tags/jaxb-api-2.2.8


jaxb-core(2.3.0.1)
      License: CDDL-1.1
      Project: http://java.net/projects/jaxb/sources/v2/show/jaxb-bundles/jaxb-core


jaxb-impl(2.3.1)
      License: CDDL-1.1
      Project: http://java.net/projects/jaxb/sources/v2/show/jaxb-bundles/jaxb-impl


spring-test(5.3.34)
      License: Apache-2.0
      Project: https://github.com/spring-projects/spring-framework


spring-context(5.3.34)
      License: Apache-2.0
      Project: https://github.com/spring-projects/spring-framework


spring-aop(5.3.34)
      License: Apache-2.0
      Project: https://github.com/spring-projects/spring-framework


spring-beans(5.3.34)
      License: Apache-2.0
      Project: https://github.com/spring-projects/spring-framework


spring-expression(5.3.34)
      License: Apache-2.0
      Project: https://github.com/spring-projects/spring-framework


spring-core(5.3.34)
      License: Apache-2.0
      Project: https://github.com/spring-projects/spring-framework


spring-jcl(5.3.34)
      License: Apache-2.0
      Project: https://github.com/spring-projects/spring-framework


de.flapdoodle.embed.mongo(3.4.3)
      License: Apache-2.0
      Project:


de.flapdoodle.embed.process(3.1.7)
      License: Apache-2.0
      Project:


de.flapdoodle.os(1.1.5)
      License: Apache-2.0
      Project:


jna(5.10.0)
      License: LGPL-2.1-or-later
      Project: https://github.com/java-native-access/jna


jna-platform(5.10.0)
      License: LGPL-2.1-or-later
      Project: https://github.com/java-native-access/jna


de.flapdoodle.embed.mongo.packageresolver(1.0.1)
      License: Apache-2.0
      Project:


ignite-dao(3.0.3)
      License:
      Project:


snakeyaml(2.0)
      License: Apache-2.0
      Project: https://bitbucket.org/snakeyaml/snakeyaml/src


morphia-core(2.2.3)
      License:
      Project:


classgraph(4.8.78)
      License: MIT
      Project: https://github.com/classgraph/classgraph


spotbugs-annotations(3.1.9)
      License: LGPL-2.1-only
      Project: https://github.com/spotbugs/spotbugs/


mongodb-driver-sync(4.5.1)
      License: Apache-2.0
      Project: https://github.com/mongodb/mongo-java-driver


bson(4.5.1)
      License: Apache-2.0
      Project: https://github.com/mongodb/mongo-java-driver


mongodb-driver-core(4.5.1)
      License: Apache-2.0
      Project: https://github.com/mongodb/mongo-java-driver


mongodb-driver-legacy(4.5.1)
      License: Apache-2.0
      Project: https://github.com/mongodb/mongo-java-driver


commons-compress(1.26.2)
      License: Apache-2.0
      Project: https://gitbox.apache.org/repos/asf?p=commons-compress.git


commons-lang3(3.14.0)
      License: Apache-2.0
      Project: https://gitbox.apache.org/repos/asf?p=commons-lang.git


commons-io(2.16.1)
      License: Apache-2.0
      Project: https://gitbox.apache.org/repos/asf?p=commons-io.git


reactor-core(3.3.10.RELEASE)
      License: Apache-2.0
      Project: https://github.com/reactor/reactor-core


reactive-streams(1.0.3)
      License: CC0-1.0
      Project:


ignite-cache(3.0.3)
      License:
      Project:


jcl-over-slf4j(2.0.13)
      License: Apache-2.0
      Project: https://github.com/qos-ch/slf4j/slf4j-parent/jcl-over-slf4j


redisson(3.31.0)
      License: Apache-2.0
      Project: scm:git:git@github.com:redisson/redisson.git/redisson


netty-common(4.1.109.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty/netty-common


netty-codec(4.1.109.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty/netty-codec


netty-buffer(4.1.109.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty/netty-buffer


netty-transport(4.1.109.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty/netty-transport


netty-resolver(4.1.109.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty/netty-resolver


netty-resolver-dns(4.1.109.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty/netty-resolver-dns


netty-codec-dns(4.1.109.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty/netty-codec-dns


netty-handler(4.1.109.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty/netty-handler


cache-api(1.1.1)
      License: Apache-2.0
      Project:


rxjava(3.1.8)
      License: Apache-2.0
      Project: https://github.com/ReactiveX/RxJava


jodd-util(6.2.2)
      License: BSD-2-Clause
      Project: https://github.com/oblac/jodd-util.git


jackson-dataformat-yaml(2.17.1)
      License: Apache-2.0
      Project: https://github.com/FasterXML/jackson-dataformats-text/jackson-dataformat-yaml


redis-jar(2.6.0.5)
      License:
      Project:


ignite-transformers(3.0.3)
      License:
      Project:


fst(2.57)
      License: Apache-2.0
      Project: https://github.com/RuedigerMoeller/fast-serialization/


javassist(3.21.0-GA)
      License: MPL-1.1
      Project: scm:git:git@github.com:jboss-javassist/javassist.git


mockito-core(3.12.4)
      License: MIT
      Project: https://github.com/mockito/mockito.git


byte-buddy(1.11.13)
      License: Apache-2.0
      Project:


byte-buddy-agent(1.11.13)
      License: Apache-2.0
      Project:


org.eclipse.paho.client.mqttv3(1.2.2)
      License: EPL-2.0
      Project: http://git.eclipse.org/c/paho/org.eclipse.paho.mqtt.java.git/org.eclipse.paho.client.mqttv3


moquette-broker(0.17)
      License: Apache-2.0
      Project:


slf4j-reload4j(1.7.36)
      License: MIT
      Project: https://github.com/qos-ch/slf4j/slf4j-reload4j


reload4j(1.2.19)
      License: Apache-2.0
      Project: https://github.com/qos-ch/reload4j


netty-codec-mqtt(4.1.93.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty/netty-codec-mqtt


netty-transport-native-epoll(4.1.93.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty/netty-transport-native-epoll


netty-transport-native-unix-common(4.1.93.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty/netty-transport-native-unix-common


netty-transport-classes-epoll(4.1.93.Final)
      License: Apache-2.0
      Project: https://github.com/netty/netty/netty-transport-classes-epoll


h2-mvstore(2.1.212)
      License: MPL-2.0
      Project: https://github.com/h2database/h2database


HikariCP(2.4.7)
      License: Apache-2.0
      Project:


metrics-jvm(3.2.2)
      License: Apache-2.0
      Project: http://github.com/dropwizard/metrics/metrics-jvm/


metrics-librato(5.1.0)
      License: Apache-2.0
      Project: https://github.com/librato/metrics-librato


librato-java(2.1.0)
      License: Apache-2.0
      Project: https://github.com/librato/librato-java


bugsnag(3.7.1)
      License: MIT
      Project: https://github.com/bugsnag/bugsnag-java


commons-codec(1.15)
      License: Apache-2.0
      Project: https://github.com/apache/commons-codec


junit-platform-launcher(1.0.1)
      License: EPL-2.0
      Project: https://github.com/junit-team/junit5


jsonassert(1.5.0)
      License: Apache-2.0
      Project:


android-json(0.0.20131108.vaadin1)
      License: Apache-2.0
      Project: http://developer.android.com/sdk/


spring-boot-starter(2.7.18)
      License: Apache-2.0
      Project: https://github.com/spring-projects/spring-boot


spring-boot(2.7.18)
      License: Apache-2.0
      Project: https://github.com/spring-projects/spring-boot


spring-boot-autoconfigure(2.7.18)
      License: Apache-2.0
      Project: https://github.com/spring-projects/spring-boot


jakarta.annotation-api(1.3.5)
      License: EPL-2.0
      Project: https://github.com/eclipse-ee4j/common-annotations-api


ignite-utils(3.0.3)
      License:
      Project:


spring-boot-starter-aop(2.7.18)
      License: Apache-2.0
      Project: https://github.com/spring-projects/spring-boot


aspectjweaver(1.9.7)
      License: EPL-2.0
      Project: https://github.com/eclipse/org.aspectj


ignite-entities(3.0.3)
      License:
      Project:


logback-classic(1.5.5)
      License: EPL-1.0
      Project: https://github.com/qos-ch/logback/logback-classic


logback-core(1.5.5)
      License: EPL-1.0
      Project: https://github.com/qos-ch/logback/logback-core


reflections(0.9.11)
      License: WTFPL
      Project: https://github.com/ronmamo/reflections/issues


jackson-datatype-jsr310(2.15.3)
      License: Apache-2.0
      Project: http://github.com/FasterXML/jackson-modules-java8/jackson-datatype-jsr310


hibernate-validator(6.1.5.Final)
      License: Apache-2.0
      Project: http://github.com/hibernate/hibernate-validator/hibernate-validator


jakarta.validation-api(2.0.2)
      License: Apache-2.0
      Project: https://github.com/eclipse-ee4j/beanvalidation-api


jboss-logging(3.3.2.Final)
      License: Apache-2.0
      Project: https://github.com/jboss-logging/jboss-logging


classmate(1.3.4)
      License: Apache-2.0
      Project: http://github.com/FasterXML/java-classmate


hibernate-validator-annotation-processor(6.1.5.Final)
      License: Apache-2.0
      Project: http://github.com/hibernate/hibernate-validator/hibernate-validator-annotation-processor


javax.el-api(3.0.0)
      License:
      Project: http://java.net/projects/el-spec/sources/source-code/show/tags/javax.el-api-3.0.0


javax.el(2.2.6)
      License:
      Project: http://java.net/projects/uel/sources/svn/show/tags/javax.el-2.2.6


logback-gelf(6.0.1)
      License:
      Project: https://github.com/osiegmar/logback-gelf


guava(33.1.0-jre)
      License: Apache-2.0
      Project: https://github.com/google/guava/guava


failureaccess(1.0.2)
      License: Apache-2.0
      Project: https://github.com/google/guava/failureaccess


listenablefuture(9999.0-empty-to-avoid-conflict-with-guava)
      License: Apache-2.0
      Project: https://github.com/google/guava/listenablefuture


jsr305(3.0.2)
      License: Apache-2.0
      Project: https://code.google.com/p/jsr-305/


checker-qual(3.42.0)
      License: MIT
      Project: https://github.com/typetools/checker-framework.git


error_prone_annotations(2.26.1)
      License: Apache-2.0
      Project: https://github.com/google/error-prone/error_prone_annotations


j2objc-annotations(3.0.0)
      License: Apache-2.0
      Project: http://github.com/google/j2objc


hivemq-mqtt-client(1.3.0)
      License: Apache-2.0
      Project: https://github.com/hivemq/hivemq-mqtt-client.git


rxjava(2.2.19)
      License: Apache-2.0
      Project: scm:git:git@github.com:ReactiveX/RxJava.git


jctools-core(2.1.2)
      License: Apache-2.0
      Project: https://github.com/JCTools/JCTools


annotations(16.0.3)
      License: Apache-2.0
      Project: https://github.com/JetBrains/java-annotations


dagger(2.27)
      License: Apache-2.0
      Project: https://github.com/google/dagger/


javax.inject(1)
      License: Apache-2.0
      Project: http://code.google.com/p/atinject/source/checkout


testcontainers(1.17.6)
      License: MIT
      Project: https://github.com/testcontainers/testcontainers-java/


duct-tape(1.0.8)
      License: MIT
      Project: https://github.com/rnorth/duct-tape


docker-java-api(3.2.13)
      License: Apache-2.0
      Project:


docker-java-transport-zerodep(3.2.13)
      License: Apache-2.0
      Project:


docker-java-transport(3.2.13)
      License: Apache-2.0
      Project:


junit-jupiter-params(5.5.2)
      License: EPL-2.0
      Project: https://github.com/junit-team/junit5


+ +## Cryptography + +Content may contain encryption software. The country in which you are currently +may have restrictions on the import, possession, and use, and/or re-export to +another country, of encryption software. BEFORE using any encryption software, +please check the country's laws, regulations and policies concerning the import, +possession, or use, and re-export of encryption software, to see if this is +permitted. diff --git a/README.md b/README.md index a09d09e..be96566 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,374 @@ -# streambase -Enabler for event-driven microservices and MQTT communication +[](./images/logo.png) + +# Stream-base +[![Build](https://github.com/eclipse-ecsp/streambase/actions/workflows/maven-publish.yml/badge.svg)](https://github.com/eclipse-ecsp/streambase/actions/workflows/maven-publish.yml) +[![License Compliance](https://github.com/eclipse-ecsp/streambase/actions/workflows/license-compliance.yml/badge.svg)](https://github.com/eclipse-ecsp/streambase/actions/workflows/license-compliance.yml) + +`streambase` library provides a layer of abstraction for kafka-streams to the similar stream processing systems and allows them to have some level of portability. + +It provides a `LauncherProvider` interface to the services to which in turn provides the service with the capabilities to create a stream processing application. +The `Launcher` class in coordination with the `StreamProcessorDiscoveryService` creates a topology as a chain of `StreamProcessor` in the kafka streams application. + +![img.png](images/img.png) + +It provides the below capabilities to the streaming services: +1. Implements a discovery service `StreamProcessorDiscoveryService` to create the chain of processors (preprocessors and postprocessors along with service processors ) in the stream. +2. Implements a `KafkaTopicsHealthMonitor` to monitor the health of the topics. +3. Implements a `BackdoorKafkaConsumer` to create customized consumers along with callback capabilities. +4. Utilizes both in-memory and RocksDB state stores depending upon the configuration. Also, provides the metrics reporting capabilities for the RocksDB state store. +5. Manages the state of the topics, partitions and offsets in the database. +6. Provides TLS capabilities by integrating with vault. +7. Integrates with both Paho MQTT client and Hive MQTT client and allows for dynamic switching between the two with zero scheduled downtime and uses `MqttDispatcher` to dispatch the messages to the mqtt client. + + +# Table of Contents +* [Getting Started](#getting-started) +* [Usage](#usage) +* [How to contribute](#how-to-contribute) +* [Built with Dependencies](#built-with-dependencies) +* [Code of Conduct](#code-of-conduct) +* [Authors](#authors) +* [Security Contact Information](#security-contact-information) +* [Support](#support) +* [Troubleshooting](#troubleshooting) +* [License](#license) +* [Announcements](#announcements) + + +## Getting Started + +To build the project locally after it has been forked/cloned, run: + +```mvn clean install``` + +from the command line interface. + +### Prerequisites + +1. Maven +2. Java 17 +3. Redis +4. MongoDB instance +5. HiveMQ instance + +### Installation + +[How to set up maven](https://maven.apache.org/install.html) + +[Install Java](https://stackoverflow.com/questions/52511778/how-to-install-openjdk-11-on-windows) + +[How to Install Redis](https://redis.io/docs/install/install-redis/) + +[How to Install MongoDB](https://www.mongodb.com/docs/manual/installation/) + +[How to Install HiveMQ](https://docs.hivemq.com/hivemq/latest/user-guide/install-hivemq.html) + +### Running the tests + +```mvn test``` + +Or run a specific test + +```mvn test -Dtest="TheFirstUnitTest"``` + +To run a method from within a test + +```mvn test -Dtest="TheSecondUnitTest#whenTestCase2_thenPrintTest2_1"``` + +### Deployment + +`stream-base` project serves as a library for the services. It is not meant to be deployed as a service in any cloud environment. + +## Usage +Add the following dependency in the target project +``` + + org.eclipse.ecsp.analytics + stream-base + 1.X.X + +``` + +### Creating a Stream Processor + +To create a kafka stream processor, the service need to create an object of type `StreamProcessor` and specify the Serdes. +2 sets of Serdes are required, one for the incoming key and value types and the other for the outgoing key and value types. + +Ex: + +```java +/** + * This interface needs to be implemented by all Ignite-Auto Stream Processors + * henceforth. The signature is IgniteKey and IgniteEvent which will be common + * across the stream processors in Ignite systems. Also, this will be key for + * processor chaining. + * + */ +public interface IgniteEventStreamProcessor extends StreamProcessor, IgniteEvent, IgniteKey, IgniteEvent> { + +} +``` +The `IgniteEventStreamProcessor` interface needs to be implemented by all Ignite-Auto Stream Processors. + +The above stream processor can be classified into these categories: +- Preprocessor +- Service Processor +- Postprocessor + +As highlighted above, the discovery service is responsible for creating the kafka topology with the above set of processors. +The service processor/s will be sandwiched between the preprocessor and postprocessor by the discovery service. + +```properties +discovery.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl +launcher.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.KafkaStreamsLauncher +pre.processors=org.eclipse.ecsp.analytics.stream.base.processors.TaskContextInitializer,org.eclipse.ecsp.analytics.stream.base.processors.ProtocolTranslatorPreProcessor,org.eclipse.ecsp.digitalkey.sp.processor.VehicleStatePreProcessor,org.eclipse.ecsp.platform.dff.agent.processors.DFFAgentPreProcessor,org.eclipse.ecsp.analytics.stream.base.processors.DeviceMessagingAgentPreProcessor +service.stream.processors=org.eclipse.ecsp.digitalkey.sp.DigitalKeyMessageProcessor +post.processors=org.eclipse.ecsp.digitalkey.sp.processor.IgniteEventWithSignaturePostProcessor,org.eclipse.ecsp.analytics.stream.base.processors.SchedulerAgentPostProcessor,org.eclipse.ecsp.analytics.stream.base.processors.DeviceMessagingAgentPostProcessor,org.eclipse.ecsp.platform.dff.agent.processors.DFFAgentPostProcessor +``` + +Here, we have specified to use `PropBasedDiscoveryServiceImpl` as the discovery service as we want to create the topology based on the properties specified. +Also, we have used `KafkaStreamsLauncher` as the launcher class which will be utilized by the discovery service to create the topology. + +The source topics and the sink topics are provided by the application with these properties: + +```properties +source.topic.name=spaak,activation,dk-termination-response +sink.topic.name=https-integ-high +``` + +All the processors (pre, service, post) will be subscribed to the source topics and will publish to the sink topics. +If there are no sinkers specified, the last processor in the chain will publish to the sink topics. + +### Topics Health Monitoring + +The topics whose health need to be monitored can be specified in the properties file as shown below: + +```properties +kafka.topics.file.path=/data/topics.txt +``` + +The structure of the file specifies the topic name along with the number of partitions and the replication factor as shown below: + +```txt + spaak|25|2 + activation|25|2 + dk-termination-response|25|2 + digital-key-sp-dlq|25|2 + dk_service_provisioning|25|2 + device-status-digital-key-sp|25|2 + dev-stolenvehicle|25|2 + vehicle-profile-modified-authorized-users|25|2 + vehicle-profile-modified-serial-no|25|2 +``` + +> **_NOTE:_** The above topics must be existing in the kafka cluster. + +### State stores + +Following state stores are supported by the `streambase` library: +- In-memory state store +- RocksDB state store + +To specify the type of state store to be used by the service along with other state store properties: + +```properties +state.dir=/tmp/kafka-streams +state.store.changelog.enabled=false +state.store.type=map +``` + +> **_NOTE:_** A `StreamProcessor` must not provide the implementation of `createStateStore` method, if they want HashMap as state store instead of the RocksDB store. + +#### Customizing state store and state store type + +The state store can be customized while creating an instance of `StreamProcessor` by overriding the `createStateStore` method. However, the `StreamProcessor` must not provide the implementation of `createStateStore` method, if they want HashMap as state store instead of the RocksDB store. + +Example: + +```java + @Override + public HarmanPersistentKVStore createStateStore() { + // + } +``` + +### In-memory cache + +There are various in-memory caches maintained by streambase library for its internal use along with maintaining the metrics for each. Some of them are following: + +- RetryRecord cache. +- RetryBucket cache. +- Connection status cache. +- Shoulder Tap RetryRecord cache. +- Shoulder Tap RetryBucket cache. + +More documentation regarding the low-level design for this can be found [here](https://confluence.harman.com/confluence/display/HCP/Stream-Base+Metrics) + +### Kafka configuration + +To connect to kafka, the following properties need to be specified in the properties file along with the optional TLS properties: + +```properties +#Comma separated list of kafka brokers +bootstrap.servers=127.0.0.1:9092 +#Comma separated list of zookeepers +zookeeper.connect=127.0.0.1:2181 + +# TLS properties +kafka.ssl.enable=false +kafka.ssl.client.auth=required +kafka.client.keystore=keystore.jks +kafka.client.keystore.password=**************** +kafka.client.key.password=**************** +kafka.client.truststore=truststore.jks +kafka.client.truststore.password=**************** +``` + +### Vault Configuration + +Vault can be configured to provide the TLS security details to the service to connect to the kafka broker. The following properties need to provided: + +```properties +vault.enabled=false +health.vault.monitor.enabled=false +health.vault.needs.restart.on.failure=false +``` + +### MongoDB configuration + +To connect to the MongoDB instance, the following properties need to be specified in the properties file: + +```properties +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=admin +mongodb.pool.max.size=200 +``` + +### Redis configuration + +To connect to the Redis instance for caching capabilities, the following properties need to be specified in the properties file: + +```properties +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.database=0 +redis.password= +``` + +### MQTT configuration + +To connect to the MQTT broker for publishing to the MQTT topics, the following properties need to be specified in the properties file: + +```properties +mqtt.broker.url=tcp://127.0.0.1:1883 +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=******************* +mqtt.user.password=simulator16 +mqtt.service.topic.name=test +``` + +### Health configuration + +Health monitoring for different components can be enabled by specifying the following properties: + +```properties +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +health.vault.monitor.enabled=false +``` + +### Enabling DMA/SCHEDULER Module Configurations For StreamBase + +To enable the DFF/DMA and DMA scheduler services, the following properties need to be specified in the properties file: + +```properties +# +dma.enabled=false +scheduler.enabled=false +``` + + +## Built With Dependencies + +| Dependency | Purpose | +|------------------------------------------------------------------------------------------------------------------------------------------------------------|:------------------------------------------------------------------| +| [NoSQL DAO](https://github.com/eclipse-ecsp/nosql-dao) | NoSQL DAO capabilities | +| [Cache Enabler](https://github.com/eclipse-ecsp/cache-enabler) | Redis caching capabilities | +| [Utils](https://github.com/eclipse-ecsp/utils) | Utils library | +| [Transformers](https://github.com/eclipse-ecsp/transformers) | Transformers and serialization capabilities | +| [Paho Client](https://eclipse.dev/paho/) | MQTT Paho Client | +| [Moquette Broker](https://github.com/moquette-io/moquette) | Java MQTT Broker | +| [HiveMQ Mqtt client](https://www.hivemq.com/mqtt/mqtt-client-library-encyclopedia/) | HiveMQ client for MQTT | +| [Vehicle Profile Entities]() | Morphia entities library for Vehicle Profile | +| [Kafka Streams](https://kafka.apache.org/documentation/streams/#:~:text=Kafka%20Streams%20is%20a%20client,Kafka's%20server%2Dside%20cluster%20technology.) | For creating kafka streams application | +| [Kafka Clients](https://kafka.apache.org/documentation/streams/#:~:text=Kafka%20Streams%20is%20a%20client,Kafka's%20server%2Dside%20cluster%20technology.) | For providing kafka client capabilities | +| [Kafka 2.13](https://kafka.apache.org/downloads) | Core kafka capabilities | +| [Kryo](https://github.com/EsotericSoftware/kryo) | binary object graph serialization framework for Java | +| [Zookeeper](https://zookeeper.apache.org/) | Enables highly reliable distributed coordination in kafka cluster | +| [Embedded Redis](https://github.com/kstyrc/embedded-redis) | The library to implement database entities | +| [okhttp](https://square.github.io/okhttp/) | HTTP client library | +| [Mock Web Server](https://github.com/square/okhttp/tree/master/mockwebserver) | A scriptable web server for testing HTTP clients | +| [Maven](https://maven.apache.org/) | Dependency Management | +| [Junit](https://junit.org/junit5/) | Testing framework | +| [Mockito](https://site.mockito.org/) | Test Mocking framework | + +## How to contribute + +Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for details on our contribution guidelines, and the process for submitting pull requests to us. + +## Code of Conduct + +Please read [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) for details on our code of conduct. + +## Authors + + + + + + + + + + + + + +
Kaushal Arora
Kaushal Arora

📖 👀
Hussain Badshah
Hussain Badshah

📖 👀
+ +See also the list of [contributors](https://github.com/eclipse-ecsp/streambase/graphs/contributors) who participated in this project. + +## Security Contact Information + +Please read [SECURITY.md](./SECURITY.md) to raise any security related issues. + +## Support +Please write to us at csp@harman.com + +## Troubleshooting + +Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for details on how to raise an issue and submit a pull request to us. + +## License + +This project is licensed under the Apache-2.0 License - see the [LICENSE.md](./LICENSE.md) file for details. + +## Announcements + +All updates to this library are documented in our [Release notes](./release_notes.txt) and [releases](https://github.com/eclipse-ecsp/streambase/releases) +For the versions available, see the [tags on this repository](https://github.com/eclipse-ecsp/streambase/tags). + + + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4773bf0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +## Security Policy + +Thanks for helping make GitHub Open Source Software safe for everyone. + +GitHub takes the security of our software products and services seriously, including all the open source code repositories managed through our GitHub organizations, such as [HARMAN-Automotive](https://github.com/HARMAN-Automotive). + +Even though [open source repositories are outside of the scope of our bug bounty program](https://bounty.github.com/index.html#scope) and therefore not eligible for bounty rewards, we want to make sure that your finding gets passed along to the maintainers of this project for remediation. + +## Reporting a Vulnerability + +Since this source is part of [HARMAN-Automotive](https://github.com/HARMAN-Automotive) (a GitHub organization) we ask that you follow the guidelines [here](https://github.com/github/.github/blob/master/SECURITY.md#reporting-security-issues) to report anything that you might've found. + +## Dependency Security Management + +This project uses Dependabot tool to monitor (and fix) vulnerabilities in this project's dependencies. + +### Dependabot + +* [Dependabot](https://docs.github.com/en/free-pro-team@latest/github/managing-security-vulnerabilities/about-dependabot-security-updates) is a GitHub Security Feature. It tracks vulnerabilities in several languages including JavaScript. +* When Dependabot detects any vulnerabilities in the [GitHub Advisory Database](https://docs.github.com/en/free-pro-team@latest/github/managing-security-vulnerabilities/browsing-security-vulnerabilities-in-the-github-advisory-database), it sends a notification and may also open a pull request to fix the vulnerability. +* Only project maintainers can see Dependabot alerts \ No newline at end of file diff --git a/checkstyle-suppressions.xml b/checkstyle-suppressions.xml new file mode 100644 index 0000000..af21860 --- /dev/null +++ b/checkstyle-suppressions.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/harman_checks.xml b/harman_checks.xml new file mode 100644 index 0000000..d9eeb54 --- /dev/null +++ b/harman_checks.xml @@ -0,0 +1,422 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/img.png b/images/img.png new file mode 100644 index 0000000000000000000000000000000000000000..1a3db5987ddb02d771dc7485c034bf9b6b235351 GIT binary patch literal 107029 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

-ey--q98V>)|<>)um2Bq60bV=aX_MJ|Y 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

%M{&G+$veRi|>Z8J#GffJhH2^yG?Ne}8G5 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�ggM^H)Gx)Kk~_cq#H&L;)1_Ff$(o=7{&6=i0w!MRF5+S{3(O^57rb z@0KM3Iy3CKGucQQcI|oe=fCIhxX=f6%n1DPpA>PBZ+Hdon{#?oeT>X0M71f-prT>R zx`}^`V~?po9eL3nKUC;ehY{lrqWb-|yhGlLZ3!<=0&-z}gXh4?_uH!pGLk#o$@86S znk{N3s(PlHyg$CyM=fH2&x!$23`G@Tna);v`&{?(eS4&YX_L>(94?xWtY~~e$%EQ@ z@)<|NfCdghwLH8$EVS-a` zn*R$kw=`>eGHpOXp@9%SGCom-#&e3J?J3EXEjZ|WMmWyapk4qBJ9F=|vO^N^vj*{s z^YFfV#+|O0t(hVMeUswO%-Q!HKHd6v0o9HN6~X*zO(;a!JZ>WblK{r)pzjxwiLTvuiOnc6iBG%uhQCu*$A0Mh8|arFX>FV<+y|U$ zo%Uz*#gaG|Ld!1KmfmE3FnBOgf<-nK7(a}aOhj18K1v9`{(UOqPivH=;7O-LQAoa> ze*5?9qnk{f-N1L#!}F%pqYHHwlSO^vw(P^V^M`ZmQ`QmJ{KZD=)J=&EuT1t(A4Je_ z%CZmcgoB>>zaJr@Lj&1(T>aoZUCl literal 0 HcmV?d00001 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..27d1015 --- /dev/null +++ b/pom.xml @@ -0,0 +1,1259 @@ + + + + + 4.0.0 + org.eclipse.ecsp + streambase + 1.0-SNAPSHOT + streambase + Abstraction over Kafka Streams and Device Messaging Capability + https://github.com/eclipse-ecsp/streambase + + + scm:git:https://github.com/eclipse-ecsp/streambase.git + https://github.com/eclipse-ecsp/streambase + HEAD + + + + + ihussainbadshah + Hussain Badshah + Hussain.Badshah@harman.com + + + + + + Apache-2.0 + http://www.apache.org/licenses/LICENSE-2.0 + + + + + GitHub + https://github.com/eclipse-ecsp/streambase/issues + + + + https://github.com/eclipse-ecsp + eclipse-ecsp + + + + 17 + UTF-8 + 3.6.2 + 3.9.2 + 5.5.2 + 2.9.8 + 2.0.13 + -Xdoclint:none + 2.18.1 + 2.10 + 6.1.14 + 3.3.3 + 6.1.14 + 3.4.3 + 4.13.2 + 1.0.0 + 1.1.0 + 1.0.0 + 1.1.0 + 1.1.1 + 2.13.14 + 5.3.0 + 2.6.0.5 + 2.34 + 3.1.0 + + 0.6.0 + + + jacoco + 17 + 17 + ${project.build.directory}/site/jacoco-ut/jacoco.xml + + java + ${project.build.directory}/coverage-reports/jacoco-ut.exec + + + 1.2.2 + 1.3.0 + 7.3.3 + + 0.17 + + + + src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessingContext.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessor.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamBaseConstant.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/PropertyNames.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/Abstract*.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Constants.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaTestUtils.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/RetryUtils.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanPersistentPrimitiveMapValueStore.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/ObjectStateStore.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/SortedKeyValueStore.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/MapObjectStateStore.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanRocksDBStore.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/SinkNode.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/JsonStateStore.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/SerializedKVIterator.java, + src/main/java/org/eclipse/ecsp/stream/dma/dao/DMAConstants.java, + src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessageHandler.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/GenericValue.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperForMap.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventParser.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventParseException.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperForSequence.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/InternalCacheConstants.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/DeviceConnectionStatusParser.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanPersistentKVStore.java, + src/main/java/org/eclipse/ecsp/analytics/stream/base/discovery/SPIDiscoveryServiceImpl.java + + + 10.13.0 + 3.3.1 + ${project.basedir}/harman_checks.xml + ${project.basedir}/checkstyle-suppressions.xml + + ${project.build.directory}/checkstyle-result.xml + + + --add-opens=java.base/java.io=ALL-UNNAMED + --add-opens=java.base/java.lang=ALL-UNNAMED + --add-opens=java.base/java.math=ALL-UNNAMED + --add-opens=java.base/java.util=ALL-UNNAMED + --add-opens=java.base/java.util.concurrent=ALL-UNNAMED + --add-opens=java.base/java.net=ALL-UNNAMED + --add-opens=java.base/java.text=ALL-UNNAMED + --add-opens=java.sql/java.sql=ALL-UNNAMED + --add-opens java.base/java.time=ALL-UNNAMED + + 1.17.6 + 2.8.9 + 1.1.0 + 3.1.1 + + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + + + dash-licenses-releases + https://repo.eclipse.org/content/repositories/dash-licenses-releases/ + + false + + + + + + + com.google.code.gson + gson + ${gson.version} + + + com.github.kstyrc + embedded-redis + 0.6 + test + + + com.google.guava + guava + + + commons-io + commons-io + + + + + com.squareup.okhttp3 + okhttp + 4.12.0 + + + com.squareup.okhttp3 + mockwebserver + 4.12.0 + test + + + com.squareup.okhttp3 + okhttp + + + junit + junit + + + io.netty + netty-transport-native-epoll + + + + + junit + junit + ${junit4.version} + test + + + org.apache.kafka + kafka-streams + ${kafka.version} + + + com.fasterxml.jackson.core + jackson-databind + + + org.slf4j + slf4j-api + + + + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-migrationsupport + ${junit.version} + test + + + junit + junit + + + + + org.junit.vintage + junit-vintage-engine + ${junit.version} + test + + + junit + junit + + + + + com.esotericsoftware + kryo + 5.6.0 + + + org.apache.kafka + kafka-clients + ${kafka.version} + + + org.slf4j + slf4j-api + + + + + org.apache.kafka + kafka-clients + ${kafka.version} + test + + + org.slf4j + slf4j-api + + + + + org.apache.kafka + kafka_2.13 + ${kafka.version} + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-annotations + + + org.slf4j + slf4j-log4j12 + + + org.apache.kafka + kafka-metadata + + + + + org.apache.kafka + kafka_2.13 + ${kafka.version} + test + + + org.slf4j + slf4j-api + + + commons-logging + commons-logging + + + + + org.apache.zookeeper + zookeeper + ${zookeeper.version} + + + org.slf4j + slf4j-log4j12 + + + log4j + log4j + + + io.netty + netty-handler + + + io.netty + netty-transport-native-epoll + + + org.slf4j + slf4j-api + + + commons-io + commons-io + + + ch.qos.logback + logback-classic + + + ch.qos.logback + logback-core + + + + + org.apache.curator + curator-test + ${curator.version} + + + org.apache.kafka + kafka-streams-scala_2.13 + ${kafka.version} + + + org.scala-lang + scala-library + + ${scala.version} + + + org.apache.kafka + kafka-metadata + ${kafka.version} + test + test + + + + + io.dropwizard.metrics + metrics-core + 3.2.6 + + + org.slf4j + slf4j-api + + + + + joda-time + joda-time + 2.10.4 + + + de.javakaffee + kryo-serializers + 0.45 + + + + io.prometheus + simpleclient + ${prometheus.client.version} + + + + io.prometheus + simpleclient_hotspot + ${prometheus.client.version} + + + + io.prometheus + simpleclient_httpserver + ${prometheus.client.version} + + + io.prometheus + simpleclient_servlet + ${prometheus.client.version} + + + + + + + javax.xml.bind + jaxb-api + 2.2.8 + + + com.sun.xml.bind + jaxb-core + 2.3.0.1 + + + com.sun.xml.bind + jaxb-impl + 2.3.1 + + + + + + + + org.springframework + spring-test + ${spring.test.version} + test + + + org.springframework + spring-core + + + + + + org.springframework + spring-context + ${spring.version} + + + + org.springframework + spring-core + ${spring.version} + + + + + de.flapdoodle.embed + de.flapdoodle.embed.mongo + ${embedded.mongodb} + test + + + org.apache.commons + commons-compress + + + org.apache.commons + commons-lang3 + + + org.slf4j + slf4j-api + + + + + org.eclipse.ecsp + nosql-dao + ${nosql.dao.version} + + + org.eclipse.ecsp + entities + + + org.eclipse.ecsp + utils + + + org.springframework + spring-context + + + org.springframework + spring-core + + + + + org.eclipse.ecsp + cache-enabler + ${cache.enabler.version} + + + org.eclipse.ecsp + entities + + + com.fasterxml.jackson.core + jackson-core + + + org.eclipse.ecsp + utils + + + org.springframework + spring-context + + + org.springframework + spring-beans + + + + + + org.eclipse.ecsp + entities + ${entities.version} + + + + com.harman.ignite.platform + redis-jar + ${redis.jar.version} + test + + + org.eclipse.ecsp + transformers + ${transformers.version} + + + org.eclipse.ecsp + entities + + + org.eclipse.ecsp + utils + + + + + org.mockito + mockito-core + 3.12.4 + + + + org.eclipse.paho + org.eclipse.paho.client.mqttv3 + ${paho.mqtt.client} + + + + io.moquette + moquette-broker + ${moquette.broker.version} + test + + + io.netty + netty-buffer + + + io.netty + netty-common + + + io.netty + netty-codec + + + io.netty + netty-resolver + + + io.netty + netty-transport + + + io.netty + netty-handler + + + io.netty + netty-codec-http + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-annotations + + + org.slf4j + slf4j-api + + + + + org.junit.platform + junit-platform-launcher + 1.0.1 + test + + + org.skyscreamer + jsonassert + 1.5.0 + test + + + org.springframework.boot + spring-boot-starter + ${spring-boot-starter} + + + org.springframework.boot + spring-boot-starter-logging + + + + + org.eclipse.ecsp + utils + ${utils.version} + + + log4j + log4j + + + org.eclipse.ecsp + entities + + + + + com.google.guava + guava + 33.1.0-jre + + + + com.hivemq + hivemq-mqtt-client + ${hivemq.mqtt.client} + + + io.netty + netty-buffer + + + io.netty + netty-codec + + + io.netty + netty-common + + + io.netty + netty-handler + + + io.netty + netty-transport + + + org.reactivestreams + reactive-streams + + + + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + org.slf4j + slf4j-api + + + + + org.junit.jupiter + junit-jupiter-params + ${junit.version} + test + + + + + + + dash + + + + org.eclipse.dash + license-tool-plugin + + false + true + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + + + release + + true + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.13 + true + + ossrh + https://oss.sonatype.org/ + true + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + verify + + sign + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.0 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.2.0 + + + attach-javadocs + + jar + + + + + + + + + + javadoc + + true + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.2.0 + + + attach-javadocs + + jar + + + ${java.version} + + + + + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + --add-opens=java.base/java.io=ALL-UNNAMED + --add-opens=java.base/java.lang=ALL-UNNAMED + --add-opens=java.base/java.math=ALL-UNNAMED + --add-opens=java.base/java.util=ALL-UNNAMED + --add-opens=java.base/java.util.concurrent=ALL-UNNAMED + --add-opens=java.base/java.net=ALL-UNNAMED + --add-opens=java.base/java.text=ALL-UNNAMED + --add-opens=java.sql/java.sql=ALL-UNNAMED + + ${java.version} + ${java.version} + + + + + org.apache.maven.plugins + maven-install-plugin + ${install-plugin.version} + + + + org.cyclonedx + cyclonedx-maven-plugin + 2.7.10 + + application + 1.5 + true + true + true + true + true + true + true + all + ${project.basedir}/sbom + false + + + + package + + makeAggregateBom + + + + + + + org.apache.maven.plugins + maven-gpg-plugin + ${maven-gpg-plugin.version} + + + --pinentry-mode + loopback + --batch + --yes + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + -Xdoclint:none + -Dmaven.javadoc.skip=true + false + + + + + + + + maven-surefire-plugin + ${maven.surefire.version} + + + ${surefireArgLine} ${java.17.options} + pertest + true + ${excludeTestCaseGroups} + + **/MqttDispatcherHealthMontiorIntegrationTest.java + + + + + org.cyclonedx + cyclonedx-maven-plugin + + + org.apache.maven.plugins + maven-dependency-plugin + ${maven.dependency.version} + + + copy-dependencies + process-test-resources + + copy-dependencies + + + ${project.build.directory}/dependencies + false + false + true + compile + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.8 + + + org/eclipse/ecsp/analytics/aws/**/* + org/eclipse/ecsp/analytics/stream/kcl/**/* + org/eclipse/ecsp/analytics/stream/base/StreamProcessingContext.class + org/eclipse/ecsp/analytics/stream/base/StreamProcessor.class + org/eclipse/ecsp/analytics/stream/base/StreamBaseConstant.class + org/eclipse/ecsp/analytics/stream/base/PropertyNames.class + org/eclipse/ecsp/analytics/stream/base/Abstract*.class + org/eclipse/ecsp/analytics/stream/base/utils/Constants.class + org/eclipse/ecsp/analytics/stream/base/utils/KafkaTestUtils.class + org/eclipse/ecsp/analytics/stream/base/utils/RetryUtils.class + org/eclipse/ecsp/analytics/stream/base/dao/SinkNode.class + org/eclipse/ecsp/analytics/stream/base/stores/JsonStateStore.class + org/eclipse/ecsp/analytics/stream/base/stores/SerializedKVIterator.class + org/eclipse/ecsp/stream/dma/dao/DMAConstants.class + org/eclipse/ecsp/stream/dma/handler/DeviceMessageHandler.class + org/eclipse/ecsp/analytics/stream/base/stores/ObjectStateStore.class + org/eclipse/ecsp/analytics/stream/base/stores/SortedKeyValueStore.class + org/eclipse/ecsp/analytics/stream/base/stores/MapObjectStateStore.class + org/eclipse/ecsp/analytics/stream/base/stores/HarmanPersistentPrimitiveMapValueStore.class + + org/eclipse/ecsp/analytics/stream/base/parser/GenericValue.class + org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperForMap.class + org/eclipse/ecsp/analytics/stream/base/parser/EventParser.class + org/eclipse/ecsp/analytics/stream/base/parser/EventParseException.class + org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperForSequence.class + + + + + + pre-unit-test + + prepare-agent + + + + ${jacoco.ut.execution.data.file} + + surefireArgLine + + + + + post-unit-test + test + + report + + + + ${jacoco.ut.execution.data.file} + + ${project.reporting.outputDirectory}/jacoco-ut + + + + + + + + org.sonarsource.scanner.maven + sonar-maven-plugin + 3.10.0.2594 + + + + maven-clean-plugin + 2.5 + + + + ${basedir} + + **/jar/ + + false + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.1 + + + package + + shade + + + true + false + + + + + + + + org.apache.maven.plugins + maven-release-plugin + 2.5.3 + + true + false + release + deploy + + + + + org.apache.maven.plugins + maven-jar-plugin + 2.2 + + + default-test + + + **/ignite/cache/redis/* + **/ignite/dao/utils/* + **/redis/embedded/* + **/analytics/stream/base/utils/EmbeddedMQTTServer.class + **/analytics/stream/base/kafka/EmbeddedKafka.class + **/analytics/stream/base/kafka/EmbeddedZookeeper.class + **/analytics/stream/base/kafka/SingleNodeKafkaCluster.class + **/analytics/stream/base/utils/KafkaStreamsApplicationTestBase.class + **/analytics/stream/base/utils/KafkaStreamsApplicationTestBase$*.class + + + + + test-jar + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${maven.checkstyle.version} + + + validate + validate + + true + true + xml + true + warning + true + true + true + + + check + + + + + + com.puppycrawl.tools + checkstyle + ${checkstyle.version} + + + + + + org.eclipse.dash + license-tool-plugin + ${license-tool-plugin.version} + + test + + + + license-check + + license-check + + + + + + + + diff --git a/release_notes.txt b/release_notes.txt new file mode 100644 index 0000000..2cc7557 --- /dev/null +++ b/release_notes.txt @@ -0,0 +1,3 @@ +================================1.0.0============================== +Open Source Release for streambase project. +------------------------------------------------------------------- \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/AbstractLauncher.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/AbstractLauncher.java new file mode 100644 index 0000000..c6c34ea --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/AbstractLauncher.java @@ -0,0 +1,274 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import com.codahale.metrics.ConsoleReporter; +import com.codahale.metrics.CsvReporter; +import com.codahale.metrics.JmxReporter; +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Slf4jReporter; +import org.eclipse.ecsp.analytics.stream.base.discovery.StreamProcessorDiscoveryService; +import org.eclipse.ecsp.analytics.stream.base.exception.InvalidMetricSpecifiedException; +import org.eclipse.ecsp.analytics.stream.base.exception.UnsupportedTimeUnitException; +import org.eclipse.ecsp.analytics.stream.base.metrics.reporter.CumulativeLogger; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.TimeUnit; + +/** + * Defines the common functionalities that may be required by various Launcher classes. + * For eg. As of now, we have {@link KafkaStreamsLauncher} but there could be a need of writing a + * wrapper over some other messaging broker. Hence, that launcher class can extend from this abstract class + * and get the common functionality. + * + * @param the generic type for incoming key + * @param the generic type for incoming value + * @param the generic type for outgoing key + * @param the generic type for outgoing value + */ + +public abstract class AbstractLauncher implements LauncherProvider { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(AbstractLauncher.class); + + /** The {@link MetricRegistry} instance. */ + protected MetricRegistry metricRegistry; + + /** The list of {@link Closeable} reporters. */ + protected List reporters = new ArrayList<>(4); + + /** + * these are the only list of metric reporter supported my Dropwizard metric. + */ + protected static final String CONSOLE = "console"; + + /** The Constant JMX. */ + protected static final String JMX = "jmx"; + + /** The Constant CSV. */ + protected static final String CSV = "csv"; + + /** The Constant SL4J. */ + protected static final String SL4J = "sl4j"; + + /** The Constant SECONDS. */ + protected static final String SECONDS = "seconds"; + + /** The Constant MINUTES. */ + protected static final String MINUTES = "minutes"; + + /** The Constant HOURS. */ + protected static final String HOURS = "hours"; + + /** + * Instantiates a new abstract launcher. + */ + protected AbstractLauncher() { + metricRegistry = new MetricRegistry(); + } + + /** + * Launches the derived Launcher class. + * + * @param props the props + */ + @Override + public final void launch(Properties props) { + if (Boolean.parseBoolean(props.getProperty(PropertyNames.LOG_COUNTS, "false"))) { + CumulativeLogger.init(props); + } + doLaunch(props); + } + + /** + * Method to define how would the streams, for eg. {@link KafkaStreams} be launched. + * + * @param props the props + */ + protected abstract void doLaunch(Properties props); + + /** + * Method to close all the reporters. + */ + public void terminate() { + for (Closeable r : reporters) { + try { + r.close(); + } catch (IOException e) { + logger.error("Exception when closing reporter", e); + } + } + } + + + /** + * You can provide comma separated list of reporter names. + * + * @param config the config + */ + protected void initializeMetricReporter(Properties config) { + String reporterNames = config.getProperty("metric.reporters.name", ""); + String[] names = reporterNames.split(","); + for (String reporter : names) { + if (isSupportedMetricReporter(reporter)) { + startReporter(reporter, config); + } else { + logger.info("Metric Reporter {} not supported.", reporter); + } + } + } + + /** + * Load discovery service. + * + * @param props the props + * @return the stream processor discovery service + */ + protected StreamProcessorDiscoveryService loadDiscoveryService(Properties props) { + try { + return (StreamProcessorDiscoveryService) getClass().getClassLoader() + .loadClass(props.getProperty(PropertyNames.DISCOVERY_SERVICE_IMPL)) + .getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new IllegalArgumentException( + PropertyNames.DISCOVERY_SERVICE_IMPL + + " refers to a class that is not available on the classpath", e); + } + } + + /** + * Checks if is supported metric reporter. + * + * @param reporterName the reporter name + * @return true, if is supported metric reporter + */ + private boolean isSupportedMetricReporter(String reporterName) { + return (reporterName.equals(CONSOLE) || reporterName.equals(CSV) + || reporterName.equals(JMX) || reporterName.equals(SL4J)) ? true + : false; + } + + /** + * Method to start the reporter. + * Only those reporter are supported which are supported by dropwizard metrics. console, csv, jmx, sl4j + * + * @param reporterName represents reporter's name as String + * + * @param config represents config properties as Properties body + */ + private void startReporter(String reporterName, Properties config) { + long interval = getLoggingInterval(config); + TimeUnit unit = getTimeunit(config); + logger.info("Metric will be generated every {} {}", interval, unit); + if (reporterName.equals(CONSOLE)) { + ConsoleReporter consoleReporter = + ConsoleReporter.forRegistry(metricRegistry).convertRatesTo(TimeUnit.SECONDS) + .convertDurationsTo(TimeUnit.MILLISECONDS) + .build(); + consoleReporter.start(interval, unit); + reporters.add(consoleReporter); + logger.info("Console reporter successfully registered to metric registry"); + } else if (reporterName.equals(CSV)) { + String dirName = config.getProperty("csv.reporter.dir.name", "/tmp"); + CsvReporter csvReporter = CsvReporter.forRegistry(metricRegistry).convertRatesTo(TimeUnit.SECONDS) + .convertDurationsTo(TimeUnit.MILLISECONDS).build(new File(dirName)); + csvReporter.start(interval, unit); + reporters.add(csvReporter); + logger.info("CSV reporter successfully registered to metric registry"); + } else if (reporterName.equals(JMX)) { + JmxReporter jmxReporter = JmxReporter.forRegistry(metricRegistry).convertRatesTo(TimeUnit.SECONDS) + .convertDurationsTo(TimeUnit.MILLISECONDS).build(); + jmxReporter.start(); + reporters.add(jmxReporter); + logger.info("JMX reporter successfully registered to metric registry"); + } else if (reporterName.equals(SL4J)) { + Slf4jReporter sl4jReporter = Slf4jReporter.forRegistry(metricRegistry).convertRatesTo(TimeUnit.SECONDS) + .convertDurationsTo(TimeUnit.MILLISECONDS).build(); + sl4jReporter.start(interval, unit); + reporters.add(sl4jReporter); + logger.info("SL4J reporter successfully registered to metric registry"); + } else { + logger.error("Metric reporter {} not supported.", reporterName); + } + } + + /** + * Gets the value of the property: metric.logging.interval. + * + * @param config the Properties instance. + * @return the logging interval + */ + private long getLoggingInterval(Properties config) { + // if property is not defined, set the interval as 2 + long val = Long.parseLong(config.getProperty("metric.logging.interval", "60")); + if (val < 1) { + logger.error("{} is invalid logging interval. Value should be greater than 1. "); + throw new InvalidMetricSpecifiedException("Invalid Metric logging value is specified."); + } + return val; + } + + /** + * Gets the timeunit. + * + * @param config the config + * @return the timeunit + */ + private TimeUnit getTimeunit(Properties config) { + // default logging unit is minutes + String timeunit = config.getProperty("metric.logging.unit", MINUTES); + if (timeunit.equals(MINUTES)) { + return TimeUnit.MINUTES; + } else if (timeunit.equals(SECONDS)) { + return TimeUnit.SECONDS; + } else if (timeunit.equals(HOURS)) { + return TimeUnit.HOURS; + } + logger.error("{} is unsupported time unit. Returning minutes.", timeunit); + throw new UnsupportedTimeUnitException("Unspported time unit specified"); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/ConfigChangeListener.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/ConfigChangeListener.java new file mode 100644 index 0000000..6e7e518 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/ConfigChangeListener.java @@ -0,0 +1,56 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import java.util.Properties; + +/** + * Interface ConfigChangeListener. + * + */ +public interface ConfigChangeListener { + + /** + * Config changed. + * + * @param props the Properties instance. + */ + void configChanged(Properties props); +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/IgniteEventStreamProcessor.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/IgniteEventStreamProcessor.java new file mode 100644 index 0000000..5e56500 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/IgniteEventStreamProcessor.java @@ -0,0 +1,55 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; + +/** + * This interface needs to be implemented by all Stream + * Processors. The signature is IgniteKey and IgniteEvent which + * will be common across the stream processors. Also this is important for processor chaining. + * + * @author avadakkootko + */ +public interface IgniteEventStreamProcessor extends + StreamProcessor, IgniteEvent, IgniteKey, IgniteEvent> { + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaProducerInstance.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaProducerInstance.java new file mode 100644 index 0000000..ae65944 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaProducerInstance.java @@ -0,0 +1,137 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.ByteArraySerializer; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; + +import javax.annotation.concurrent.ThreadSafe; +import java.util.Properties; + +/** + * Singleton Kafka producer. Factory class that creates the {@link KafkaProducer} instance + * with the supplied configurations. + * + * @author ssasidharan + */ +public class KafkaProducerInstance { + + /** The config holder. */ + private static Properties configHolder = null; + + /** The {@link KafkaProducer} instance. */ + private final KafkaProducer producer; + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(KafkaProducerInstance.class); + + /** + * Instantiates a new kafka producer instance with the supplied config. + * + * @param config The Properties instance. + */ + private KafkaProducerInstance(Properties config) { + logger.debug("Inside KafkaProducerInstance constructor config {}", config); + Properties props = new Properties(); + config.forEach(props::put); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + props.put(ProducerConfig.MAX_REQUEST_SIZE_CONFIG, config.getProperty(PropertyNames.KAFKA_MAX_REQUEST_SIZE)); + props.put(ProducerConfig.ACKS_CONFIG, config.getProperty(PropertyNames.KAFKA_ACKS_CONFIG)); + props.put(ProducerConfig.RETRIES_CONFIG, config.getProperty(PropertyNames.KAFKA_RETRIES_CONFIG)); + props.put(ProducerConfig.BATCH_SIZE_CONFIG, config.getProperty(PropertyNames.KAFKA_BATCH_SIZE_CONFIG)); + props.put(ProducerConfig.LINGER_MS_CONFIG, config.getProperty(PropertyNames.KAFKA_LINGER_MS_CONFIG)); + props.put(ProducerConfig.BUFFER_MEMORY_CONFIG, + config.getProperty(PropertyNames.KAFKA_BUFFER_MEMORY_CONFIG)); + props.put(ProducerConfig.REQUEST_TIMEOUT_MS_CONFIG, + config.getProperty(PropertyNames.KAFKA_REQUEST_TIMEOUT_MS_CONFIG)); + props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, + config.getProperty(PropertyNames.KAFKA_DELIVERY_TIMEOUT_MS_CONFIG)); + props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, + config.getProperty(PropertyNames.KAFKA_COMPRESSION_TYPE_CONFIG)); + producer = new KafkaProducer<>(props); + logger.info("KafkaProducer instance created with config {}", props); + } + + /** + * Gets the producer instance. + * + * @param config the config + * @return the {@link KafkaProducer} instance. + */ + public static KafkaProducer getProducerInstance(Properties config) { + configHolder = config; + return KafkaProducerInstanceHolder.getInstance().producer; + } + + /** + * Holds the instance of KafkaProducer. + */ + @ThreadSafe + private static final class KafkaProducerInstanceHolder { + + /** The {@link KafkaProducerInstance} instance. */ + private static KafkaProducerInstance instance; + + /** + * Instantiates a new kafka producer instance holder. + */ + private KafkaProducerInstanceHolder() {} + + /** + * Gets the single instance of KafkaProducerInstanceHolder. + * + * @return single instance of KafkaProducerInstanceHolder + */ + public static synchronized KafkaProducerInstance getInstance() { + if (instance != null) { + return instance; + } + logger.info("Creating new Instance of KafkaProducerInstance"); + instance = new KafkaProducerInstance(configHolder); + logger.info("New Instance of KafkaProducerInstance created successfully"); + return instance; + } + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaSslConfig.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaSslConfig.java new file mode 100644 index 0000000..0c8cfc0 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaSslConfig.java @@ -0,0 +1,123 @@ +/* +********************************************************************* +* COPYRIGHT (c) 2023 Harman International Industries, Inc. * +* * +* All rights reserved * +* * +* This software embodies materials and concepts which are * +* confidential to Harman International Industries, Inc. and is * +* made available solely pursuant to the terms of a written license * +* agreement with Harman International Industries, Inc. * +* * +* Designed and Developed by Harman International Industries, Inc. * +*-------------------------------------------------------------------* +* MODULE OR UNIT: stream-base * +********************************************************************* +*/ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaSslUtils; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Properties; + +/** + * Spring bean to read and set properties for enabling SSL or SASL_SSL protocol for Kafka client. + * + * @author karora + * + */ +@Component +public class KafkaSslConfig { + + /** The Constant LOGGER. */ + private static final IgniteLogger LOGGER = IgniteLoggerFactory.getLogger(KafkaSslConfig.class); + + /** Specifies whether SSL is enabled for Kafka. */ + @Value("${" + PropertyNames.KAFKA_SSL_ENABLE + ":false}") + private boolean kafkaSslEnable; + + /** Specifies whether SASL is enabled for Kafka. */ + @Value("${" + PropertyNames.KAFKA_ONE_WAY_TLS_ENABLE + ":false}") + private boolean kafkaOneWayTlsEnable; + + /** The path to Kafka client keystore. */ + @Value("${" + PropertyNames.KAFKA_CLIENT_KEYSTORE + ":}") + private String keystore; + + /** The Kafka client keystore password. */ + @Value("${" + PropertyNames.KAFKA_CLIENT_KEYSTORE_PASSWORD + ":}") + private String keystorePwd; + + /** The Kafka client key password. */ + @Value("${" + PropertyNames.KAFKA_CLIENT_KEY_PASSWORD + ":}") + private String keyPwd; + + /** The path to Kafka client trustore where server's public key details are located. */ + @Value("${" + PropertyNames.KAFKA_CLIENT_TRUSTSTORE + ":}") + private String truststore; + + /** The Kafka client truststore password. */ + @Value("${" + PropertyNames.KAFKA_CLIENT_TRUSTSTORE_PASSWORD + ":}") + private String truststorePwd; + + /** The ssl client auth. */ + @Value("${" + PropertyNames.KAFKA_SSL_CLIENT_AUTH + ":}") + private String sslClientAuth; + + /** The sasl mechanism. */ + @Value("${" + PropertyNames.KAFKA_SASL_MECHANISM + ":}") + private String saslMechanism; + + /** The sasl jaas config. */ + @Value("${" + PropertyNames.KAFKA_SASL_JAAS_CONFIG + ":}") + private String saslJaasConfig; + + /** The ssl endpoint algo. */ + @Value("${" + PropertyNames.KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM + ":}") + private String sslEndpointAlgo; + + /** + * Utility method to set the required properties for enabling SASL_SSL or SSL, if enabled. + * To enable SSL, set kafka.ssl.enable=true + * To enable one way TLS with SASL, set kafka.one.way.tls.enable=true + * + * @param props The properties instance. + */ + public void setSslPropsIfEnabled(Properties props) { + if (kafkaSslEnable || kafkaOneWayTlsEnable) { + LOGGER.info("SSL/TLS is enabled. Setting corresponding properties."); + setSslProps(props); + } + } + + /** + * Sets the SSL properties in the properties instance. + * + * @param targetProps The properties instance. + */ + private void setSslProps(Properties targetProps) { + Properties sourceProps = new Properties(); + if (kafkaOneWayTlsEnable) { + sourceProps.put(PropertyNames.KAFKA_SASL_MECHANISM, saslMechanism); + sourceProps.put(PropertyNames.KAFKA_SASL_JAAS_CONFIG, saslJaasConfig); + sourceProps.put(PropertyNames.KAFKA_ONE_WAY_TLS_ENABLE, Constants.TRUE); + } + if (kafkaSslEnable) { + sourceProps.put(PropertyNames.KAFKA_CLIENT_KEYSTORE, keystore); + sourceProps.put(PropertyNames.KAFKA_CLIENT_KEYSTORE_PASSWORD, keystorePwd); + sourceProps.put(PropertyNames.KAFKA_CLIENT_KEY_PASSWORD, keyPwd); + sourceProps.put(PropertyNames.KAFKA_SSL_ENABLE, Constants.TRUE); + } + sourceProps.put(PropertyNames.KAFKA_CLIENT_TRUSTSTORE, truststore); + sourceProps.put(PropertyNames.KAFKA_CLIENT_TRUSTSTORE_PASSWORD, truststorePwd); + sourceProps.put(PropertyNames.KAFKA_SSL_CLIENT_AUTH, sslClientAuth); + sourceProps.put(PropertyNames.KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM, sslEndpointAlgo); + KafkaSslUtils.applySslProperties(targetProps, sourceProps); + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStateAgentListener.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStateAgentListener.java new file mode 100644 index 0000000..1d57540 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStateAgentListener.java @@ -0,0 +1,50 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.streams.KafkaStreams.State; + +/** + * Interface for providing the action to be performed upon state change of + * {@link org.apache.kafka.streams.KafkaStreams}. + */ +public interface KafkaStateAgentListener { + void onChange(final State newState, final State oldState); +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStateListener.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStateListener.java new file mode 100644 index 0000000..a37bad4 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStateListener.java @@ -0,0 +1,276 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.streams.KafkaStreams; +import org.apache.kafka.streams.KafkaStreams.State; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.BackdoorKafkaConsumer; +import org.eclipse.ecsp.analytics.stream.base.offset.OffsetManager; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.healthcheck.HealthMonitor; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * An implementation of {@link KafkaStateAgentListener} which takes the following actions on {@link KafkaStreams} + * state change. + *
  • + * Restart the {@link BackdoorKafkaConsumer}. + *
  • + *
  • + * If the streams have been in rebalancing state for over 10 mins then restart the KafkaStreams. + *
  • + * + */ + +@Component +public class KafkaStateListener implements KafkaStreams.StateListener, HealthMonitor { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(KafkaStateListener.class); + + /** How long to monitor the KafkaStreams state. */ + @Value("${" + PropertyNames.KAFKA_REBALANCE_TIME_MINS + ":10}") + private int timeToRebalance; + + /** The timeout before closing the KafkaStreams. */ + @Value("${" + PropertyNames.KAFKA_CLOSE_TIMEOUT_SECS + ":30}") + private int closeTimeout; + + /** Indicates whether the KafkaStreams is in rebalancing state or not. */ + private volatile boolean balancing; + + /** The {@link KafkaStreams} instance. */ + private KafkaStreams streams; + + /** The list of {@link BackdoorKafkaConsumer} to be restarted if KafkaStreams state changes + * from anything to RUNNING. */ + @Autowired + private List backdoorConsumers; + + /** The {@link OffsetManager} instance. */ + @Autowired + private OffsetManager offsetManager; + + /** The Spring's ApplicationContext. */ + @Autowired + private ApplicationContext applicationContext; + + /** Whether DMA is enabled or not. */ + @Value("${" + PropertyNames.DMA_ENABLED + ":true}") + private boolean isDmaEnabled; + + /** Whether KafkaConsumer group health monitor is enabled or not. */ + @Value("${health.kafka.consumer.group.monitor.enabled:true}") + protected boolean healthMonitorEnabled; + + /** Whether to restart the KafkaStreams if KafkaConsumer group health monitor reports UNHEALTHY. */ + @Value("${health.kafka.consumer.group.needs.restart.on.failure:false}") + protected boolean needsRestartOnFailure; + + /** The Constant GROUP_HEALTH_MONITOR. */ + protected static final String GROUP_HEALTH_MONITOR = "KAFKA_CONSUMER_GROUP_HEALTH_MONITOR"; + + /** The Constant GROUP_HEALTH_GUAGE. */ + protected static final String GROUP_HEALTH_GUAGE = "KAFKA_CONSUMER_GROUP_HEALTH_GUAGE"; + + /** Indicates the health status reported by this health monitor. */ + private volatile boolean healthy; + + /** + * Instantiates a new kafka state listener. + */ + public KafkaStateListener() { + //default constructor + } + + /** + * Sets the streams. + * + * @param streams the new streams + */ + public void setStreams(KafkaStreams streams) { + this.streams = streams; + } + + /** + * Should be used only in testcases. + * + * @param backdoorConsumers backdoorConsumers + */ + void setBackdoorConsumers(List backdoorConsumers) { + this.backdoorConsumers = backdoorConsumers; + } + + /** + * Following actions are taken when the state of KafkaStreams changes. + *
      + *
    • Notify all the {@link BackdoorKafkaConsumer} that the KafkaStreams state has changed so that + * necessary action could be taken by the Kafka consumers
    • . + *
    • Keep monitoring the state of KafkaStreams and if it remains in the REBALANCING state for + * more than 10 mins, then restart the streams application.
    • + *
    • Notify the {@link OffsetManager} so that offsets could be repopulated from MongoDB. This is + * related to the manual offset management done in the stream-base library. See {@link OffsetManager} for more. + *
    + * + * @param newState the new state + * @param oldState the old state + */ + @Override + public void onChange(State newState, State oldState) { + + backdoorConsumers.forEach(backdoorConsumer -> + backdoorConsumer.setStreamState(newState) + ); + if (State.RUNNING == newState) { + healthy = true; + } else { + healthy = false; + } + logger.info("Stream state changed from {} to {}", oldState, newState); + if (State.REBALANCING == newState || State.ERROR == newState || State.NOT_RUNNING == newState) { + if (!balancing) { + balancing = true; + Thread rebalanceMonitor = new Thread(() -> { + try { + Thread.sleep(timeToRebalance * Constants.LONG_60000); + } catch (InterruptedException e) { + logger.error("Interrupted exception occurred when waiting for REBALANCING to complete. " + + "Error - {}", e); + Thread.currentThread().interrupt(); + } + validateStateBalancing(); + }); + rebalanceMonitor.setName("IgniteKafkaStateListener:" + Thread.currentThread().getName()); + rebalanceMonitor.setDaemon(true); + rebalanceMonitor.start(); + } + } else { + balancing = false; + } + if (State.REBALANCING == oldState && State.RUNNING == newState) { + Map kafkaAgentListeners = + applicationContext.getBeansOfType(KafkaStateAgentListener.class); + + kafkaAgentListeners.values().forEach(listner -> + listner.onChange(newState, oldState) + ); + offsetManager.setUp(); + } + } + + /** + * Checks if the KafkaStreams has been rebalancing for more than 10 mins. If yes, then close the + * current streams instance and restart the application. + */ + private void validateStateBalancing() { + if (balancing) { + logger.error("I have been rebalancing or in error state for last {} minutes. Exiting the JVM to restart.", + timeToRebalance); + if (streams.close(Duration.ofSeconds(closeTimeout))) { + logger.error("All threads were successfully stopped, Streams closed"); + } else { + logger.error("Streams closed after time out of {} seconds.", closeTimeout); + } + System.exit(1); + } else { + logger.info("Stream back to normal"); + } + } + + /** + * Returns true if the health monitor for KafkaConsumer group is enabled. + * + * @return true, if is enabled + */ + @Override + public boolean isEnabled() { + return healthMonitorEnabled; + } + + /** + * Returns true if the health monitor for KafkaConsumer group is HEALTHY. + * + * @param arg0 the arg 0 + * @return true, if is healthy + */ + @Override + public boolean isHealthy(boolean arg0) { + return healthy; + } + + /** + * Metric name. + * + * @return the string + */ + @Override + public String metricName() { + return GROUP_HEALTH_GUAGE; + } + + /** + * Name of the Prometheus Guage under which these health metrics will be captured. + * + * @return the name of the Guage. + */ + @Override + public String monitorName() { + return GROUP_HEALTH_MONITOR; + } + + /** + * Returns true if the application Needs to be restarted on UNHEALTHY health status reported by + * this health monitor. + * + * @return true, if to be restarted. + */ + @Override + public boolean needsRestartOnFailure() { + return needsRestartOnFailure; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsLauncher.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsLauncher.java new file mode 100644 index 0000000..afdc04c --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsLauncher.java @@ -0,0 +1,735 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import com.codahale.metrics.MetricRegistry; +import org.apache.commons.lang3.StringUtils; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.metrics.Sensor.RecordingLevel; +import org.apache.kafka.common.serialization.ByteArraySerializer; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.common.serialization.StringSerializer; +import org.apache.kafka.streams.KafkaStreams; +import org.apache.kafka.streams.StreamsConfig; +import org.apache.kafka.streams.Topology; +import org.apache.kafka.streams.processor.StateStore; +import org.apache.kafka.streams.processor.api.ProcessorSupplier; +import org.apache.kafka.streams.state.StoreBuilder; +import org.eclipse.ecsp.analytics.stream.base.KafkaStreamsProcessorContext.StoreType; +import org.eclipse.ecsp.analytics.stream.base.discovery.StreamProcessorDiscoveryService; +import org.eclipse.ecsp.analytics.stream.base.exception.InvalidStoreException; +import org.eclipse.ecsp.analytics.stream.base.kafka.support.KafkaStreamsThreadStatusPrinter; +import org.eclipse.ecsp.analytics.stream.base.kafka.support.LoggingStateRestoreListener; +import org.eclipse.ecsp.analytics.stream.base.processors.ProtocolTranslatorPostProcessor; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanRocksDBStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaSslUtils; +import org.eclipse.ecsp.diagnostic.DiagnosticService; +import org.eclipse.ecsp.healthcheck.HealthMonitor; +import org.eclipse.ecsp.healthcheck.HealthService; +import org.eclipse.ecsp.healthcheck.HealthServiceCallBack; +import org.eclipse.ecsp.stream.dma.handler.MaxFailuresUncaughtExceptionHandler; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.rocksdb.RocksDB; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.KAFKA_STREAMS_MAX_FAILURES; +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.KAFKA_STREAMS_MAX_TIME_INTERVAL; + +/** + * Launches {@link KafkaStreams} application processing logic packaged appropriately for Kafka Streams. + * This launcher provides with the topology building and the state-store configuration to + * the streaming application. + * + * @author ssasidharan + * + * @param the type parameter for incoming key. + * @param the type parameter for incoming value. + * @param the type parameter for outgoing key. + * @param the type parameter for outgoing value. + * + */ +@Component +public class KafkaStreamsLauncher extends AbstractLauncher { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(KafkaStreamsLauncher.class); + + /** The group id suffix. */ + private int groupIdSuffix = 0; + + /** The {@link KafkaStreams} instance. */ + private KafkaStreams streams = null; + + /** Whether to clean up state store or not. */ + @Value("${perform.state.store.cleanup:false}") + private boolean cleanupStateStore = false; + + /** The list of health monitor whose health status can be ignored at the bootstrap + * of the application. */ + @Value("#{'${ignore.bootstrap.failure.monitors}'.split(',')}") + private List bootstrapIgnoredMonitors; + + /** Whether to restart the streams application or not. */ + @Value("${sp.restart.on.failure:false}") + private boolean restartOnFailure; + + /** The wait time in mills. */ + @Value("${sp.restart.wait.time.in.millis:10000}") + private int waitTimeInMills; + + /** The maximum failures. */ + @Value("${" + KAFKA_STREAMS_MAX_FAILURES + "}") + private int maximumFailures; + + /** The max time interval. */ + @Value("${" + KAFKA_STREAMS_MAX_TIME_INTERVAL + "}") + private long maxTimeInterval; + + /** The Spring ApplicationContext. */ + @Autowired + private ApplicationContext ctx; + + /** The {@link KafkaStateListener} implementation. */ + @Autowired + private KafkaStateListener kafkaStateListener; + + /** The {@link HealthService} instance. */ + @Autowired + private HealthService healthService; + + /** The {@link KafkaStreamsThreadStatusPrinter} instance. */ + @Autowired + private KafkaStreamsThreadStatusPrinter threadStatusPrinter; + + /** The diagnostic service. */ + @Autowired + private DiagnosticService diagnosticService; + + /** + * Closes the {@link KafkaStreams} instance and terminates the application. + */ + @Override + public void terminate() { + if (streams != null) { + logger.info("Closing streams application"); + threadStatusPrinter.close(); + streams.close(); + super.terminate(); + } + } + + /** + * Terminate streams with timeout. To be used only for unit test cases. + */ + @Override + public void terminateStreamsWithTimeout() { + if (streams != null) { + logger.info("Closing streams application with timeout of 30 seconds"); + threadStatusPrinter.close(); + streams.close(Duration.ofSeconds(Constants.INT_30)); + super.terminate(); + } + } + + /** + * Builds the topology for the streaming application. The topology is built like a linked list, where + * multiple stream processor classes are linked together like a chain. A Kafka record is read from the + * stream and is passed through all the processors linked together in chain one by one. Each processor + * may or may not process the record (depending upon the use-case of the processor). + * + *

    + * Stream-base library exposes multiple stream processor classes called pre-processor and + * post-processors, where pre-processor classes execute certain logic on the record like transforming the + * byte[] array data into desired format etc. and post-processor classes execute the logic on the record + * once it's been processed completely by the service's stream-processor and again given back to stream-base + * library, for eg. Putting the record into some sink Kafka topic. + *

    + * + * @param processors The complete list of all the processor classes that need to be chained. + * @param props the Properties instance containing all the configs for the application. + * @return the {@link Topology} instance. + */ + private Topology buildKafkaStreamsTopology(List> processors, Properties props) { + // Pre-loading seems to avoid the .so corruption issue + RocksDB.loadLibrary(); + Map> outputSinks = new HashMap<>(); + Map, Class> processorMap = new HashMap<>(); + List> processorClones = new ArrayList<>(); + String prev = "source"; + String qualifier = getStreamQualifier(props); + Set inputSources = validateInputSources(processors, props, outputSinks, processorMap, processorClones); + Topology topology = new Topology(); + topology.addSource(prev, inputSources.stream().map(t -> t + qualifier).toList().toArray(new String[0])); + inputSources.stream().forEach(s -> logger.info("Subscribing processors to source topic {}", s)); + /* + * Register processor in order to get notification whenever incremental + * master/settings data arrived in shared data topics. + */ + groupIdSuffix++; + int numberOfProcessors = processorClones.size(); + int counter = 1; + boolean lastProcessor = false; + boolean legacyTopologySinkBuilder = true; + + for (StreamProcessor processorTemplate : processorClones) { + lastProcessor = isLastProcessor(numberOfProcessors, counter); + if (lastProcessor) { + logger.info("Chained Processor Class {} and ProtocolTranslatorPostProcessor Class name {}", + processorTemplate.getClass().getName(), ProtocolTranslatorPostProcessor.class.getName()); + legacyTopologySinkBuilder = !(processorTemplate instanceof ProtocolTranslatorPostProcessor); + } + addProcessorToTopology(props, processorMap, prev, topology, lastProcessor, processorTemplate); + + // Get the value of state.store.type property + HarmanPersistentKVStore storeTemplate = processorTemplate.createStateStore(); + + if (storeTemplate != null) { + + /* + * StreamProcessor must not provide the implementation of + * createStateStore method, if they want HashMap as state store + * instead of RocksDB store. + * + * Throw exception if StreamProcessor has specified the store + * type(state.store.type) as map and provided the implementation + * of createStateStore method + */ + checkStoreType(props); + addStoreToTopology(topology, processorTemplate, storeTemplate); + } + prev = processorTemplate.name(); + // increment the stream processor + counter++; + } + // We should support configurable value types for the output topics + getProcessor(outputSinks, processorClones, qualifier, topology, legacyTopologySinkBuilder); + return topology; + } + + /** + * Adds the state-store to KafkaStreams topology. + * + * @param topology the {@link Topology} instance + * @param processorTemplate the {@link StreamProcessor} instance. + * @param storeTemplate the {@link HarmanPersistentKVStore} instance. + */ + private static void addStoreToTopology(Topology topology, StreamProcessor processorTemplate, + HarmanPersistentKVStore storeTemplate) { + topology.addStateStore(new StoreBuilder() { + + @Override + public StoreBuilder withCachingEnabled() { + return this; + } + + @Override + public StoreBuilder withLoggingEnabled(Map config) { + return this; + } + + @Override + public StoreBuilder withLoggingDisabled() { + return this; + } + + @Override + public StateStore build() { + return processorTemplate.createStateStore(); + } + + @Override + public Map logConfig() { + return new HashMap(); + } + + @Override + public boolean loggingEnabled() { + return true; + } + + @Override + public String name() { + return storeTemplate.name(); + } + + // RTC-141484 - Kafka version upgrade from 1.0.0. to 2.2.0 + // changes. + @Override + public StoreBuilder withCachingDisabled() { + return this; + } + + }, processorTemplate.name()); + } + + /** + * Checks store type. + * + * @param props the props + */ + private static void checkStoreType(Properties props) { + String storeType = props.getProperty(PropertyNames.STATE_STORE_TYPE, StoreType.ROCKSDB.getStoreType()); + if (storeType.equals(StoreType.MAP.getStoreType())) { + throw new InvalidStoreException("Store Implementation found for hash map state store."); + } + } + + /** + * Adds a processor to the topology. + * + * @param props the Properties instance + * @param processorMap {@link StreamProcessor} Class' instance. + * @param prev the previous processor chained to this processor. + * @param topology the {@link Topology} instance. + * @param lastProcessor If this is the last processor to be chained. + * @param processorTemplate the {@link StreamProcessor} implementation instance. + */ + private void addProcessorToTopology(Properties props, Map, + Class> processorMap, String prev, Topology topology, + boolean lastProcessor, StreamProcessor processorTemplate) { + topology.addProcessor(processorTemplate.name(), getProcessorSupplier(processorMap.get(processorTemplate), + props, this.metricRegistry, lastProcessor), prev); + logger.info("Chained {} -> {}", prev, processorTemplate.name()); + } + + /** + * Checks if it is last processor. + * + * @param numberOfProcessors the number of processors + * @param counter the counter + * @return true, if is last processor + */ + private boolean isLastProcessor(int numberOfProcessors, int counter) { + return counter == numberOfProcessors; + } + + /** + * Validate input sources. + * + * @param processors the processors + * @param props the props + * @param outputSinks the output sinks + * @param processorMap the processor map + * @param processorClones the processor clones + * @return the Set view of input sources. + */ + private Set validateInputSources(List> processors, + Properties props, Map> outputSinks, Map, Class> processorMap, + List> processorClones) { + Set inputSources = new HashSet<>(); + for (StreamProcessor sp : processors) { + addInputSourcesSinkers(props, inputSources, outputSinks, processorMap, processorClones, sp); + } + // give preference to stream processor input topics + if (inputSources.isEmpty()) { + inputSources.add(props.getProperty(PropertyNames.SOURCE_TOPIC_NAME)); + } + if (inputSources.isEmpty()) { + throw new IllegalArgumentException("No input topics configured!!!"); + } + return inputSources; + } + + /** + * Gets the processor. + * + * @param outputSinks the output sinks + * @param processorClones the processor clones + * @param qualifier the qualifier + * @param topology the topology + * @param legacyTopologySinkBuilder the legacy topology sink builder + * @return the processor + */ + private void getProcessor(Map> outputSinks, + List> processorClones, + String qualifier, Topology topology, boolean legacyTopologySinkBuilder) { + logger.info("Legacy topology sink builder is set to:{}", legacyTopologySinkBuilder); + if (legacyTopologySinkBuilder) { + logger.info("Building the topology sink builder in legacy way."); + outputSinks.forEach((sink, sinkers) -> topology.addSink(sink, + sink + qualifier, new StringSerializer(), new StringSerializer(), + sinkers.toArray(new String[0]))); + } else { + logger.info("Last processor in the chain will be the sink node for all the sink topics."); + // get the last processor from the processor list + StreamProcessor lastProcessorInChain = processorClones.get(processorClones.size() - 1); + logger.info("Last processor in the chain:{}", lastProcessorInChain.name()); + outputSinks.forEach( + (sink, sinkers) -> topology.addSink(sink, sink + qualifier, + new ByteArraySerializer(), new ByteArraySerializer(), + lastProcessorInChain.name())); + } + } + + /** + * Adds the input sources sinkers. + * + * @param props the Properties instance. + * @param inputSources the input sources + * @param outputSinks the output sinks + * @param processorMap the processor map + * @param processorClones the processor clones + * @param sp the sp + */ + private void addInputSourcesSinkers(Properties props, Set inputSources, + Map> outputSinks, + Map, Class> processorMap, + List> processorClones, StreamProcessor sp) { + StreamProcessor clone = null; + try { + clone = (StreamProcessor) ctx.getAutowireCapableBeanFactory().createBean(sp.getClass()); + clone.initConfig(props); + processorClones.add(clone); + processorMap.put(clone, sp.getClass()); + } catch (BeansException be) { + throw new IllegalArgumentException("Unable to instantiate processor", be); + } + inputSources.addAll(Arrays.asList(clone.sources())); + for (String sink : clone.sinks()) { + outputSinks.computeIfAbsent(sink, k -> new ArrayList<>()); + List sinkers = outputSinks.get(sink); + sinkers.add(clone.name()); + } + } + + /** + * Validates KafkaStreams specific configurations before launching streams. + * + * @param props the {@link Properties} instance. + */ + private void checkMergeDefaults(Properties props) { + validateProps(props); + String replFactor = null; + if ((replFactor = props.getProperty(PropertyNames.REPLICATION_FACTOR)) == null) { + props.put(PropertyNames.REPLICATION_FACTOR, Constants.TWO); + } else { + props.put(PropertyNames.REPLICATION_FACTOR, Integer.parseInt(replFactor)); + } + // RTC-141484 - Kafka version upgrade from 1.0.0. to 2.2.0 changes. + String keySerde = props.getProperty(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG); + if (keySerde == null) { + logger.info("default.key.serde not found in properties. Using ByteArray as default."); + props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.ByteArray().getClass().getName()); + } + String valueSerde = props.getProperty(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG); + if (valueSerde == null) { + logger.info("default.value.serde not found in properties. Using ByteArray as default."); + props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.ByteArray().getClass().getName()); + } + String keyDeSerde = props.getProperty(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG); + if (keyDeSerde == null) { + logger.info("Consume key deserializer not found in properties. Using ByteArray as default."); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().deserializer().getClass().getName()); + } + String valueDeSerde = props.getProperty(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG); + if (valueDeSerde == null) { + logger.info("Consumer value deserializer not found in properties. Using ByteArray as default."); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().deserializer().getClass().getName()); + } + String kafkaRebalanceTime = props.getProperty(PropertyNames.KAFKA_REBALANCE_TIME_MINS); + if (StringUtils.isEmpty(kafkaRebalanceTime)) { + props.put(PropertyNames.KAFKA_REBALANCE_TIME_MINS, "10"); + } + String kafkaCloseTimeout = props.getProperty(PropertyNames.KAFKA_CLOSE_TIMEOUT_SECS); + if (StringUtils.isEmpty(kafkaCloseTimeout)) { + props.put(PropertyNames.KAFKA_CLOSE_TIMEOUT_SECS, "30"); + } + props.put(StreamsConfig.METRICS_RECORDING_LEVEL_CONFIG, RecordingLevel.DEBUG.toString()); + KafkaSslUtils.checkAndApplySslProperties(props); + logger.info("Initializing with properties:"); + props.forEach((k, v) -> logger.info("\t" + k + "=" + v)); + } + + /** + * Validate props. + * + * @param props the props + */ + private void validateProps(Properties props) { + if (props.getProperty(PropertyNames.APPLICATION_ID) == null) { + throw new IllegalArgumentException(PropertyNames.APPLICATION_ID + " is mandatory"); + } + // if (props.getProperty(PropertyNames.AUTO_OFFSET_RESET_CONFIG) == + // null) { + // throw new + // IllegalArgumentException(PropertyNames.AUTO_OFFSET_RESET_CONFIG + " + // is mandatory"); + // } + if (props.getProperty(PropertyNames.BOOTSTRAP_SERVERS) == null) { + props.put(PropertyNames.BOOTSTRAP_SERVERS, "localhost:9092"); + } + if (props.get(PropertyNames.ZOOKEEPER_CONNECT) == null) { + props.put(PropertyNames.ZOOKEEPER_CONNECT, "localhost:2181/haa"); + } + String numThreads = null; + if ((numThreads = props.getProperty(PropertyNames.NUM_STREAM_THREADS)) == null) { + props.put(PropertyNames.NUM_STREAM_THREADS, Constants.FOUR); + } else { + props.put(PropertyNames.NUM_STREAM_THREADS, Integer.parseInt(numThreads)); + } + } + + /** + * Gets {@link ProcessorSupplier} instance. + * + * @param processor the processor + * @param props the props + * @param metricRegistry {@link MetricRegistry} + * @param isLastProcessor if it is last processor + * @return the processor supplier instance. + * + * @see ProcessorSupplier + * + */ + @SuppressWarnings("unchecked") + private ProcessorSupplier getProcessorSupplier( + Class processor, Properties props, + MetricRegistry metricRegistry, boolean isLastProcessor) { + return () -> { + try { + StreamProcessor clone = (StreamProcessor) + ctx.getAutowireCapableBeanFactory().createBean(processor); + clone.initConfig(props); + KafkaStreamsProcessor streamProcessor = + (KafkaStreamsProcessor) ctx.getAutowireCapableBeanFactory() + .createBean(KafkaStreamsProcessor.class); + streamProcessor.initProcessor(clone, props, metricRegistry, isLastProcessor); + return streamProcessor; + } catch (BeansException be) { + throw new IllegalArgumentException("Unable to instantiate processor", be); + } + }; + } + + /** + * Gets the stream qualifier. + * + * @param config the Properties instance. + * @return the stream qualifier for this environment. + */ + private String getStreamQualifier(Properties config) { + String topicQualifier = ""; + String env = config.getProperty(PropertyNames.ENV); + if ((env != null) && (env.length() > 0)) { + String tenant = config.getProperty(PropertyNames.TENANT); + topicQualifier = "-" + env + "-" + tenant; + } + return topicQualifier; + } + + /** + * Provision to check the health status of certain components, if enabled. For eg. Redis, MongoDB, + * Kafka Consumer group, MQTT Server etc. If the health monitor is part of the "ignore at bootstrap" list, + * then health status reported by those won't matter and application will proceed to start regardless. + * If initial health check fails then log the error and exit the application. + * + * @return true, if application needs to be terminated. + */ + protected boolean bootstrapHealthCheck() { + logger.info("List of health monitors that can be ignored if initial" + + " health check fails are : {}", bootstrapIgnoredMonitors); + List failedHealthMonitors = healthService.triggerInitialCheck(); + boolean exit = false; + for (HealthMonitor monitor : failedHealthMonitors) { + String name = monitor.monitorName(); + if (!bootstrapIgnoredMonitors.contains(monitor.monitorName())) { + exit = true; + logger.error("Initial health check failed for health monitor : {}. Cannot be ignored", name); + } else { + logger.info("Ignoring initial health check fail for health monitor : {}," + + " as it is part of the ignored list", name); + } + } + return exit; + } + + /** + * Gets the streams instance. + * + * @return the streams + */ + // used for unit testing + public KafkaStreams getStreams() { + return streams; + } + + /** + * Launches the {@link KafkaStreams} after executing some preliminary steps and checks. + * This method is the place where the callback for HealthService is registered. This callback defines + * the action to be taken when a health monitor is continuously reporting unhealthy. + * + * @param props the Properties instance containing all the configuration supplied. + */ + @Override + protected void doLaunch(Properties props) { + boolean exit = false; + try { + exit = bootstrapHealthCheck(); + } catch (Exception e) { + logger.error("Error while bootstrapping Health Check {}", e); + exit = true; + } + if (exit) { + logger.info("Waiting on prometheus to collect metrics for {} milliseconds before exiting stream processor.", + waitTimeInMills); + try { + Thread.sleep(waitTimeInMills); + } catch (InterruptedException e) { + logger.error("Interrupted exception has occured in kafka streams launcher post bootstrapHealthCheck"); + Thread.currentThread().interrupt(); + } + logger.error("Exiting stream processor as initial health check has failed"); + System.exit(1); + } else { + logger.info("Initial health check of stream processor has passed. Continuing with streams creation"); + } + checkMergeDefaults(props); + initializeMetricReporter(props); + HarmanRocksDBStore.setProperties(props); + StreamProcessorDiscoveryService discoverySvc = loadDiscoveryService(props); + discoverySvc.setProperties(props); + List> processors = discoverySvc.discoverProcessors(); + logger.info("Number of stream processors: {}", processors.size()); + Topology topology = buildKafkaStreamsTopology((List>) processors, props); + streams = new KafkaStreams(topology, props); + kafkaStateListener.setStreams(streams); + streams.setStateListener(kafkaStateListener); + streams.setGlobalStateRestoreListener(new LoggingStateRestoreListener()); + threadStatusPrinter.init(streams); + final MaxFailuresUncaughtExceptionHandler exceptionHandler = + new MaxFailuresUncaughtExceptionHandler(maximumFailures, + maxTimeInterval); + streams.setUncaughtExceptionHandler(exceptionHandler); + if (cleanupStateStore) { + streams.cleanUp(); + } + healthService.registerCallBack(new KslHealthServiceCallBack()); + healthService.startHealthServiceExecutor(); + diagnosticService.triggerDiagnosis(); + streams.start(); + } + + /** + * Gets the bootstrap ignored monitors. + * + * @return the bootstrap ignored monitors + */ + List getBootstrapIgnoredMonitors() { + return bootstrapIgnoredMonitors; + } + + /** + * Sets the bootstrap ignored monitors. + * + * @param monitorNames the new bootstrap ignored monitors + */ + void setBootstrapIgnoredMonitors(List monitorNames) { + this.bootstrapIgnoredMonitors = monitorNames; + } + + /** + * Checks if is restart on failure. + * + * @return true, if is restart on failure + */ + boolean isRestartOnFailure() { + return restartOnFailure; + } + + /** + * Sets the restart on failure. + * + * @param restartOnFailure the new restart on failure + */ + void setRestartOnFailure(boolean restartOnFailure) { + this.restartOnFailure = restartOnFailure; + } + + /** + * Sets the health service. + * + * @param healthService the new health service + */ + void setHealthService(HealthService healthService) { + this.healthService = healthService; + } + + /** + * The callback Class for {@link HealthService} framework. + */ + class KslHealthServiceCallBack implements HealthServiceCallBack { + + /** + * Performs restart if the application is configured to be restarted if health monitor(s) + * report unhealthy. + * + * @return true, if is to be restarted. + */ + @Override + public boolean performRestart() { + if (restartOnFailure) { + terminate(); + } + return restartOnFailure; + } + + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsProcessor.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsProcessor.java new file mode 100644 index 0000000..dca76c6 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsProcessor.java @@ -0,0 +1,301 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import com.codahale.metrics.Meter; +import com.codahale.metrics.MetricRegistry; +import org.apache.kafka.streams.processor.api.Processor; +import org.apache.kafka.streams.processor.api.ProcessorContext; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.utils.DLQHandler; +import org.eclipse.ecsp.domain.IgniteExceptionDataV1_1; +import org.eclipse.ecsp.entities.EventData; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.eclipse.ecsp.utils.metrics.IgniteErrorCounter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.Properties; + +/** + * A thin layer on top of our processors to support. + *
      + *
    • portability between similar systems in the future
    • + *
    • an AOP like layer that will allow interjection of common processing + * across all processors.
    • + *
    + * + * @author ssasidharan + * @param the generic type + * @param the generic type + * @param the generic type + * @param the generic type + */ +public class KafkaStreamsProcessor implements + Processor, ConfigChangeListener, TickListener { + + /** The Constant DELIM. */ + private static final String DELIM = "/"; + + /** The Constant PROCESS_RATE. */ + private static final String PROCESS_RATE = "ProcessingRatePerSecond"; + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(KafkaStreamsProcessor.class); + + /** The worker. This holds the instance of a {@link StreamProcessor}*/ + private StreamProcessor worker; + + /** The {@link KafkaStreamsProcessorContext} .*/ + private KafkaStreamsProcessorContext context; + + /** The config. */ + private Properties config; + + /** The {@link MetricRegistry}. */ + private MetricRegistry metricRegistry; + + /** The processed event rate meter. */ + private Meter processedEventRateMeter = null; + + /** The last processor in chain. */ + private boolean lastProcessorInChain = false; + + /** The {@link DLQHandler}. */ + @Autowired + private DLQHandler dLQHandler; + + /** The {@link IgniteErrorCounter}. */ + @Autowired + private IgniteErrorCounter errorCounter; + + /** The ctx. */ + @Autowired + private ApplicationContext ctx; + + /** + * Instantiates a new kafka streams processor. + */ + public KafkaStreamsProcessor() { + // default constructor + } + + /** + * Initializes the stream processor class. + * + * @param worker {@link StreamProcessor} + * @param config The configuration supplied. + * @param metricRegistry {@link MetricRegistry} + * @param lastProcessorInChain lastProcessorInChain + */ + public void initProcessor(StreamProcessor worker, Properties config, + MetricRegistry metricRegistry, boolean lastProcessorInChain) { + logger.debug("Initializing KafkaStreamsProcessor for worker {} ", worker.name()); + this.worker = worker; + this.config = config; + this.metricRegistry = metricRegistry; + this.lastProcessorInChain = lastProcessorInChain; + logger.info("Creating KafkaStreamsProcessor wrapper {} for worker {} named {}", this, worker, worker.name()); + + } + + /** + * Initiliazes the init() on the stream-processor worker and registers the metrics for it. + * + * @param c the c + */ + @Override + public void init(ProcessorContext c) { + logger.debug("Inside init method of KafkaStreamsProcessor for worker {}", + worker.name()); + this.context = new KafkaStreamsProcessorContext<>(c, config, this.metricRegistry, lastProcessorInChain, ctx); + logger.info("Initializing stream processor {}", worker.name()); + worker.init(this.context); + // this here, so that it calls only after the Processor is initialized + WallClock.getInstance().subscribe(this); + registerMetrics(); + logger.info("Initialization complete for stream processor {}", worker.name()); + } + + /** + * Process the Kafka record as soon as it comes in the topic. This method also handles the + * exception occurred in the processing of the record by a stream processor down the line. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record kafkaRecord) { + + KIn key = kafkaRecord.key(); + VIn value = kafkaRecord.value(); + try { + mark(processedEventRateMeter); + if (key instanceof byte[] keyBytes) { + byte[] valueBytes = (byte[]) value; + logger.debug("Processing byte array Key:{},byte array value:{} by worker:{}", + new String(keyBytes, StandardCharsets.UTF_8), + new String(valueBytes, StandardCharsets.UTF_8), worker.name()); + } + if (key instanceof IgniteKey) { + logger.debug("Processing ignite Key:{}, ignite value:{} by worker:{}", key, value, worker.name()); + if (value instanceof IgniteEvent igniteEvent) { + EventData eventData = igniteEvent.getEventData(); + if (dLQHandler.isReprocessingEnabled() + && eventData instanceof IgniteExceptionDataV1_1 igniteExceptionData) { + String processorName = igniteExceptionData.getProcessorName(); + if (worker.name().equals(processorName)) { + worker.process(kafkaRecord); + } else { + @SuppressWarnings("unchecked") + KOut igniteKey = (KOut) key; + + @SuppressWarnings("unchecked") + VOut igniteValue = (VOut) value; + + Record igniteRecord = + new Record<>(igniteKey, igniteValue, kafkaRecord.timestamp()); + this.context.forward(igniteRecord); + } + return; + } + } + } + worker.process(kafkaRecord); + } catch (Exception ex) { + errorCounter.incErrorCounter(Optional.ofNullable(context.getTaskID()), ex.getClass()); + dLQHandler.forwardToDlq(context, key, value, ex, worker.name()); + } + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + // RTC-141484 - Kafka version upgrade from 1.0.0. to 2.2.0 changes. + public void punctuate(long timestamp) { + worker.punctuate(timestamp); + } + + /** + * Invokes close() on this worker. + */ + @Override + public void close() { + worker.close(); + WallClock.getInstance().unsubscribe(this); + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + worker.configChanged(props); + } + + /** + * Tick. + * + * @param seconds the seconds + */ + @Override + public void tick(long seconds) { + worker.punctuateWc(seconds); + } + + /** + * Metric to report rate at which events are getting processed, + * or in other words rate at which the process method is getting called. + */ + private void registerEventRateMetric() { + if (Boolean.parseBoolean(this.config.getProperty("metrics.event.rate.enable", "true"))) { + logger.info("Registering metrics to report the event rate per second"); + String metricName = this.worker.name() + DELIM + PROCESS_RATE; + // Every thread should share the same metric + if (null != metricRegistry) { + try { + processedEventRateMeter = this.metricRegistry.register(metricName, new Meter()); + } catch (Exception e) { + logger.info("Metric {} already registered.", metricName); + processedEventRateMeter = this.metricRegistry.getMeters().get(metricName); + } + } else { + logger.error("Metric registry is not initialized."); + } + + } + } + + /** + * Register metrics. + */ + private void registerMetrics() { + registerEventRateMetric(); + } + + /** + * Marking method for metered event metric. + * + * @param meter meter + */ + private void mark(Meter meter) { + if (null != meter) { + meter.mark(); + } + } + + /** + * Gets the error count. + * + * @param exceptionClassName the exception class name + * @return the error count + */ + public double getErrorCount(Class exceptionClassName) { + return errorCounter.getErrorCounterValue(Optional.ofNullable(context.getTaskID()), exceptionClassName); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsProcessorContext.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsProcessorContext.java new file mode 100644 index 0000000..735651b --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsProcessorContext.java @@ -0,0 +1,448 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import com.codahale.metrics.MetricRegistry; +import org.apache.commons.lang3.StringUtils; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.streams.processor.PunctuationType; +import org.apache.kafka.streams.processor.Punctuator; +import org.apache.kafka.streams.processor.api.ProcessorContext; +import org.apache.kafka.streams.processor.api.Record; +import org.apache.kafka.streams.processor.api.RecordMetadata; +import org.apache.kafka.streams.state.KeyValueStore; +import org.eclipse.ecsp.analytics.stream.base.stores.MapObjectStateStore; +import org.eclipse.ecsp.analytics.stream.threadlocal.ContextKey; +import org.eclipse.ecsp.analytics.stream.threadlocal.TaskContextHandler; +import org.eclipse.ecsp.domain.IgniteEventSource; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.transform.IgniteKeyTransformer; +import org.eclipse.ecsp.transform.Transformer; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; + +import java.lang.reflect.InvocationTargetException; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; +import java.util.Properties; + +/** + * Abstraction for the Kafka Streams based processing context. + * + * @author ssasidharan + * @param the key type + * @param the value type + */ +public class KafkaStreamsProcessorContext implements StreamProcessingContext { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(KafkaStreamsProcessorContext.class); + + /** The context. */ + private ProcessorContext context; + + /** The kafka producer. */ + private KafkaProducer kafkaProducer = null; + + /** The config. */ + private Properties config = null; + + /** The {@link MetricRegistry}. */ + private MetricRegistry metricRegistry = null; + + /** The task id. Storing the taskID from the processor context-->topicGroupID_partitionID */ + private String taskId; + + /** The {@link TaskContextHandler}. This is the first processor in chain of processors. */ + private TaskContextHandler handler; + + /** The last processor in chain. */ + private boolean lastProcessorInChain; + + /** The {@link Transformer} implementation to transform the Kafka Record. */ + private Transformer igniteTransformer; + + /** The key transformer implementation to transform the key part. */ + private IgniteKeyTransformer keyTransformer; + + /** The ctx. */ + @Autowired + private ApplicationContext ctx; + + /** + * Enum for the state-stores that are supported by this library. + */ + public enum StoreType { + + /** The rocksdb. */ + ROCKSDB("rocksdb") { + @Override + public KeyValueStore getStore(ProcessorContext context, String name) { + return (KeyValueStore) context.getStateStore(name); + } + }, + + /** The map. */ + MAP("map") { + @Override + public KeyValueStore getStore(ProcessorContext context, String name) { + return new MapObjectStateStore(); + } + }; + + /** The store type. */ + private String storeType; + + /** + * Instantiates a new store type. + * + * @param type the type + */ + StoreType(String type) { + this.storeType = type; + } + + /** + * Gets the store type. + * + * @return the store type + */ + public String getStoreType() { + return this.storeType; + } + + /** + * Whether the current type is supported or not. + * + * @param type the type + * @return Whether the given type is supported or not. + */ + public static boolean isSupported(String type) { + for (StoreType storeType : StoreType.values()) { + if (storeType.getStoreType().equalsIgnoreCase(type)) { + return true; + } + } + return false; + } + + /** + * Gets the store. + * + * @param the key type + * @param the value type + * @param context the context + * @param name the name + * @return the store + */ + public abstract KeyValueStore getStore(ProcessorContext context, String name); + + /** + * Returns the instance of KeyValueStore based on the store type. + + * @param Key type in {@link ProcessorContext} + * @param Value type in {@link ProcessorContext} + * @param context {@link ProcessorContext} instance + * @param name The name of the state-store. + * @param storeType The type of the store + * @return the instance of KeyValueStore based on the store type. + */ + public static KeyValueStore getStateStore(ProcessorContext context, + String name, String storeType) { + if (isSupported(storeType)) { + if (storeType.equals(ROCKSDB.getStoreType())) { + return ROCKSDB.getStore(context, name); + } else if (storeType.equals(MAP.getStoreType())) { + return MAP.getStore(context, name); + } + } + return null; + } + } + + /** + * Initializes the context for KafkaStreams. This also initializes the list of transformers provided + * to transform the kafka records. For different customers / platforms, different transformers can be + * provided and based on the source of kafka record, specific transformer will be used to transform the record. + * + * @param pc {@link ProcessorContext} instance. + * @param config Configurations needed to initialize the {@link KafkaProducer} + * @param metricRegistry {@link MetricRegistry} + * @param lastProcessorInChain Whether this is the last processor in chain. + * @param ctx2 {@link ApplicationContext} + */ + public KafkaStreamsProcessorContext(ProcessorContext pc, Properties config, MetricRegistry metricRegistry, + boolean lastProcessorInChain, ApplicationContext ctx2) { + this.context = pc; + this.config = config; + this.metricRegistry = metricRegistry; + this.taskId = pc.taskId().toString(); + this.lastProcessorInChain = lastProcessorInChain; + this.ctx = ctx2; + kafkaProducer = KafkaProducerInstance.getProducerInstance(config); + handler = TaskContextHandler.getTaskContextHandler(); + String transformerList = config.getProperty(PropertyNames.EVENT_TRANSFORMER_CLASSES); + // Flag which will ensure that the instances of transformers + // created via runtime class loader( Non Spring Based Bean + // creation) will have the properties available to it via the + // parameterized constructor. + boolean transformerInjectPropertyFlg = Boolean.parseBoolean(config + .getProperty(PropertyNames.TRANSFORMER_INJECT_PROPERTY_ENABLE)); + + if (StringUtils.isBlank(transformerList)) { + logger.error("Event transformer list cannot be blank"); + throw new IllegalArgumentException("Event transformer list cannot be blank"); + } + String[] transformerArr = transformerList.split(","); + for (String transformer : transformerArr) { + Transformer t = null; + if (transformerInjectPropertyFlg) { + try { + logger.info("Loading parameterized constructor for transformer :{}", transformer); + t = (Transformer) ctx.getAutowireCapableBeanFactory().getBean(transformer, config); + } catch (Exception ex) { + throw new IllegalArgumentException( + transformer + " parameterized constructor is not available to accept Properties."); + } + } else { + t = (Transformer) ctx.getAutowireCapableBeanFactory().getBean(transformer); + } + if (IgniteEventSource.IGNITE.equals(t.getSource())) { + igniteTransformer = t; + break; + } + + } + Objects.requireNonNull(igniteTransformer); + String transformer = config.getProperty(PropertyNames.IGNITE_KEY_TRANSFORMER); + try { + keyTransformer = (IgniteKeyTransformer) getClass().getClassLoader().loadClass(transformer) + .getDeclaredConstructor().newInstance(); + } catch (InstantiationException | IllegalAccessException + | ClassNotFoundException | InvocationTargetException | NoSuchMethodException e) { + throw new IllegalArgumentException( + String.format("%s refers to a class that is not available on the classpath", keyTransformer)); + } + Objects.requireNonNull(keyTransformer); + } + + /** + * The name of the stream / Kafka topic . + * + * @return the string + */ + @Override + public String streamName() { + Optional recordMetadata = context.recordMetadata(); + if (recordMetadata.isPresent()) { + return recordMetadata.get().topic(); + } + return null; + } + + /** + * The partition ID. + * + * @return the partition ID. + */ + @Override + public int partition() { + Optional recordMetadata = context.recordMetadata(); + if (recordMetadata.isPresent()) { + return recordMetadata.get().partition(); + } + return 0; + } + + /** + * Offset of the Kafka Record. + * + * @return the Offset for the Kafka Record. + */ + @Override + public long offset() { + Optional recordMetadata = context.recordMetadata(); + if (recordMetadata.isPresent()) { + return recordMetadata.get().offset(); + } + return 0; + } + + /** + * Checkpoint. + */ + @Override + public void checkpoint() { + context.commit(); + } + + /** + * Forwards a record to all child processors. + * + * @param kafkaRecord the kafka record + * + * @see ProcessorContext#forward(Record) + */ + @Override + public void forward(Record kafkaRecord) { + kafkaRecord.withTimestamp(System.currentTimeMillis()); + context.forward(kafkaRecord); + } + + /** + * Forwards a record to the specified child processor. + * + * @param kafkaRecord the kafka record + * @param name name of the child processor. + */ + @Override + public void forward(Record kafkaRecord, String name) { + if (lastProcessorInChain) { + kafkaRecord.withTimestamp(System.currentTimeMillis()); + context.forward(kafkaRecord, name); + } else { + handler.setValue(taskId, ContextKey.KAFKA_SINK_TOPIC, name); + kafkaRecord.withTimestamp(System.currentTimeMillis()); + context.forward(kafkaRecord); + } + } + + /** + * Forward directly to a Kafka topic. + * + * @param key the key + * @param value the value + * @param topic the topic + */ + @Override + public void forwardDirectly(String key, String value, String topic) { + forwardDirectly(key.getBytes(), value.getBytes(), topic); + } + + /** + * Forwards directly to a kafka topic. + * + * @param key the key + * @param value the value + * @param topic the topic + */ + @Override + public void forwardDirectly(@SuppressWarnings("rawtypes") IgniteKey key, IgniteEvent value, String topic) { + if (!(key instanceof IgniteStringKey)) { + throw new IllegalArgumentException(String + .format("Key %s is not currently supported", key.getClass().getName())); + } + @SuppressWarnings("unchecked") + byte[] keyBytes = keyTransformer.toBlob(key); + byte[] valueBytes = igniteTransformer.toBlob(value); + forwardDirectly(keyBytes, valueBytes, topic); + } + + /** + * Forward directly to a Kafka topic. + * + * @param key the key + * @param value the value + * @param topic the topic + */ + private void forwardDirectly(byte[] key, byte[] value, String topic) { + kafkaProducer.send(new ProducerRecord<>(topic, key, value), (metadata, exception) -> { + if (exception != null) { + logger.error("Exception when pushing message directly to stream: ", exception); + } + }); + } + + /** + * Check what is the value of the property "state.store.type" if the + * state store type is map, then return the HashMapStateStore else go as + * usual through context. + * Pls note that hash map state store will nnot be initialized during + * the topology builder. It will be initialized first time when you call + * this method + * + * @param name the name + * @return the state store + */ + @Override + public KeyValueStore getStateStore(String name) { + String storeType = this.config.getProperty(PropertyNames.STATE_STORE_TYPE, StoreType.ROCKSDB.getStoreType()); + return StoreType.getStateStore(context, name, storeType); + } + + /** + * Gets the metric registry. + * + * @return the metric registry + */ + @Override + public MetricRegistry getMetricRegistry() { + return this.metricRegistry; + } + + /** + * Gets the task ID. + * + * @return the task ID + */ + @Override + public String getTaskID() { + return this.taskId; + } + + /** + * Schedule. + * + * @param interval the interval + * @param punctuationType the punctuation type + * @param punctuator the punctuator + */ + @Override + public void schedule(long interval, PunctuationType punctuationType, Punctuator punctuator) { + // RTC-141484 - Kafka version upgrade from 1.0.0. to 2.2.0 changes + context.schedule(Duration.ofMillis(interval), punctuationType, punctuator); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/Launcher.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/Launcher.java new file mode 100644 index 0000000..d899ed5 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/Launcher.java @@ -0,0 +1,309 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import io.prometheus.client.exporter.HTTPServer; +import io.prometheus.client.hotspot.DefaultExports; +import org.eclipse.ecsp.analytics.stream.base.idgen.MessageIdGenerator; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.PropertySource; +import org.springframework.core.env.AbstractEnvironment; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.core.env.Environment; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Map; +import java.util.Properties; + +import static org.eclipse.ecsp.analytics.stream.base.utils.Constants.COLON_9100; + +/** + * Entry point for running the stream processors. + * + * @author ssasidharan + */ +@Configuration +@ComponentScan(basePackages = { "org.eclipse.ecsp" }, excludeFilters + = {@ComponentScan.Filter(type = FilterType.REGEX, pattern = "org.eclipse.ecsp.hashicorp.*") }) +@PropertySource("classpath:/application-base.properties") +@PropertySource(ignoreResourceNotFound = true, value = "classpath:/application.properties") +@PropertySource(ignoreResourceNotFound = true, value = "classpath:/application-ext.properties") +@Component +public class Launcher { + + /** The Constant VALUE_END. */ + private static final String VALUE_END = "}"; + + /** The Constant VALUE_START. */ + private static final String VALUE_START = "${"; + + /** The launcher class name. */ + @Value("${launcher.impl.class.fqn}") + private String launcherClassName; + + /** The shutdown hook wait time ms. */ + @Value("${shutdown.hook.wait.ms}") + private long shutdownHookWaitTimeMs; + + /** Indicates execution of shutdown. */ + @Value("${exec.shutdown.hook}") + private boolean executeShutdownHook; + + /** The message id generator type. */ + @Value("${messageid.generator.type}") + private String messageIdGeneratorType; + + /** The env. */ + @Autowired + private Environment env; + + /** The ctx. */ + @Autowired + private ApplicationContext ctx; + + /** The prometheus export server. */ + private HTTPServer prometheusExportServer; + + /** Prometheus Agent Port Number. **/ + @Value(VALUE_START + PropertyNames.PROMETHEUS_AGENT_PORT_KEY + COLON_9100 + VALUE_END) + private int prometheusExportPort; + + /** The enable prometheus. */ + @Value(VALUE_START + PropertyNames.ENABLE_PROMETHEUS + VALUE_END) + private boolean enablePrometheus; + + /** The dynamic props. */ + protected static Properties dynamicProps = new Properties(); + + /** The {@link LauncherProvider} implementation. */ + volatile LauncherProvider provider = null; + + /** The Constant LOGGER. */ + private static final IgniteLogger LOGGER = IgniteLoggerFactory.getLogger(Launcher.class); + + /** + * inMemoryPropertySource(). + * + * @param cenv the cenv + * @return EnumerablePropertySource + */ + @Bean + @Lazy(value = false) + public EnumerablePropertySource> inMemoryPropertySource(ConfigurableEnvironment cenv) { + MutablePropertySources propSources = cenv.getPropertySources(); + PropertiesPropertySource pps = new PropertiesPropertySource("in-mem", dynamicProps); + propSources.addFirst(pps); + return pps; + } + + /** + * Creates a bean of {@link MessageIdGenerator} implementation. + * + * @return MessageIdGenerator + */ + @Bean + public MessageIdGenerator msgIdGenerator() { + try { + Class c = getClass().getClassLoader().loadClass(messageIdGeneratorType); + return (MessageIdGenerator) ctx.getBean(c); + } catch (ClassNotFoundException e) { + throw new IllegalArgumentException("MessageId generator type " + messageIdGeneratorType + " ,is undefined"); + } + } + + /** + * Entry point for stream processors. + + * @param args Arguments to main method + * @throws Exception exception + */ + public static void main(String[] args) throws Exception { + ConfigurableApplicationContext ctxt = new AnnotationConfigApplicationContext(Launcher.class); + Launcher l = ctxt.getBean(Launcher.class); + l.launch(); + } + + /** + * Extract all the properties supplied via ".properties" file & config-map and, launch the + * streams application. + * + * @throws IllegalArgumentException if {@link LauncherProvider} implementation isn't available on the + * classpath. + */ + + public void launch() throws IllegalArgumentException { + Thread.setDefaultUncaughtExceptionHandler((Thread t, Throwable e) -> + LOGGER.error("Uncaught exception for thread " + t.getName(), e)); + + if (executeShutdownHook) { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + LOGGER.info("Shutdown hook executing"); + closeStream(); + LOGGER.info("Shutdown hook waiting"); + try { + Thread.sleep(shutdownHookWaitTimeMs); + } catch (InterruptedException e) { + LOGGER.error("Interrupted when waiting in shutdown hook"); + Thread.currentThread().interrupt(); + } + LOGGER.info("Shutdown hook complete"); + })); + } + + Properties props = extractProperties(env); + try { + Class c = getClass().getClassLoader().loadClass(launcherClassName); + provider = (LauncherProvider) ctx.getBean(c); + if (enablePrometheus) { + prometheusExportServer = new HTTPServer(prometheusExportPort, true); + DefaultExports.initialize(); + } + provider.launch(props); + + } catch (ClassNotFoundException e) { + throw new IllegalArgumentException(PropertyNames.LAUNCHER_IMPL + + " refers to a class that is not available on the classpath"); + } catch (IllegalArgumentException e1) { + throw e1; + } catch (IOException ie) { + LOGGER.error("IOException occurred ", ie); + } + } + + /** + * Extract properties. + * + * @param env the env + * @return the properties + */ + private Properties extractProperties(Environment env) { + Properties props = new Properties(); + MutablePropertySources propSrcs = ((AbstractEnvironment) env).getPropertySources(); + for (org.springframework.core.env.PropertySource ps : propSrcs) { + if (ps instanceof EnumerablePropertySource propertySource) { + for (String pn : propertySource.getPropertyNames()) { + props.setProperty(pn, env.getProperty(pn)); + } + } + } + props.putAll(dynamicProps); + return props; + } + + /** + * Terminates the streams application. + */ + public void closeStream() { + if (provider != null) { + LOGGER.info("Asked to terminate"); + provider.terminate(); + } + if (null != prometheusExportServer) { + LOGGER.info("Asked Prometheus Export Server to terminate"); + prometheusExportServer.stop(); + } + } + + /** + * closeStreamWithTimeout(). + */ + //WI-365808 For unit test cases + public void closeStreamWithTimeout() { + if (provider != null) { + LOGGER.info("Asked to terminate with timeout"); + provider.terminateStreamsWithTimeout(); + } + if (null != prometheusExportServer) { + LOGGER.info("Asked Prometheus Export Server to terminate"); + prometheusExportServer.stop(); + } + } + + /** + * Checks if is execute shutdown hook. + * + * @return true, if is execute shutdown hook + */ + public boolean isExecuteShutdownHook() { + return executeShutdownHook; + } + + /** + * Sets the execute shutdown hook. + * + * @param executeShutdownHook the new execute shutdown hook + */ + public void setExecuteShutdownHook(boolean executeShutdownHook) { + this.executeShutdownHook = executeShutdownHook; + } + + /** + * Getter for dynamicProps. + * + * @return the dynamicProps + */ + public static Properties getDynamicProps() { + return dynamicProps; + } + + /** + * Setter for dynamicProps. + * + *@param dynamicProps the dynamicProps to set + */ + public static void setDynamicProps(Properties dynamicProps) { + Launcher.dynamicProps = dynamicProps; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/LauncherProvider.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/LauncherProvider.java new file mode 100644 index 0000000..10403fd --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/LauncherProvider.java @@ -0,0 +1,57 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import java.util.Properties; + +/** + * Launches stream processing specific to the stream + * processing system being used (for ex Kafka Streams or Samza). + * + * @author ssasidharan + */ +public interface LauncherProvider { + void launch(Properties props); + + void terminate(); + + //WI-365808 For unit test cases + void terminateStreamsWithTimeout(); +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/PropertyNames.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/PropertyNames.java new file mode 100644 index 0000000..f6b86bf --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/PropertyNames.java @@ -0,0 +1,970 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.streams.StreamsConfig; +import org.eclipse.ecsp.analytics.stream.base.exception.PropertyNotFoundException; + +import java.util.HashMap; +import java.util.Map; + +/** + * PropertyNames: Constant File. A list of all the properties exposed by stream-base library. + */ +public class PropertyNames { + + /** + * Instantiates a new property names. + */ + private PropertyNames() { + // empty private constructor for utility class + } + + /** The Constant DMA_CONFIG_RESOLVER_CLASS. */ + public static final String DMA_CONFIG_RESOLVER_CLASS = "dma.config.resolver.class"; + + /** The Constant ADMIN. */ + private static final String ADMIN = "admin"; + + /** The Constant SOURCE_TOPIC_NAME2. */ + // source.topic.name2 is from CFMS + public static final String SOURCE_TOPIC_NAME2 = "source.topic.name2"; + + /** The Constant SOURCE_TOPIC_NAME. */ + public static final String SOURCE_TOPIC_NAME = "source.topic.name"; + + /** The Constant DMA_NOTIFICATION_TOPIC_NAME. */ + public static final String DMA_NOTIFICATION_TOPIC_NAME = "dma.notification.topic.name"; + + /** The Constant DISCOVERY_SERVICE_IMPL. */ + public static final String DISCOVERY_SERVICE_IMPL = "discovery.impl.class.fqn"; + + /** The Constant LAUNCHER_IMPL. */ + public static final String LAUNCHER_IMPL = "launcher.impl.class.fqn"; + + /** The Constant EVENT_TRANSFORMER_CLASSES. */ + public static final String EVENT_TRANSFORMER_CLASSES = "event.transformer.classes"; + + /** The Constant IGNITE_KEY_TRANSFORMER. */ + public static final String IGNITE_KEY_TRANSFORMER = "ignite.key.transformer.class"; + + /** The Constant DEVICE_MESSAGING_EVENT_TRANSFORMER. */ + public static final String DEVICE_MESSAGING_EVENT_TRANSFORMER = "device.messaging.event.transformer.class"; + + /** The Constant DEVICE_MESSAGE_FEEDBACK_TOPIC. */ + public static final String DEVICE_MESSAGE_FEEDBACK_TOPIC = "device.message.feedback.topic"; + + /** The Constant INGESTION_SERIALIZER_CLASS. */ + public static final String INGESTION_SERIALIZER_CLASS = "ingestion.serializer.class"; + + /** The Constant SHUTDOWN_HOOK_WAIT_MS. */ + public static final String SHUTDOWN_HOOK_WAIT_MS = "shutdown.hook.wait.ms"; + + /** The Constant APPLICATION_ID. */ + public static final String APPLICATION_ID = StreamsConfig.APPLICATION_ID_CONFIG; + + /** The Constant NUM_STREAM_THREADS. */ + public static final String NUM_STREAM_THREADS = StreamsConfig.NUM_STREAM_THREADS_CONFIG; + + /** The Constant CLIENT_ID. */ + public static final String CLIENT_ID = ProducerConfig.CLIENT_ID_CONFIG; + + /** The Constant ZOOKEEPER_CONNECT. */ + // RTC-141484 - Kafka version upgrade from 1.0.0. to 2.2.0 changes + public static final String ZOOKEEPER_CONNECT = "zookeeper.connect"; + + /** The Constant BOOTSTRAP_SERVERS. */ + public static final String BOOTSTRAP_SERVERS = StreamsConfig.BOOTSTRAP_SERVERS_CONFIG; + + /** The Constant REPLICATION_FACTOR. */ + public static final String REPLICATION_FACTOR = StreamsConfig.REPLICATION_FACTOR_CONFIG; + + /** The Constant AUTO_OFFSET_RESET_CONFIG. */ + public static final String AUTO_OFFSET_RESET_CONFIG = "auto.offset.reset"; + + /** The Constant APPLICATION_OFFSET_RESET. */ + public static final String APPLICATION_OFFSET_RESET = "application.offset.reset"; + + /** The Constant APPLICATION_RESET_TOPICS. */ + public static final String APPLICATION_RESET_TOPICS = "application.reset.topics"; + + /** The Constant TENANT. */ + public static final String TENANT = "tenant"; + + /** The Constant ENV. */ + public static final String ENV = "env"; + + /** The Constant STATE_STORE_TYPE. */ + public static final String STATE_STORE_TYPE = "state.store.type"; + + /** The Constant SHARED_DATA_SOURCE_IMPL. */ + public static final String SHARED_DATA_SOURCE_IMPL = "shared.data.source.impl.class"; + + /** The Constant SHARED_DATA_SOURCE_URL. */ + public static final String SHARED_DATA_SOURCE_URL = "shared.data.source.url"; + + /** The Constant SHARED_DATA_SOURCE_NAME. */ + public static final String SHARED_DATA_SOURCE_NAME = "shared.data.source.name"; + + /** The Constant SHARED_DATA_SOURCE_CONNECTION_PER_HOST. */ + public static final String SHARED_DATA_SOURCE_CONNECTION_PER_HOST = "shared.data.source.connection.per.host"; + + /** The Constant SHARED_DATA_SOURCE_CONNECTION_TIMEOUT. */ + public static final String SHARED_DATA_SOURCE_CONNECTION_TIMEOUT = "shared.data.source.connection.timeout"; + + /** The Constant SHARED_DATA_SOURCE_CONNECTION_SOCKET_TIMEOUT. */ + public static final String SHARED_DATA_SOURCE_CONNECTION_SOCKET_TIMEOUT = + "shared.data.source.connection.socket.timeout"; + + /** The Constant SHARED_TOPICS. */ + public static final String SHARED_TOPICS = "shared.topics"; + + /** The Constant MONGODB_URL. */ + // DB Connection constant --> start (FROM CFMS) + public static final String MONGODB_URL = "db.url"; + + /** The Constant MONGODB_PORT. */ + public static final String MONGODB_PORT = "db.port"; + + /** The Constant MONGODB_AUTH_USERNAME. */ + public static final String MONGODB_AUTH_USERNAME = "db.auth.username"; + + /** The Constant MONGODB_AUTH_PSWD. */ + public static final String MONGODB_AUTH_PSWD = "db.auth.password"; + + /** The Constant MONGODB_AUTH_DB. */ + public static final String MONGODB_AUTH_DB = "db.auth.db"; + + /** The Constant MONGODB_DBNAME. */ + public static final String MONGODB_DBNAME = "db.name"; + + /** The Constant MONGODB_POOL_MAX_SIZE. */ + public static final String MONGODB_POOL_MAX_SIZE = "db.pool.max.size"; + + /** The Constant MONGO_CLIENT_MAX_WAIT_TIME_MS. */ + public static final String MONGO_CLIENT_MAX_WAIT_TIME_MS = "db.client.max.wait.time.ms"; + + /** The Constant MONGO_CLIENT_CONNECTION_TIMEOUT_MS. */ + public static final String MONGO_CLIENT_CONNECTION_TIMEOUT_MS = "db.client.connection.timeout.ms"; + + /** The Constant MONGO_CLIENT_SOCKET_TIMEOUT_MS. */ + public static final String MONGO_CLIENT_SOCKET_TIMEOUT_MS = "db.client.socket,timeout.ms"; + + /** The Constant MONGO_MAX_CONNECTIONS. */ + public static final String MONGO_MAX_CONNECTIONS = "db.client.max.connections"; + + /** The Constant DEFAULT_MONGODB_URL. */ + private static final String DEFAULT_MONGODB_URL = "localhost"; + + /** The Constant DEFAULT_MONGODB_PORT. */ + private static final String DEFAULT_MONGODB_PORT = "12345"; + + /** The Constant DEFAULT_MONGODB_AUTH_USERNAME. */ + private static final String DEFAULT_MONGODB_AUTH_USERNAME = ADMIN; + + /** The Constant DEFAULT_MONGODB_AUTH_PSWD. */ + private static final String DEFAULT_MONGODB_AUTH_PSWD = "password"; + + /** The Constant DEFAULT_MONGODB_AUTH_DB. */ + private static final String DEFAULT_MONGODB_AUTH_DB = ADMIN; + + /** The Constant DEFAULT_MONGODB_DBNAME. */ + private static final String DEFAULT_MONGODB_DBNAME = ADMIN; + + /** The Constant DEFAULT_MAX_WAIT_TIME_MS. */ + private static final String DEFAULT_MAX_WAIT_TIME_MS = "30000"; + + /** The Constant DEFAULT_CONNECT_TIMEOUT_MS. */ + private static final String DEFAULT_CONNECT_TIMEOUT_MS = "20000"; + + /** The Constant DEFAULT_SOCKET_TIMEOUT_MS. */ + private static final String DEFAULT_SOCKET_TIMEOUT_MS = "60000"; + + /** The Constant DEFAULT_MAX_CONNECTION. */ + private static final String DEFAULT_MAX_CONNECTION = "50"; + + /** The Constant KAFKA_PARTITIONER. */ + // DB Connection constant --> end + public static final String KAFKA_PARTITIONER = "kafka.partitioner"; + + /** The Constant KAFKA_REPLACE_CLASSLOADER. */ + public static final String KAFKA_REPLACE_CLASSLOADER = "kafka.replace.classloader"; + + /** The Constant KAFKA_DEVICE_EVENTS_ASYNC_PUTS. */ + public static final String KAFKA_DEVICE_EVENTS_ASYNC_PUTS = "kafka.device.events.sync.puts"; + + /** The Constant REDIS_MODE. */ + public static final String REDIS_MODE = "redis.mode"; + + /** The Constant REDIS_SINGLE_ENDPOINT. */ + public static final String REDIS_SINGLE_ENDPOINT = "redis.single.endpoint"; + + /** The Constant REDIS_REPLICA_ENDPOINTS. */ + public static final String REDIS_REPLICA_ENDPOINTS = "redis.replica.endpoints"; + + /** The Constant REDIS_CLUSTER_ENDPOINTS. */ + public static final String REDIS_CLUSTER_ENDPOINTS = "redis.cluster.endpoints"; + + /** The Constant REDIS_SENTINEL_ENDPOINTS. */ + public static final String REDIS_SENTINEL_ENDPOINTS = "redis.sentinel.endpoints"; + + /** The Constant REDIS_MASTER_NAME. */ + public static final String REDIS_MASTER_NAME = "redis.master.name"; + + /** The Constant REDIS_MASTER_POOL_MAX. */ + public static final String REDIS_MASTER_POOL_MAX = "redis.master.pool.max.size"; + + /** The Constant REDIS_MASTER_IDLE_MIN. */ + public static final String REDIS_MASTER_IDLE_MIN = "redis.master.idle.min"; + + /** The Constant REDIS_SLAVE_POOL_MAX. */ + public static final String REDIS_SLAVE_POOL_MAX = "redis.slave.pool.max.size"; + + /** The Constant REDIS_SLAVE_IDLE_MIN. */ + public static final String REDIS_SLAVE_IDLE_MIN = "redis.slave.idle.min"; + + /** The Constant REDIS_SCAN_INTERVAL. */ + public static final String REDIS_SCAN_INTERVAL = "redis.scan.interval"; + + /** The Constant REDIS_BATCH_SIZE. */ + public static final String REDIS_BATCH_SIZE = "redis.batch.size"; + + /** The Constant REDIS_DATABASE. */ + public static final String REDIS_DATABASE = "redis.database"; + + /** The Constant REDIS_MAX_POOL. */ + public static final String REDIS_MAX_POOL = "redis.max.pool.size"; + + /** The Constant REDIS_MAX_IDLE. */ + public static final String REDIS_MAX_IDLE = "redis.max.idle"; + + /** The Constant REDIS_MIN_IDLE. */ + public static final String REDIS_MIN_IDLE = "redis.min.idle"; + + /** The Constant REDIS_READ_TIMEOUT. */ + public static final String REDIS_READ_TIMEOUT = "redis.read.timeout"; + + /** The Constant REDIS_RETRY_INTERVAL. */ + public static final String REDIS_RETRY_INTERVAL = "redis.retry.interval"; + + /** The Constant REDIS_RETRY_ATTEMPTS. */ + public static final String REDIS_RETRY_ATTEMPTS = "redis.retry.attempts"; + + /** The Constant REDIS_DATA_SERIALIZE. */ + public static final String REDIS_DATA_SERIALIZE = "redis.data.serialize"; + + /** The Constant REDIS_READ_MODE. */ + public static final String REDIS_READ_MODE = "redis.read.mode"; + + /** The Constant KAFKA_REBALANCE_TIME_MINS. */ + public static final String KAFKA_REBALANCE_TIME_MINS = "kafka.rebalance.time.mins"; + + /** The Constant KAFKA_CLOSE_TIMEOUT_SECS. */ + public static final String KAFKA_CLOSE_TIMEOUT_SECS = "kafka.close.timeout.secs"; + + /** The Constant KAFKA_SSL_ENABLE. */ + public static final String KAFKA_SSL_ENABLE = "kafka.ssl.enable"; + + /** The Constant KAFKA_ONE_WAY_TLS_ENABLE. */ + public static final String KAFKA_ONE_WAY_TLS_ENABLE = "kafka.one.way.tls.enable"; + + /** The Constant KAFKA_SASL_MECHANISM. */ + public static final String KAFKA_SASL_MECHANISM = "kafka.sasl.mechanism"; + + /** The Constant KAFKA_SASL_JAAS_CONFIG. */ + public static final String KAFKA_SASL_JAAS_CONFIG = "kafka.sasl.jaas.config"; + + /** The Constant KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM. */ + public static final String KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM = + "kafka.ssl.endpoint.identification.algorithm"; + + /** The Constant KAFKA_CLIENT_KEYSTORE. */ + public static final String KAFKA_CLIENT_KEYSTORE = "kafka.client.keystore"; + + /** The Constant KAFKA_CLIENT_KEYSTORE_PASSWORD. */ + public static final String KAFKA_CLIENT_KEYSTORE_PASSWORD = "kafka.client.keystore.password"; + + /** The Constant KAFKA_CLIENT_KEY_PASSWORD. */ + public static final String KAFKA_CLIENT_KEY_PASSWORD = "kafka.client.key.password"; + + /** The Constant KAFKA_CLIENT_TRUSTSTORE. */ + public static final String KAFKA_CLIENT_TRUSTSTORE = "kafka.client.truststore"; + + /** The Constant KAFKA_CLIENT_TRUSTSTORE_PASSWORD. */ + public static final String KAFKA_CLIENT_TRUSTSTORE_PASSWORD = "kafka.client.truststore.password"; + + /** The Constant KAFKA_SSL_CLIENT_AUTH. */ + public static final String KAFKA_SSL_CLIENT_AUTH = "kafka.ssl.client.auth"; + + /** The Constant KAFKA_MAX_REQUEST_SIZE. */ + public static final String KAFKA_MAX_REQUEST_SIZE = "kafka.max.request.size"; + + /** The Constant KAFKA_ACKS_CONFIG. */ + public static final String KAFKA_ACKS_CONFIG = "kafka.acks.config"; + + /** The Constant KAFKA_RETRIES_CONFIG. */ + public static final String KAFKA_RETRIES_CONFIG = "kafka.retries.config"; + + /** The Constant KAFKA_BATCH_SIZE_CONFIG. */ + public static final String KAFKA_BATCH_SIZE_CONFIG = "kafka.batch.size.config"; + + /** The Constant KAFKA_LINGER_MS_CONFIG. */ + public static final String KAFKA_LINGER_MS_CONFIG = "kafka.linger.ms.config"; + + /** The Constant KAFKA_BUFFER_MEMORY_CONFIG. */ + public static final String KAFKA_BUFFER_MEMORY_CONFIG = "kafka.buffer.memory.config"; + + /** The Constant KAFKA_REQUEST_TIMEOUT_MS_CONFIG. */ + public static final String KAFKA_REQUEST_TIMEOUT_MS_CONFIG = "kafka.request.timeout.ms.config"; + + /** The Constant KAFKA_DELIVERY_TIMEOUT_MS_CONFIG. */ + public static final String KAFKA_DELIVERY_TIMEOUT_MS_CONFIG = "kafka.delivery.timeout.ms.config"; + + /** The Constant KAFKA_COMPRESSION_TYPE_CONFIG. */ + public static final String KAFKA_COMPRESSION_TYPE_CONFIG = "kafka.compression.type.config"; + + /** The Constant KAFKA_CONSUMER_TOPIC. */ + public static final String KAFKA_CONSUMER_TOPIC = "kafka.consumer.topic"; + + /** The Constant KAFKA_CONSUMER_POLL. */ + public static final String KAFKA_CONSUMER_POLL = "kafka.consumer.poll"; + + /** The Constant LOG_COUNTS. */ + public static final String LOG_COUNTS = "log.counts"; + + /** The Constant LOG_COUNTS_MINUTES. */ + public static final String LOG_COUNTS_MINUTES = "log.counts.minutes"; + + /** The Constant LOG_PER_PDID. */ + public static final String LOG_PER_PDID = "log.per.pdid"; + + /** The Constant KINESIS_ACCESS_KEY. */ + public static final String KINESIS_ACCESS_KEY = "kinesis.accessKey"; + + /** The Constant KINESIS_SECRET_KEY. */ + public static final String KINESIS_SECRET_KEY = "kinesis.secretAccessKey"; + + /** The Constant KINESIS_RETRY_ATTEMPTS. */ + public static final String KINESIS_RETRY_ATTEMPTS = "kinesis.retry.attempts"; + + /** The Constant KINESIS_REGION. */ + public static final String KINESIS_REGION = "kinesis.region"; + + /** The Constant KCL_ACCESS_KEY. */ + public static final String KCL_ACCESS_KEY = "kcl.access.key"; + + /** The Constant KCL_SECRET_KEY. */ + public static final String KCL_SECRET_KEY = "kcl.secret.key"; + + /** The Constant KCL_WORKER_NUMBER_OF_THREADS. */ + public static final String KCL_WORKER_NUMBER_OF_THREADS = "kcl.worker.number.of.threads"; + + /** The Constant KCL_WORKER_KEEP_ALIVE_TIME. */ + public static final String KCL_WORKER_KEEP_ALIVE_TIME = "kcl.worker.keep.alive.time"; + + /** The Constant KCL_STREAM_POSITION. */ + public static final String KCL_STREAM_POSITION = "kcl.stream.position"; + + /** The Constant KCL_BACKOFF_TIME_MILLIS. */ + public static final String KCL_BACKOFF_TIME_MILLIS = "kcl.backoff.time.in.millis"; + + /** The Constant KCL_NUM_RETRIES. */ + public static final String KCL_NUM_RETRIES = "kcl.num.retries"; + + /** The Constant PRE_PROCESSORS. */ + public static final String PRE_PROCESSORS = "pre.processors"; + + /** The Constant SERVICE_STREAM_PROCESSORS. */ + public static final String SERVICE_STREAM_PROCESSORS = "service.stream.processors"; + + /** The Constant SERVICE_NAME. */ + public static final String SERVICE_NAME = "service.name"; + + /** The Constant POST_PROCESSORS. */ + public static final String POST_PROCESSORS = "post.processors"; + + /** The Constant APPLICATION_PROPERTIES. */ + public static final String APPLICATION_PROPERTIES = "/application.properties"; + + /** The Constant SEQUENCE_BLOCK_MAXVALUE. */ + public static final String SEQUENCE_BLOCK_MAXVALUE = "sequence.block.config.maxvalue"; + + /** The Constant IGNITE_PLATFORM_SERVICE_IMPL_CLASS_NAME. */ + public static final String IGNITE_PLATFORM_SERVICE_IMPL_CLASS_NAME = "ignite.platform.service.impl.class.name"; + + /** The Constant VEHICLE_PROFILE_VIN_URL. */ + public static final String VEHICLE_PROFILE_VIN_URL = "http.vp.vin.url"; + + /** The Constant VEHICLE_PROFILE_PLATFORM_IDS. */ + public static final String VEHICLE_PROFILE_PLATFORM_IDS = "http.vp.platform.ids"; + + /** The Constant MQTT_TOPIC_GENERATOR_SERVICE_IMPL_CLASS_NAME. */ + public static final String MQTT_TOPIC_GENERATOR_SERVICE_IMPL_CLASS_NAME = + "mqtt.topic.name.generator.impl.class.name"; + + /** The Constant DEFAULT_TOPIC_NAME_GENERATOR_IMPL. */ + public static final String DEFAULT_TOPIC_NAME_GENERATOR_IMPL = + "org.eclipse.ecsp.analytics.stream.base.utils.DefaultMqttTopicNameGeneratorImpl"; + /* + * MQTT propreties required for DeviceMessagingAgent stream processor + */ + + // MQTT_SHORT_CIRCUIT property used to identify whether we would like to + // directly send the event to MQTT or Redis if true, DevieMessaging Agent + // stream processor will directly push the data to mqtt. + + /** The Constant EVENT_WRAP_FREQUENCY. */ + public static final String EVENT_WRAP_FREQUENCY = "event.wrap.frequency"; + + /** The Constant MQTT_SHORT_CIRCUIT. */ + public static final String MQTT_SHORT_CIRCUIT = "mqtt.short.circuit"; + + /** The Constant MQTT_BROKER_URL. */ + public static final String MQTT_BROKER_URL = "mqtt.broker.url"; + + /** The Constant MQTT_BROKER_PORT. */ + public static final String MQTT_BROKER_PORT = "mqtt.broker.port"; + + /** The Constant MQTT_TOPIC_NAME. */ + public static final String MQTT_TOPIC_NAME = "mqtt.topic.name"; + + /** The Constant MQTT_TOPIC_SEPARATOR. */ + public static final String MQTT_TOPIC_SEPARATOR = "mqtt.topic.separator"; + + /** The Constant MQTT_CONFIG_QOS. */ + public static final String MQTT_CONFIG_QOS = "mqtt.config.qos"; + + /** The Constant MQTT_USER_NAME. */ + public static final String MQTT_USER_NAME = "mqtt.user.name"; + + /** The Constant MQTT_CLIENT_AUTH_MECHANISM. */ + public static final String MQTT_CLIENT_AUTH_MECHANISM = "mqtt.client.auth.mechanism"; + + /** The Constant MQTT_SERVICE_TRUSTSTORE_PATH. */ + public static final String MQTT_SERVICE_TRUSTSTORE_PATH = "mqtt.service.truststore.path"; + + /** The Constant MQTT_SERVICE_TRUSTSTORE_PASSWORD. */ + public static final String MQTT_SERVICE_TRUSTSTORE_PASSWORD = "mqtt.service.truststore.password"; + + /** The Constant MQTT_SERVICE_TRUSTSTORE_TYPE. */ + public static final String MQTT_SERVICE_TRUSTSTORE_TYPE = "mqtt.service.truststore.type"; + + /** The Constant CONNECTIONS_MAX_IDLE_MS. */ + public static final String CONNECTIONS_MAX_IDLE_MS = "connections.max.idle.ms"; + + /** The Constant MQTT_USER_PASSWORD. */ + public static final String MQTT_USER_PASSWORD = "mqtt.user.password"; + + /** The Constant MQTT_MAX_INFLIGHT. */ + public static final String MQTT_MAX_INFLIGHT = "mqtt.max.inflight"; + + /** The Constant MQTT_SERVICE_TOPIC_NAME. */ + public static final String MQTT_SERVICE_TOPIC_NAME = "mqtt.service.topic.name"; + + /** The Constant MQTT_GLOBAL_BROADCAST_RETENTION_TOPICS. */ + public static final String MQTT_GLOBAL_BROADCAST_RETENTION_TOPICS = "mqtt.global.broadcast.retention.topics"; + + /** The Constant MQTT_SERVICE_TOPIC_NAME_PREFIX. */ + public static final String MQTT_SERVICE_TOPIC_NAME_PREFIX = "mqtt.service.topic.name.prefix"; + + /** The Constant MQTT_TOPIC_TO_DEVICE_INFIX. */ + public static final String MQTT_TOPIC_TO_DEVICE_INFIX = "mqtt.topic.to.device.infix"; + + /** The Constant MQTT_CONNECTION_RETRY_COUNT. */ + public static final String MQTT_CONNECTION_RETRY_COUNT = "mqtt.conn.retry.count"; + + /** The Constant MQTT_CONNECTION_RETRY_INTERVAL. */ + public static final String MQTT_CONNECTION_RETRY_INTERVAL = "mqtt.conn.retry.interval"; + + /** The Constant MQTT_TIMEOUT_IN_MILLIS. */ + public static final String MQTT_TIMEOUT_IN_MILLIS = "mqtt.timeout.in.millis"; + + /** The Constant MQTT_KEEP_ALIVE_INTERVAL. */ + public static final String MQTT_KEEP_ALIVE_INTERVAL = "mqtt.keep.alive.in.seconds"; + + /** The Constant MQTT_CLIENT. */ + public static final String MQTT_CLIENT = "mqtt.client"; + + /** The Constant MQTT_CLEAN_SESSION. */ + public static final String MQTT_CLEAN_SESSION = "mqtt.clean.session"; + + /** The Constant WRAP_DISPATCH_EVENT. */ + public static final String WRAP_DISPATCH_EVENT = "wrap.dispatch.event"; + + /** The Constant SHORT_HASHCODE_INDEX. */ + public static final String SHORT_HASHCODE_INDEX = "short_hashcode_index"; + + /** The Constant HEALTH_MQTT_MONITOR_ENABLED. */ + public static final String HEALTH_MQTT_MONITOR_ENABLED = "health.mqtt.monitor.enabled"; + + /** The Constant HEALTH_MQTT_MONITOR_RESTART_ON_FAILURE. */ + public static final String HEALTH_MQTT_MONITOR_RESTART_ON_FAILURE = "health.mqtt.monitor.restart.on.failure"; + + /** The Constant MQTT_BROKER_PLATFORMID_MAPPING. */ + public static final String MQTT_BROKER_PLATFORMID_MAPPING = "mqtt.broker.platformId.mapping"; + + /** The Constant DEFAULT_PLATFORMID. */ + public static final String DEFAULT_PLATFORMID = "defaultPlatformId"; + + /** The default property values. */ + private static Map defaultPropertyValues = new HashMap<>(); + + static { + defaultPropertyValues.put(MONGODB_URL, DEFAULT_MONGODB_URL); + defaultPropertyValues.put(MONGODB_PORT, DEFAULT_MONGODB_PORT); + defaultPropertyValues.put(MONGODB_AUTH_USERNAME, DEFAULT_MONGODB_AUTH_USERNAME); + defaultPropertyValues.put(MONGODB_AUTH_PSWD, DEFAULT_MONGODB_AUTH_PSWD); + defaultPropertyValues.put(MONGODB_AUTH_DB, DEFAULT_MONGODB_AUTH_DB); + defaultPropertyValues.put(MONGODB_DBNAME, DEFAULT_MONGODB_DBNAME); + defaultPropertyValues.put(MONGO_CLIENT_MAX_WAIT_TIME_MS, DEFAULT_MAX_WAIT_TIME_MS); + defaultPropertyValues.put(MONGO_CLIENT_CONNECTION_TIMEOUT_MS, DEFAULT_CONNECT_TIMEOUT_MS); + defaultPropertyValues.put(MONGO_CLIENT_SOCKET_TIMEOUT_MS, DEFAULT_SOCKET_TIMEOUT_MS); + defaultPropertyValues.put(MONGO_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTION); + + } + + /** + * Helper method to retrieve the default values of the property. + * + * @param propertyName propertyName + * @return String + */ + public static String getDefaultPropertyValue(String propertyName) { + if (defaultPropertyValues.containsKey(propertyName)) { + return defaultPropertyValues.get(propertyName); + } + throw new PropertyNotFoundException("Property " + propertyName + " doesn't exist in the default list."); + } + + /** The Constant DMA_KAFKA_CONSUMER_POLL. */ + public static final String DMA_KAFKA_CONSUMER_POLL = "dma.kafka.consumer.poll"; + + /** The Constant BACKDOOR_KAFKA_CONSUMER_DEFAULT_API_TIMEOUT_MS. */ + //Below property specifies the timeout for committing the offsets. + public static final String BACKDOOR_KAFKA_CONSUMER_DEFAULT_API_TIMEOUT_MS = + "backdoor.kafka.consumer.default.api.timeout.ms"; + + /** The Constant DMA_AUTO_OFFSET_RESET_CONFIG. */ + public static final String DMA_AUTO_OFFSET_RESET_CONFIG = + "dma.auto.offset.reset"; + + /** The Constant DMA_EVENT_HEADER_UPDATION_TYPE. */ + public static final String DMA_EVENT_HEADER_UPDATION_TYPE = + "dma.event.header.updation.type"; + + /** The Constant DMA_SERVICE_MAX_RETRY. */ + public static final String DMA_SERVICE_MAX_RETRY = + "dma.service.max.retry"; + + /** The Constant DMA_SERVICE_RETRY_INTERVAL_MILLIS. */ + public static final String DMA_SERVICE_RETRY_INTERVAL_MILLIS = + "dma.service.retry.interval.millis"; + + /** The Constant DMA_SERVICE_RETRY_MIN_THRESHOLD_MILLIS. */ + public static final String DMA_SERVICE_RETRY_MIN_THRESHOLD_MILLIS = + "dma.service.retry.min.threshold.millis"; + + /** The Constant DMA_SERVICE_RETRY_INTERVAL_DIVISOR. */ + public static final String DMA_SERVICE_RETRY_INTERVAL_DIVISOR = + "dma.service.retry.interval.divisor"; + + /** The Constant DMA_SHOULDER_TAP_INVOKER_IMPL_CLASS. */ + public static final String DMA_SHOULDER_TAP_INVOKER_IMPL_CLASS = + "dma.shoulder.tap.invoker.impl.class"; + + /** The Constant DMA_SHOULDER_TAP_INVOKER_WAM_SEND_SMS_URL. */ + public static final String DMA_SHOULDER_TAP_INVOKER_WAM_SEND_SMS_URL = + "dma.shoulder.tap.invoker.wam.send.sms.url"; + + /** The Constant DMA_SHOULDER_TAP_INVOKER_WAM_SMS_TRANSACTION_STATUS_URL. */ + public static final String DMA_SHOULDER_TAP_INVOKER_WAM_SMS_TRANSACTION_STATUS_URL = + "dma.shoulder.tap.invoker.wam.sms.transaction.status.url"; + + /** The Constant DMA_SHOULDER_TAP_WAM_SMS_PRIORITY. */ + public static final String DMA_SHOULDER_TAP_WAM_SMS_PRIORITY = "dma.shoulder.tap.wam.sms.priority"; + + /** The Constant DMA_SHOULDER_TAP_WAM_SMS_VALIDITY_HOURS. */ + public static final String DMA_SHOULDER_TAP_WAM_SMS_VALIDITY_HOURS = + "dma.shoulder.tap.wam.sms.validity.hours"; + + /** The Constant DMA_SHOULDER_TAP_WAM_SEND_SMS_SKIP_STATUS_CHECK. */ + public static final String DMA_SHOULDER_TAP_WAM_SEND_SMS_SKIP_STATUS_CHECK = + "dma.shoulder.tap.wam.send.sms.skip.status.check"; + + /** The Constant DMA_SHOULDER_TAP_WAM_API_MAX_RETRY_COUNT. */ + public static final String DMA_SHOULDER_TAP_WAM_API_MAX_RETRY_COUNT = + "dma.shoulder.tap.wam.api.max.retry.count"; + + /** The Constant DMA_SHOULDER_TAP_WAM_API_MAX_RETRY_INTERVAL_MS. */ + public static final String DMA_SHOULDER_TAP_WAM_API_MAX_RETRY_INTERVAL_MS = + "dma.shoulder.tap.wam.api.max.retry.interval.ms"; + + /** The Constant DMA_TTL_EXPIRY_NOTIFICATION_ENABLED. */ + public static final String DMA_TTL_EXPIRY_NOTIFICATION_ENABLED = "dma.ttl.expiry.notification.enabled"; + + /** The Constant DMA_REMOVE_ON_TTL_EXPIRY_ENABLED. */ + public static final String DMA_REMOVE_ON_TTL_EXPIRY_ENABLED = "dma.remove.on.ttl.expiry.enabled"; + + /** The Constant DMA_EVENT_CONFIG_PROVIDER_CLASS. */ + public static final String DMA_EVENT_CONFIG_PROVIDER_CLASS = "dma.event.config.provider.class"; + + /** The Constant DMA_POST_DISPATCH_HANDLER_CLASS. */ + public static final String DMA_POST_DISPATCH_HANDLER_CLASS = "dma.post.dispatch.handler.class"; + + /** + * Properties required to make use of the following DMA capabilities: + * 1. Dispatch to kafka broker. + * 2. Retrieve connection status of devices from a third party API + */ + public static final String DMA_DISPATCHER_ECU_TYPES = "dma.dispatcher.ecu.types"; + + /** The Constant DMA_CONNECTION_STATUS_RETRIEVER_API_URL. */ + public static final String DMA_CONNECTION_STATUS_RETRIEVER_API_URL = + "dma.connection.status.retriever.api.url"; + + /** The Constant DMA_CONNECTION_STATUS_API_MAX_RETRY_COUNT. */ + public static final String DMA_CONNECTION_STATUS_API_MAX_RETRY_COUNT = + "dma.connection.status.api.max.retry.count"; + + /** The Constant DMA_CONNECTION_STATUS_API_RETRY_INTERVAL_MS. */ + public static final String DMA_CONNECTION_STATUS_API_RETRY_INTERVAL_MS = + "dma.connection.status.api.retry.interval.ms"; + + /** The Constant DMA_CONNECTION_STATUS_PARSER_IMPL. */ + public static final String DMA_CONNECTION_STATUS_PARSER_IMPL = "dma.connection.status.parser.impl"; + + /** The Constant DMA_CONNECTION_MSG_VALUE_TRANSFORMER. */ + public static final String DMA_CONNECTION_MSG_VALUE_TRANSFORMER = + "dma.connection.msg.value.transformer"; + + /** The Constant DMA_CONNECTION_MSG_KEY_TRANSFORMER. */ + public static final String DMA_CONNECTION_MSG_KEY_TRANSFORMER = "dma.connection.msg.key.transformer"; + + /** The Constant OFFLINE_BUFFER_PER_DEVICE. */ + public static final String OFFLINE_BUFFER_PER_DEVICE = "offline.buffer.per.device"; + + /** The Constant SHOULDER_TAP_MAX_RETRY. */ + public static final String SHOULDER_TAP_MAX_RETRY = "shoulder.tap.max.retry"; + + /** The Constant SHOULDER_TAP_RETRY_INTERVAL_MILLIS. */ + public static final String SHOULDER_TAP_RETRY_INTERVAL_MILLIS = "shoulder.tap.retry.interval.millis"; + + /** The Constant SHOULDER_TAP_RETRY_MIN_THRESHOLD_MILLIS. */ + public static final String SHOULDER_TAP_RETRY_MIN_THRESHOLD_MILLIS = "shoulder.tap.retry.min.threshold.millis"; + + /** The Constant SHOULDER_TAP_RETRY_INTERVAL_DIVISOR. */ + public static final String SHOULDER_TAP_RETRY_INTERVAL_DIVISOR = "shoulder.tap.retry.interval.divisor"; + + /** The Constant FILTER_DM_OFFLINE_BUFFER_ENTRIES_IMPL. */ + public static final String FILTER_DM_OFFLINE_BUFFER_ENTRIES_IMPL = "filter.dmoffline.buffer.entry.impl"; + + /** The Constant DMA_NUM_CACHE_BYPASS_THREADS. */ + public static final String DMA_NUM_CACHE_BYPASS_THREADS = "dma.num.cache.bypass.threads"; + + /** The Constant CACHE_BYPASS_THREADS_SHUTDOWN_WAIT_TIME. */ + public static final String CACHE_BYPASS_THREADS_SHUTDOWN_WAIT_TIME = "cache.bypass.threads.shutdown.wait.time"; + + /** The Constant CACHE_BYPASS_QUEUE_INITIAL_CAPACITY. */ + public static final String CACHE_BYPASS_QUEUE_INITIAL_CAPACITY = "cache.bypass.queue.initial.capacity"; + + /** The Constant DMA_CONNECTION_STATUS_RETRIEVER_IMPL. */ + public static final String DMA_CONNECTION_STATUS_RETRIEVER_IMPL = "dma.connection.status.retriever.impl"; + + /** The Constant DEFAULT_CONNECTION_STATUS_RETRIEVER_IMPL. */ + public static final String DEFAULT_CONNECTION_STATUS_RETRIEVER_IMPL = + "org.eclipse.ecsp.analytics.stream.base.utils.DefaultDeviceConnectionStatusRetriever"; + + /** The Constant KAFKA_STREAMS_MAX_FAILURES. */ + //RTC 334625 Configuration for maxFailures and maxTimeInterval to be used to recover the thread + public static final String KAFKA_STREAMS_MAX_FAILURES = "kafka.streams.max.failures"; + + /** The Constant KAFKA_STREAMS_MAX_TIME_INTERVAL. */ + public static final String KAFKA_STREAMS_MAX_TIME_INTERVAL = "kafka.streams.max.time.millis"; + + /** The Constant HTTP_CONNECTION_TIMEOUT_IN_SEC. */ + // Http client properties + public static final String HTTP_CONNECTION_TIMEOUT_IN_SEC = "http.connection.timeout.in.sec"; + + /** The Constant HTTP_READ_TIMEOUT_IN_SEC. */ + public static final String HTTP_READ_TIMEOUT_IN_SEC = "http.read.timeout.in.sec"; + + /** The Constant HTTP_WRITE_TIMEOUT_IN_SEC. */ + public static final String HTTP_WRITE_TIMEOUT_IN_SEC = "http.write.timeout.in.sec"; + + /** The Constant HTTP_KEEP_ALIVE_DURATION_IN_SEC. */ + public static final String HTTP_KEEP_ALIVE_DURATION_IN_SEC = "http.keep.alive.duration.in.sec"; + + /** The Constant HTTP_MAX_IDLE_CONNECTIONS. */ + public static final String HTTP_MAX_IDLE_CONNECTIONS = "http.max.idle.connections"; + + /** The Constant HTTP_VP_SERVICE_AUTH_HEADER. */ + public static final String HTTP_VP_SERVICE_AUTH_HEADER = "http.vp.auth.header"; + + /** The Constant HTTP_VP_SERVICE_USER. */ + public static final String HTTP_VP_SERVICE_USER = "http.vp.service.user"; + + /** The Constant HTTP_VP_SERVICE_PASSWORD. */ + public static final String HTTP_VP_SERVICE_PASSWORD = "http.vp.service.password"; + + /** The Constant D2V_MAPPER_IMPL. */ + // Device to Vehicle profile implementation class + public static final String D2V_MAPPER_IMPL = "device.to.vehicle.mapper.impl"; + + /** The Constant VEHICLE_PROFILE_URL. */ + // Vehicle profile service URL + public static final String VEHICLE_PROFILE_URL = "http.vp.url"; + + /** The Constant VP_RES_ECUS_NODE. */ + public static final String VP_RES_ECUS_NODE = "ecus"; + + /** The Constant VP_RES_SERVICES_NODE. */ + public static final String VP_RES_SERVICES_NODE = "services"; + + /** The Constant VP_RES_CLIENT_ID_FIELD. */ + public static final String VP_RES_CLIENT_ID_FIELD = "clientId"; + + /** The Constant VP_RETRY_INTERVAL_IN_MILLIS. */ + public static final String VP_RETRY_INTERVAL_IN_MILLIS = "http.vp.retry.interval.in.millis"; + + /** The Constant VP_RETRY_MAX_COUNT. */ + public static final String VP_RETRY_MAX_COUNT = "http.vp.max.retry.count"; + + /** The Constant SCHEDULER_AGENT_TOPIC_NAME. */ + public static final String SCHEDULER_AGENT_TOPIC_NAME = "scheduler.agent.topic.name"; + + /** The Constant START_DEVICE_STATUS_CONSUMER. */ + public static final String START_DEVICE_STATUS_CONSUMER = "start.device.status.consumer"; + + /** The Constant FETCH_CONNECTION_STATUS_TOPIC_NAME. */ + public static final String FETCH_CONNECTION_STATUS_TOPIC_NAME = "fetch.connection.status.topic.name"; + + /** The Constant CONVERT_BACKDOOR_KAFKA_TOPIC_TO_LOWERCASE. */ + public static final String CONVERT_BACKDOOR_KAFKA_TOPIC_TO_LOWERCASE = "convert.backdoor.kafka.topic.tolowercase"; + + /** The Constant ENABLE_PROMETHEUS. */ + public static final String ENABLE_PROMETHEUS = "metrics.prometheus.enabled"; + + /** The Constant NODE_NAME. */ + public static final String NODE_NAME = "NODE_NAME"; + + /** The Constant PROMETHEUS_AGENT_PORT_KEY. */ + public static final String PROMETHEUS_AGENT_PORT_KEY = "prometheus.agent.port"; + + /** The Constant BACKDOOR_KAFKA_MAX_POLL_INTERVAL_MS. */ + public static final String BACKDOOR_KAFKA_MAX_POLL_INTERVAL_MS = "backdoor.kafka.max.poll.interval.ms"; + + /** The Constant BACKDOOR_KAFKA_REQUEST_TIMEOUT_MS. */ + public static final String BACKDOOR_KAFKA_REQUEST_TIMEOUT_MS = "backdoor.kafka.request.timeout.ms"; + + /** The Constant BACKDOOR_KAFKA_SESSION_TIMEOUT_MS. */ + public static final String BACKDOOR_KAFKA_SESSION_TIMEOUT_MS = "backdoor.kafka.session.timeout.ms"; + + /** The Constant BACKDOOR_KAFKA_MAX_RESTART_ATTEMPTS. */ + public static final String BACKDOOR_KAFKA_MAX_RESTART_ATTEMPTS = + "backdoor.kafka.max.restart.attempts"; + + /** The Constant BACKDOOR_KAFKA_ATTEMPTS_RESET_INTERVAL_MIN. */ + public static final String BACKDOOR_KAFKA_ATTEMPTS_RESET_INTERVAL_MIN = + "backdoor.kafka.restart.reset.interval.min"; + + /** The Constant BACKDOOR_KAFKA_ENABLE_AUTO_COMMIT. */ + public static final String BACKDOOR_KAFKA_ENABLE_AUTO_COMMIT = + "backdoor.kafka.enable.auto.commit"; + + /** The Constant BACKDOOR_KAFKA_OFFSET_PERSISTENCE_DELAY. */ + public static final String BACKDOOR_KAFKA_OFFSET_PERSISTENCE_DELAY = + "backdoor.kafka.offset.persistence.delay"; + + /** The Constant KAFKA_STREAMS_OFFSET_PERSISTENCE_DELAY. */ + public static final String KAFKA_STREAMS_OFFSET_PERSISTENCE_DELAY = + "kafka.streams.offset.persistence.delay"; + + /** The Constant KAFKA_STREAMS_OFFSET_PERSISTENCE_INIT_DELAY. */ + public static final String KAFKA_STREAMS_OFFSET_PERSISTENCE_INIT_DELAY = + "kafka.streams.offset.persistence.init.delay"; + + /** The Constant KAFKA_STREAMS_OFFSET_PERSISTENCE_ENABLED. */ + public static final String KAFKA_STREAMS_OFFSET_PERSISTENCE_ENABLED = + "kafka.streams.offset.persistence.enabled"; + + /** The Constant TRANSFORMER_INJECT_PROPERTY_ENABLE. */ + public static final String TRANSFORMER_INJECT_PROPERTY_ENABLE = + "transformer.inject.property.enable"; + + /** + * Below flags are used at the time DLQ re-processing. + */ + public static final String DLQ_MAX_RETRY_COUNT = "dlq.max.retry.count"; + + /** The Constant DLQ_REPROCESSING_ENABLED. */ + public static final String DLQ_REPROCESSING_ENABLED = "dlq.reprocessing.enabled"; + + /** The Constant HEALTH_DEVICE_STATUS_BACKDOOR_MONITOR_ENABLED. */ + public static final String HEALTH_DEVICE_STATUS_BACKDOOR_MONITOR_ENABLED = + "health.device.status.backdoor.monitor.enabled"; + + /** The Constant HEALTH_DEVICE_STATUS_BACKDOOR_MONITOR_RESTART_ON_FAILURE. */ + public static final String HEALTH_DEVICE_STATUS_BACKDOOR_MONITOR_RESTART_ON_FAILURE = + "health.device.status.backdoor.monitor.restart.on.failure"; + /** + * Below flags are used Kafka topic validator healthcheck. + */ + public static final String KAFKA_TOPICS_NEEDS_RESTART_ON_FAILURE = + "health.kafka.topics.monitor.needs.restart.on.failure"; + + /** The Constant ENABLE_HEALTHCHECK. */ + public static final String ENABLE_HEALTHCHECK = "health.kafka.topics.monitor.enabled"; + + /** The Constant KAFKA_TOPICS_FILE_PATH. */ + public static final String KAFKA_TOPICS_FILE_PATH = "kafka.topics.file.path"; + + /** The Constant EXPECTED_MIN_ISR. */ + public static final String EXPECTED_MIN_ISR = "expected.min.isr"; + + /** + * Below flag is used in case of message filter to identify the message + * duplicates. + */ + + public static final String MSG_FILTER_ENABLED = "message.filter.enabled"; + + /** The Constant MSG_FILTER_TTL_MS. */ + public static final String MSG_FILTER_TTL_MS = "message.filter.ttl.ms"; + + /** + * Below flag is used to enable and disable DMA/SCHEDULER Component in StreamBase. + */ + public static final String DMA_ENABLED = "dma.enabled"; + + /** The Constant SCHEDULER_ENABLED. */ + public static final String SCHEDULER_ENABLED = "scheduler.enabled"; + + /** + * Below flag is used to enabling streaming of event size to kafka for analytics dashboard RTC-301848. + */ + public static final String KAFKA_DATA_CONSUMPTION_METRICS = "kafka.data.consumption.metrics"; + + /** The Constant KAFKA_DATA_CONSUMPTION_METRICS_KAFKA_TOPIC. */ + public static final String KAFKA_DATA_CONSUMPTION_METRICS_KAFKA_TOPIC = "data.consumption.metrics.kafka.topic"; + + /** + * CR-1758 property which will hold events that will not be saved to offline buffer in DMA. + */ + public static final String DMA_EVENTS_SKIP_ONLINE_BUFFER = "dma.events.skip.offline.buffer"; + + /** The Constant SUB_SERVICES. */ + /* + * RTC 355420, if a service has multiple mqtt topics or multiple sub-service under itself, then + * it must let DMA know about all those through below property by assigning comma separated values + * of names of sub-services. + */ + public static final String SUB_SERVICES = "sub.services"; + + /** The Constant KAFKA_HEADERS_ENABLED. */ + public static final String KAFKA_HEADERS_ENABLED = "kafka.headers.enabled"; + + /** + * RDNG 171775 & RTC 503148 Expose RocksDB metrics to Prometheus. + */ + public static final String ROCKSDB_METRICS_ENABLED = "rocksdb.metrics.enabled"; + + /** The Constant ROCKSDB_METRICS_LIST. */ + public static final String ROCKSDB_METRICS_LIST = "rocksdb.metrics.list"; + + /** The Constant ROCKSDB_METRICS_THREAD_INITIAL_DELAY_MS. */ + public static final String ROCKSDB_METRICS_THREAD_INITIAL_DELAY_MS = "rocksdb.metrics.thread.initial.delay.ms"; + + /** The Constant ROCKSDB_METRICS_THREAD_FREQUENCY_MS. */ + public static final String ROCKSDB_METRICS_THREAD_FREQUENCY_MS = "rocksdb.metrics.thread.frequency.ms"; + + /** + * RDNG 171859 & RTC 525171 Report internal cache metrics to Prometheus . + */ + public static final String INTERNAL_METRICS_ENABLED = "internal.metrics.enabled"; + + /** + * RDNG 171813. + */ + public static final String KAFKA_TOPIC_NAME_PLATFORM_PREFIXES = "kafka.topic.name.platform.prefixes"; + + /** The Constant MQTT_BROKER_URL_SUFFIX. */ + public static final String MQTT_BROKER_URL_SUFFIX = ".broker.url"; + + /** The Constant MQTT_USER_NAME_SUFFIX. */ + public static final String MQTT_USER_NAME_SUFFIX = ".user.name"; + + /** The Constant MQTT_USER_PASSWORD_SUFFIX. */ + public static final String MQTT_USER_PASSWORD_SUFFIX = ".user.password"; + + /** The Constant MQTT_BROKER_PORT_SUFFIX. */ + public static final String MQTT_BROKER_PORT_SUFFIX = ".broker.port"; + + /** The Constant MQTT_CONFIG_QOS_SUFFIX. */ + public static final String MQTT_CONFIG_QOS_SUFFIX = ".config.qos"; + + /** The Constant MQTT_MAX_INFLIGHT_SUFFIX. */ + public static final String MQTT_MAX_INFLIGHT_SUFFIX = ".max.inflight"; + + /** The Constant MQTT_TIMEOUT_IN_MILLIS_SUFFIX. */ + public static final String MQTT_TIMEOUT_IN_MILLIS_SUFFIX = ".timeout.in.millis"; + + /** The Constant MQTT_KEEP_ALIVE_INTERVAL_SUFFIX. */ + public static final String MQTT_KEEP_ALIVE_INTERVAL_SUFFIX = ".keep.alive.in.seconds"; + + /** The Constant MQTT_CLEAN_SESSION_SUFFIX. */ + public static final String MQTT_CLEAN_SESSION_SUFFIX = ".clean.session"; + + /** The Constant MQTT_CLIENT_AUTH_MECHANISM_SUFFIX. */ + public static final String MQTT_CLIENT_AUTH_MECHANISM_SUFFIX = ".client.auth.mechanism"; + + /** The Constant MQTT_SERVICE_TRUSTSTORE_PATH_SUFFIX. */ + public static final String MQTT_SERVICE_TRUSTSTORE_PATH_SUFFIX = ".service.truststore.path"; + + /** The Constant MQTT_SERVICE_TRUSTSTORE_PASSWORD_SUFFIX. */ + public static final String MQTT_SERVICE_TRUSTSTORE_PASSWORD_SUFFIX = ".service.truststore.password"; + + /** The Constant MQTT_SERVICE_TRUSTSTORE_TYPE_SUFFIX. */ + public static final String MQTT_SERVICE_TRUSTSTORE_TYPE_SUFFIX = ".service.truststore.type"; + + /** The Constant MAX_DECOMPRESS_INPUT_STREAM_SIZE_IN_BYTES. */ + public static final String MAX_DECOMPRESS_INPUT_STREAM_SIZE_IN_BYTES = "max.decompress.input.stream.size.in.bytes"; +} + diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/SequenceBuffer.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/SequenceBuffer.java new file mode 100644 index 0000000..2795f26 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/SequenceBuffer.java @@ -0,0 +1,124 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.eclipse.ecsp.analytics.stream.base.utils.Pair; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; + +import java.util.List; +import java.util.SortedMap; + +/** + * This interface defines the contract for data structure that implements message ordering. + */ +public interface SequenceBuffer { + + /** + * Initialize sequence buffer by supplied data object. + * + * @param data data + */ + void init(SequenceBuffer data); + + /** + * remove list sequence keys from in-memory sequence buffer. + * + * @param sequenceKey sequenceKey + */ + void removeSequence(Long sequenceKey); + + /** + * remove the data from sequence buffer. + * + * @param sequenceKey + * sequence buffer key + * @param dataKey + * data key which will be remove from the sequence buffer + */ + void remove(Long sequenceKey, String dataKey); + + /** + * returns the all eligible sequence which are ready to flush. + * + * @param flushTime flushTime + * @return SortedMap + */ + SortedMap> head(long flushTime); + + + /** + * It returns the head KeyValuePair's key id. + * + * @return String + */ + + String head(); + + /** + * add Pair (IgniteKey and IgniteValue) in state store. + * + * @param pairId pairId + * @param pair pair + */ + void add(String pairId, Pair, IgniteEvent> pair); + + /** + * It returns the complete buffered sequence which will be used for storing it to state store. + * + * @return SortedMap + */ + SortedMap> getAll(); + + /** + * Get last flush timestamp. + * + * @return long + */ + long getLastFlushTimestamp(); + + /** + * Set last flush timestamp. It is required when. + * + * @param lastFlushTimestamp lastFlushTimestamp + */ + void setLastFlushTimestamp(long lastFlushTimestamp); + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/SequenceBufferTreeMapImpl.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/SequenceBufferTreeMapImpl.java new file mode 100644 index 0000000..7fdfab1 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/SequenceBufferTreeMapImpl.java @@ -0,0 +1,195 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.eclipse.ecsp.analytics.stream.base.utils.Pair; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map.Entry; +import java.util.SortedMap; +import java.util.TreeMap; + +/** + * It is TreeMap based message sequencing. + */ +public class SequenceBufferTreeMapImpl implements SequenceBuffer { + + /** The logger. */ + private static Logger logger = LoggerFactory.getLogger(SequenceBufferTreeMapImpl.class); + + /** The data. */ + private TreeMap> data = new TreeMap<>(); + + /** The last flush timestamp. */ + private long lastFlushTimestamp; + + /** + * Initializes the SequenceBuffer. + * + * @param sequenceBuffer the sequence buffer + */ + @Override + public void init(SequenceBuffer sequenceBuffer) { + if (sequenceBuffer != null) { + // At the time of initialization, It picks the data + // from State-Store and stores into In-memory Cached. + this.data.putAll(sequenceBuffer.getAll()); + this.lastFlushTimestamp = sequenceBuffer.getLastFlushTimestamp(); + } + } + + + /** + * Returns {@link TreeMap#headMap(Object)}. + * + * @param flushTime the flush time + * @return the sorted map + */ + @Override + public SortedMap> head(long flushTime) { + SortedMap> headmap = new TreeMap<>(); + SortedMap> existingData = data.headMap(flushTime, true); + for (Entry> entry : existingData.entrySet()) { + List dataKeys = new ArrayList<>(); + for (String dataKey : entry.getValue()) { + dataKeys.add(dataKey); + } + headmap.put(entry.getKey(), dataKeys); + } + return headmap; + } + + /** + * Head. + * + * @return the string + */ + @Override + public String head() { + String headEventKeyInStore = null; + Entry> lastEntry = data.ceilingEntry(System.currentTimeMillis()); + if (lastEntry != null && !lastEntry.getValue().isEmpty()) { + // Get the IgniteEvent from the StateStore + headEventKeyInStore = lastEntry.getValue().get(0); + } + return headEventKeyInStore; + } + + /** + * Gets the all. + * + * @return the all + */ + @Override + public SortedMap getAll() { + return data; + } + + /** + * Gets the last flush timestamp. + * + * @return the last flush timestamp + */ + @Override + public long getLastFlushTimestamp() { + return lastFlushTimestamp; + } + + /** + * Sets the last flush timestamp. + * + * @param lastFlushTimestamp the new last flush timestamp + */ + @Override + public void setLastFlushTimestamp(long lastFlushTimestamp) { + this.lastFlushTimestamp = lastFlushTimestamp; + } + + /** + * Adds the. + * + * @param pairId the pair id + * @param pair the pair + */ + @Override + public void add(String pairId, Pair, IgniteEvent> pair) { + logger.trace("Store entry with key {} in state store", pairId); + + // Add to state store + long bucketKey = pair.getB().getTimestamp(); + data.computeIfAbsent(bucketKey, k -> new ArrayList<>()); + List eventsOccurAtTime = data.get(bucketKey); + eventsOccurAtTime.add(pairId); + logger.trace("Added key {} to sequence buffer entry {} with size {}", pairId, bucketKey, + eventsOccurAtTime.size()); + } + + /** + * Removes the sequence. + * + * @param sequenceKey the sequence key + */ + @Override + public void removeSequence(Long sequenceKey) { + logger.trace("Removing key from sequence buffer: {}", sequenceKey); + // Remove the all time based keys from Cache + data.remove(sequenceKey); + } + + /** + * Removes the. + * + * @param sequenceKey the sequence key + * @param dataKey the data key + */ + @Override + public void remove(Long sequenceKey, String dataKey) { + List allDatakeys = data.get(sequenceKey); + if (allDatakeys != null) { + logger.trace("Removing data key {} from sequence key: {}", dataKey, sequenceKey); + allDatakeys.remove(dataKey); + } + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/SimplePropertiesLoader.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/SimplePropertiesLoader.java new file mode 100644 index 0000000..0c2a849 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/SimplePropertiesLoader.java @@ -0,0 +1,98 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +/** + * Loads properties from the given source. + * If the given source exists in both classpath and filesystem then first + * classpath is loaded followed by what is in the filesystem. + * This is followed by all system properties followed by all system env. + * Subsequent sources override the earlier sources. So System Env + * overrides the earlier sources. + * + */ +public class SimplePropertiesLoader { + /** + * Loads properties from the given source. + * If the given source exists in both classpath and filesystem then first + * classpath is loaded followed by what is in the filesystem. + * This is followed by all system properties followed by all system env. + * Subsequent sources override the earlier sources. So System Env + * overrides the earlier sources. + * + * @param source - an entry in the classpath and/or filesystem + * @return Properties + * @throws IOException IOException + */ + public Properties loadProperties(String source) throws IOException { + Properties props = new Properties(); + if (null != source) { + InputStream is = getClass().getResourceAsStream(source); + if (is != null) { + props.load(is); + is.close(); + } + File f = new File(source); + if (f.exists() && f.isFile()) { + try (InputStream fis = new BufferedInputStream(new FileInputStream(f))) { + Properties nprops = new Properties(); + nprops.load(fis); + nprops.forEach((k, v) -> props.setProperty((String) k, (String) v)); + } + } + if (props.isEmpty()) { + throw new IllegalArgumentException( + "Invalid properties source specified. " + + "The file could not be found neither in the classpath nor the filesystem."); + } + } + System.getProperties().forEach((k, v) -> props.setProperty((String) k, (String) v)); + System.getenv().forEach(props::setProperty); + return props; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamBaseConstant.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamBaseConstant.java new file mode 100644 index 0000000..300bdfd --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamBaseConstant.java @@ -0,0 +1,79 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +/** + * Constants class for StreamBase. + */ +public class StreamBaseConstant { + + /** The Constant ASCENDING. */ + public static final String ASCENDING = "ascending"; + + /** The Constant DESCENDING. */ + public static final String DESCENDING = "descending"; + + /** The Constant DLQ_TOPIC_POSFIX. */ + public static final String DLQ_TOPIC_POSFIX = "-dlq"; + + /** The Constant MSG_SEQ_PREFIX. */ + public static final String MSG_SEQ_PREFIX = "msg-seq-"; + + /** The Constant MSG_SEQ_LAST_PROCESSED_EVENT_TS. */ + public static final String MSG_SEQ_LAST_PROCESSED_EVENT_TS = "MSG_SEQ_LAST_PROCESSED_EVENT_TS_"; + + /** The Constant MSG_SEQ_FLUSH_EVENT_DATA. */ + public static final String MSG_SEQ_FLUSH_EVENT_DATA = "FlushEvent data"; + + /** The Constant MSG_FILTER. */ + public static final String MSG_FILTER = "MESSAGE_FILTER_"; + + /** The Constant UNDERSCORE. */ + public static final String UNDERSCORE = "_"; + + /** + * Private constructor to not allow instantiation of a constants class. + */ + private StreamBaseConstant() { + } + + /** The Constant DMA_ONE_WAY_TLS_AUTH_MECHANISM. */ + public static final String DMA_ONE_WAY_TLS_AUTH_MECHANISM = "one-way-tls"; +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessingContext.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessingContext.java new file mode 100644 index 0000000..832fbcb --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessingContext.java @@ -0,0 +1,143 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import com.codahale.metrics.MetricRegistry; +import org.apache.kafka.streams.processor.PunctuationType; +import org.apache.kafka.streams.processor.Punctuator; +import org.apache.kafka.streams.processor.api.Record; +import org.apache.kafka.streams.state.KeyValueStore; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; + +/** + * Contract for stream based processing. + */ +public interface StreamProcessingContext { + + /** + * The current topic being read from. + * + * @return String + */ + public String streamName(); + + /** + * The partition for the current message being processed. + * + * @return int + */ + public int partition(); + + /** + * The offset for the current message being processed. + * + * @return long + */ + public long offset(); + + /** + * Enforce a checkpoint of the processing. Once this is called messages till the current + * offset will never be available again unless replayed. + */ + public void checkpoint(); + + @SuppressWarnings("rawtypes") + public KeyValueStore getStateStore(String name); + + /** + * Forward to the next processor/sink in the chain. + * + * @param kafkaRecord kafkaRecord + */ + public void forward(Record kafkaRecord); + + /** + * Forward to the named processor/sink in the chain. + * + * @param kafkaRecord kafkaRecord + * @param name name + */ + public void forward(Record kafkaRecord, String name); + + + + /** + * Return the taskID per stream thread. It would be generally topicGroupID_partitionID. + * Useful when like to know the which task is processing which partition + * + * @return String + */ + public String getTaskID(); + + /** + * Return the MetricRegistry. + */ + public MetricRegistry getMetricRegistry(); + + /** + * Forwards to a sink stream directly without going through the rest of the framework. + * + * @param key key + * @param value value + * @param topic topic + */ + public default void forwardDirectly(@SuppressWarnings("rawtypes") IgniteKey key, IgniteEvent value, String topic) { + } + + /** + * Forwards to a sink stream directly without going through the rest of the framework. + * + * @param key key + * @param value value + * @param topic topic + */ + public void forwardDirectly(String key, String value, String topic); + + /** + * schedule(). + * + * @param interval interval + * @param punctuationType punctuationType + * @param punctuator punctuator + */ + public void schedule(long interval, PunctuationType punctuationType, Punctuator punctuator); + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessor.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessor.java new file mode 100644 index 0000000..e20b07a --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessor.java @@ -0,0 +1,160 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.dao.GenericDAO; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; + +import java.util.Properties; + +/** + * Contract for all event processors. + * + * @author ssasidharan + */ +public interface StreamProcessor { + + /** + * Initialize the processor + * Use spc.schedule() to schedule punctuations. + * + * @param spc spc + */ + void init(StreamProcessingContext spc); + + /** + * Name by which this processor is known to other processors. + * + * @return String + */ + String name(); + + /** + * Process an event. + * + * @param kafkaRecord kafkaRecord + */ + void process(Record kafkaRecord); + + /** + * Perform actions on a schedule that is dictated by arrival of events. + * + * @param timestamp timestamp + */ + void punctuate(long timestamp); + + /** + * Perform actions on a schedule dictated by wall clock time. + * One tick is roughly one second. Note that implementations of this method + * should use the StreamsProcessorContext.forwardDirectly() instead of the forward() methods. + */ + default void punctuateWc(long ticks) { + } + + /** + * Cleanup. Note that outputting data at this stage is not possible + */ + void close(); + + /** + * Optional provision to define source topics this processor can handle. Typical implementations should ignore this. + * + * @return String[] + */ + default String[] sources() { + return new String[0]; + } + + /** + * Perform any restructuring based on new configuration. + * + * @param props props + */ + void configChanged(Properties props); + + @SuppressWarnings("rawtypes") + HarmanPersistentKVStore createStateStore(); + + /** + * Defines sink topics this processor writes to. Typically the final processor in the chain will define this. + * + * @return String[] + */ + default String[] sinks() { + return new String[0]; + } + + /** + * The initial config to use. This is the same as the config used from the command + * line to launch the streams application. Note that + * this method is called before any other methods are called on your + * stream processor and also note that your stream processor is not + * yet registered with the streams framework when this is called. + * + * @param props props + */ + default void initConfig(Properties props) { + } + + /** + * Called only when event arrived in config topic or master data topic. + * Respective implementation needs to update respective changes in + * its state-store. + * Since we already had readAndPopulateSharedData(GenericDAO dao) which is + * reading master/config data from the data source. But this + * method (i.e. updateSharedData) is also required because we want changed/new + * property or master data needs to be read as and when + * needed. + * + */ + default void updateSharedData(Object key, Object value, String streamName) { + } + + /** + * Setter for external data source. + * + * @param dao The DAO for the data source. + */ + default void setExternalSharedDataSource(GenericDAO dao) { + + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessorFilter.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessorFilter.java new file mode 100644 index 0000000..70245c9 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessorFilter.java @@ -0,0 +1,58 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import java.util.Properties; + +/** + * Contract for all processor's property filters. + * + * @author ashekar + */ +public interface StreamProcessorFilter { + + /** + * returns if current stream processor is enabled or not. + * + * @return boolean + */ + boolean includeInProcessorChain(Properties props); + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/TickListener.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/TickListener.java new file mode 100644 index 0000000..f8c9d3f --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/TickListener.java @@ -0,0 +1,49 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +/** + * Contract for listeners interested in ticks. + * + * @author ssasidharan + */ +public interface TickListener { + void tick(long seconds); +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/WallClock.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/WallClock.java new file mode 100644 index 0000000..0a9f49f --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/WallClock.java @@ -0,0 +1,137 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Keeps track of ticks. Each tick represents 1 second. To subscribe to ticks, + * {@link TickListener} implementation must be defined to which ticks count will be given. + */ +public class WallClock { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(WallClock.class); + + /** The WallClock Instance. */ + public static final WallClock INSTANCE = new WallClock(); + + /** The list of all the subscribed listeners. */ + private List listeners = new ArrayList<>(); + + /** {@link ScheduledExecutorService} instance. */ + private ScheduledExecutorService exec = null; + + /** + * Starts a scheduled thread which notifies the listeners about the ticks. + */ + private WallClock() { + exec = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = Executors.defaultThreadFactory().newThread(r); + t.setDaemon(true); + return t; + }); + exec.scheduleWithFixedDelay(new Runnable() { + private long ticks = 0; + + @Override + public void run() { + notifyListeners(++ticks); + } + }, Constants.INT_45, 1, TimeUnit.SECONDS); + } + + /** + * Gets the single instance of WallClock. + * + * @return single instance of WallClock + */ + public static final WallClock getInstance() { + return INSTANCE; + } + + /** + * Subscribe to WallClock. + * + * @param wcl the implementation of {@link TickListener}. + */ + public synchronized void subscribe(TickListener wcl) { + listeners.add(wcl); + logger.info("Added listener: {} to wall clock listeners list", wcl); + } + + /** + * Unsubscribe from WallClock. + * + * @param wcl the instance of the implementation of {@link TickListener} + */ + public synchronized void unsubscribe(TickListener wcl) { + listeners.remove(wcl); + logger.info("Removed listener: {} from wall clock listeners list", wcl); + } + + /** + * Notify listeners of the ticks passed. + * + * @param ticks the ticks + */ + private void notifyListeners(long ticks) { + List immutableList = null; + synchronized (this) { + immutableList = new ArrayList<>(listeners); + } + immutableList.forEach(wcl -> { + try { + wcl.tick(ticks); + } catch (Exception e) { + logger.error("Listener failed in tick()", e); + } + }); + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/context/StreamBaseSpringContext.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/context/StreamBaseSpringContext.java new file mode 100644 index 0000000..366cf95 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/context/StreamBaseSpringContext.java @@ -0,0 +1,84 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.context; + +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +/** + * If we want to access a spring managed class from a non-spring class then that can be achieved by. + * {@link StreamBaseSpringContext#getBean(Class)} + */ +@Component +public class StreamBaseSpringContext implements ApplicationContextAware { + + private static ApplicationContext context; + + /** + * Returns the Spring managed bean instance of the given class type if it exists. Returns null otherwise. + * + * @param beanClass beanClass + */ + public static T getBean(Class beanClass) { + return context.getBean(beanClass); + } + + /** + * Sets the Spring's {@link ApplicationContext}. + * + * @param context the {@link ApplicationContext}. + */ + private static synchronized void setContext(ApplicationContext context) { + StreamBaseSpringContext.context = context; + } + + /** + * Sets the Spring's {@link ApplicationContext}. + * + * @param context the {@link ApplicationContext}. + */ + @Override + public void setApplicationContext(ApplicationContext context) throws BeansException { + // store ApplicationContext reference to access required beans later on + setContext(context); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/CacheBackedInMemoryBatchCompleteCallBack.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/CacheBackedInMemoryBatchCompleteCallBack.java new file mode 100644 index 0000000..66ab73c --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/CacheBackedInMemoryBatchCompleteCallBack.java @@ -0,0 +1,52 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.dao; + +import java.util.List; + +/** + * Interface to implement a callback after completion of processing of a batch of records in in-memory + * cache. + **/ +public interface CacheBackedInMemoryBatchCompleteCallBack { + + public void batchCompleted(List processedRecords); + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/GenericDAO.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/GenericDAO.java new file mode 100644 index 0000000..8fc6aac --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/GenericDAO.java @@ -0,0 +1,94 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.dao; + +import dev.morphia.Datastore; + +import java.util.List; + +/** + * Implemented by data sources. Data sources could be MongoDB/Cassandra/RDBMS/XML or any properties file. + * + */ +public interface GenericDAO { + + /** + * Retrieve only one record which matches the criteria based on keyFieldName/keyFieldValue. + * + * @param collectionName + * : Name of Table/collection + * @param keyFieldName + * : Field name whose key is being use. Ex : _id + * @param keyFieldValue + * : Value of the field (i.e. value of keyFieldName). Ex : _id : + * {@code <}P202{@code >}. P202 is value. + * @param fields + * : Retrieved record will have the values of specified fields. + * + * @return String + */ + String getRecord(String collectionName, String keyFieldName, String keyFieldValue, String... fields); + + /** + * Retrieve all the records from the collection. + * + * @param collectionName + * : Name of Table/collection + * @param fields + * : Retrieved records will have the values of specified fields. + * @return List + */ + List getRecords(String collectionName, String... fields); + + /** + * getAllRecords(). + * + * @param collectionName collectionName + * @return List + */ + List getAllRecords(String collectionName); + + /** + * Retrieve the data store. From CFMS. + * + * @return Datastore + */ + Datastore getDataStore(); +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/SinkNode.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/SinkNode.java new file mode 100644 index 0000000..1808efc --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/SinkNode.java @@ -0,0 +1,101 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.dao; + +import java.util.Optional; +import java.util.Properties; + +/** + * Contract for representing a sink node. + */ +public interface SinkNode { + + public void init(Properties prop); + + /** + * If required implementors should override it according Returns + * value for a given field for a given mongo collection / DDB tablename. + * + * @param primaryKeyValue primaryKeyValue + * @param fieldName fieldName + * @param tableName tableName + * @return String + */ + public default Optional get(K primaryKeyValue, String fieldName, String tableName) { + return Optional.empty(); + } + + /** + * If required implementors should override it according Delete single + * record with the primary key from the mongo collection / DDB table + * name. + * + * @param id id + * @param tableName tableName + */ + public default void deleteSingleRecord(K id, String tableName) { + + } + + /** + * Flush the records. + */ + public default void flush() { + + } + + /** + * Close the node. + */ + public default void close() { + + } + + /** + * Put the record into Mongo collection / DB Table. + * + * @param id the Key. + * @param value the Value. + * @param tableName Name of the table. + * @param primaryKeyMapping Primary key. + */ + public void put(K id, V value, String tableName, String primaryKeyMapping); + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/ConnectionException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/ConnectionException.java new file mode 100644 index 0000000..6c15efe --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/ConnectionException.java @@ -0,0 +1,52 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.dao.impl; + +/** + * Custom exception class for connection exceptions. + */ +public class ConnectionException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public ConnectionException(String msg) { + super(msg); + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/KafkaSinkNode.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/KafkaSinkNode.java new file mode 100644 index 0000000..05aa928 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/KafkaSinkNode.java @@ -0,0 +1,188 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.dao.impl; + +import org.apache.kafka.clients.producer.Callback; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.dao.SinkNode; +import org.eclipse.ecsp.analytics.stream.base.exception.ClassNotFoundException; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaSslUtils; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; + +import java.util.Arrays; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +/** + * Implementation for Kafka topic as sink node {@link SinkNode}. + */ +public class KafkaSinkNode implements SinkNode { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(KafkaSinkNode.class); + + /** The replace classloader. */ + private boolean replaceClassloader = false; + + /** The is sync put. */ + private boolean isSyncPut; + + /** The kafka partitioner class name. */ + private String kafkaPartitionerClassName = null; + + /** The {@link KafkaProducer} instance.*/ + private KafkaProducer producer = null; + + /** + * Initializes the KafkaProducer with certain configuration like Kafka Partitioner, Kafka Replace Classloader + * and SSL (if enabled). + * + * @param props the supplied configuration. + */ + @Override + public void init(Properties props) { + kafkaPartitionerClassName = props.getProperty(PropertyNames.KAFKA_PARTITIONER); + replaceClassloader = Boolean.parseBoolean(props.getProperty(PropertyNames.KAFKA_REPLACE_CLASSLOADER)); + isSyncPut = Boolean.parseBoolean(props.getProperty(PropertyNames.KAFKA_DEVICE_EVENTS_ASYNC_PUTS)); + KafkaSslUtils.checkAndApplySslProperties(props); + initKafkaProducer(props); + } + + /** + * Put into the specified Kafka topic. + * + * @param key the key + * @param messageInBytes the message in bytes + * @param kafkaTopic the kafka topic name + * @param primaryKeyMapping the primary key mapping + */ + @Override + public void put(byte[] key, byte[] messageInBytes, String kafkaTopic, String primaryKeyMapping) { + String keyString = Arrays.toString(key); + try { + logger.debug("Sending message to kafka. Topic: {}, Message: {}, key: {}", kafkaTopic, keyString); + java.util.concurrent.Future f = producer + .send(new ProducerRecord(kafkaTopic, key, messageInBytes), new Callback() { + public void onCompletion(RecordMetadata metadata, Exception e) { + if (e != null) { + logger.error("Exception occurred in " + + "KafkaProducerByPartition callback for key : {}", keyString, e); + } + } + }); + testIsSync(f, keyString); + } catch (Exception e) { + logger.error("Unable to send messages on Kafka for key : {} ", keyString, e); + } + logger.debug("Successfully sent message to kafka. Topic: {}, key: {}", kafkaTopic, keyString); + } + + /** + * Test is sync. + * + * @param f the f + * @param keyString the key string + */ + private void testIsSync(Future f, String keyString) { + if (isSyncPut) { + try { + f.get(); + } catch (InterruptedException exception) { + logger.error("Interrupted exception occured when when putting message to kafka for PDID : {} " + + keyString, exception); + Thread.currentThread().interrupt(); + } catch (ExecutionException ee) { + logger.error("Failed when putting message to kafka for PDID : {} " + keyString, ee); + } + } + } + + /** + * Initializes the {@link KafkaProducer}. + * + * @param props the Properties instance with supplied configuration. + */ + private void initKafkaProducer(Properties props) { + logger.info("Initializing Kafka Producer"); + try { + Class.forName(kafkaPartitionerClassName); + } catch (Exception e) { + logger.error("Failed when loading partitioner", e); + throw new ClassNotFoundException("Failed when loading partitioner", e); + } + ClassLoader ccl = Thread.currentThread().getContextClassLoader(); + if (replaceClassloader) { + Thread.currentThread().setContextClassLoader(null); + } + producer = new KafkaProducer<>(props); + if (replaceClassloader) { + Thread.currentThread().setContextClassLoader(ccl); + } + } + + /** + * Flush all the records immediately. + * + * @see KafkaProducer#flush() + */ + @Override + public void flush() { + logger.info("Flushing Kafka Producer"); + producer.flush(); + } + + /** + * method to close the opened resources. + */ + @Override + public void close() { + if (producer != null) { + logger.info("Closing Kafka Producer :"); + producer.close(); + logger.info("Closed Kafka Producer :"); + } + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/MongoSinkNode.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/MongoSinkNode.java new file mode 100644 index 0000000..c2b0b5c --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/MongoSinkNode.java @@ -0,0 +1,206 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.dao.impl; + +import com.mongodb.MongoClientSettings; +import com.mongodb.MongoCredential; +import com.mongodb.ReadPreference; +import com.mongodb.ServerAddress; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import dev.morphia.Datastore; +import dev.morphia.Morphia; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.dao.SinkNode; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.TimeUnit; + +/** + * Implementation for Mongo DB as sink node. + * + * @see SinkNode + */ +public class MongoSinkNode implements SinkNode { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(MongoSinkNode.class); + + /** The mongoDB port. */ + private int port = 0; + + /** The connection url. */ + private String url = ""; + + /** MongoDB username. */ + private String username = ""; + + /** MongoDB password. */ + private char[] password = new char[50]; + + /** The db access. */ + private String dbAccess = ""; + + /** The db name. */ + private String dbName = ""; + + /** The {@link Datastore}. */ + private Datastore datastore = null; + + /** The properties instance. */ + Properties props; + + /** The max connection. */ + private int maxConnection = 50; + + /** + * Connects to Mongo DB. + */ + private void connect() { + MongoClient mongoClient = null; + try { + logger.info("Connecting to MongoDB"); + MongoCredential credential = MongoCredential.createCredential(username, dbAccess, password); + MongoClientSettings.Builder mongoClientSettingsBuilder = MongoClientSettings.builder(); + mongoClientSettingsBuilder + .applyToConnectionPoolSettings(builder -> { + builder.maxConnecting(maxConnection); + builder.maxWaitTime(Integer.parseInt(props.getProperty( + PropertyNames.MONGO_CLIENT_MAX_WAIT_TIME_MS)), + TimeUnit.MILLISECONDS); + }).applyToSocketSettings(builder -> { + builder.connectTimeout(Integer.parseInt(props.getProperty( + PropertyNames.MONGO_CLIENT_CONNECTION_TIMEOUT_MS)), + TimeUnit.MILLISECONDS); + builder.readTimeout(Integer.parseInt(props.getProperty( + PropertyNames.MONGO_CLIENT_SOCKET_TIMEOUT_MS)), + TimeUnit.MILLISECONDS); + }).readPreference(ReadPreference.secondaryPreferred()) + .applyToClusterSettings(builder -> + builder.hosts(Arrays.asList(new ServerAddress(url, port))) + ).codecRegistry(MongoClientSettings.getDefaultCodecRegistry()).credential(credential); + logger.info("MongoDB connection strings. URL {} and port {}", url, port); + mongoClient = MongoClients.create(mongoClientSettingsBuilder.build()); + Calendar endTime = Calendar.getInstance(); + Calendar startTime = Calendar.getInstance(); + logger.info("Inititalizing the mongodb and time taken is: {}", + endTime.getTimeInMillis() - startTime.getTimeInMillis()); + + // create the Datastore connecting to the default port on the local + // host + this.datastore = Morphia.createDatastore(mongoClient, dbName); + // + // tell Morphia where to find your classes + this.datastore.getMapper().mapPackage("org.eclipse.ecsp.haa.pulse.entity"); + this.datastore.getMapper().mapPackage("org.eclipse.ecsp.haa.pulse.domain"); + Calendar endTimeMorpia = Calendar.getInstance(); + logger.debug("Connection time taken from Morphia : " + + (endTimeMorpia.getTimeInMillis() - endTime.getTimeInMillis())); + } catch (Exception e) { + if (mongoClient != null) { + mongoClient.close(); + logger.debug("DB Connection closed"); + } else { + logger.debug("DB Connection already closed"); + } + logger.info(" MongoDB exception " + e); + this.datastore = null; + } + } + + /** + * Gets the data store. + * + * @return the data store + */ + public Datastore getDataStore() { + return this.datastore; + } + + /** + * Initializes the configuration for connection to MongoDB. + * + * @param prop the properties instance to get the supplied config values from. + */ + @Override + public void init(Properties prop) { + this.props = prop; + url = props.getProperty(PropertyNames.MONGODB_URL); + port = Integer.parseInt(props.getProperty(PropertyNames.MONGODB_PORT)); + username = props.getProperty(PropertyNames.MONGODB_AUTH_USERNAME); + password = props.getProperty(PropertyNames.MONGODB_AUTH_PSWD).toCharArray(); + dbAccess = props.getProperty(PropertyNames.MONGODB_AUTH_DB); + dbName = props.getProperty(PropertyNames.MONGODB_DBNAME); + maxConnection = Integer.parseInt(props.getProperty(PropertyNames.MONGODB_POOL_MAX_SIZE)); + + connect(); + } + + /** + * Gets the record from a specified collection. + * + * @param primaryKeyValue the primary key value + * @param fieldName the field name + * @param tableName the table / collection name + * @return the optional + */ + @Override + public Optional get(String primaryKeyValue, String fieldName, String tableName) { + return Optional.empty(); + } + + /** + * Puts the record into the specified collection. + * + * @param id the id + * @param value the value + * @param tableName the table name + * @param primaryKeyMapping the primary key mapping + */ + @Override + public void put(String id, String value, String tableName, String primaryKeyMapping) { + //overridden method of SinkNode class + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/discovery/PropBasedDiscoveryServiceImpl.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/discovery/PropBasedDiscoveryServiceImpl.java new file mode 100644 index 0000000..5eeb423 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/discovery/PropBasedDiscoveryServiceImpl.java @@ -0,0 +1,170 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.discovery; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessorFilter; +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Properties; + +/** + * The purpose of this class is to chain the mandatory pre and post processors along with the service processors. + * The pre and post processor classes are pluggable via the following configs exposed by the stream-base + * library: {@link PropertyNames#PRE_PROCESSORS} & {@link PropertyNames#POST_PROCESSORS}. + * + *

    + * In between pre and post processors, service integrating the stream-base library can provide its own + * {@link StreamProcessor} which will be chained like: pre-processors -> service processor -> post-processors. + *

    + * + * @author avadakkootko + * @param the type parameter for incoming key. + * @param the type parameter for incoming value. + * @param the type parameter for outgoing key. + * @param the type parameter for outgoing value. + */ +public class PropBasedDiscoveryServiceImpl implements + StreamProcessorDiscoveryService { + + /** The Properties instance. */ + private Properties props; + + /** + * Sets the properties. + * + * @param props the new properties + */ + @Override + public void setProperties(Properties props) { + this.props = props; + } + + /** + * Chaining of service's processor nodes in the fashion: + * pre-processors nodes-> service stream processor node -> post-processor nodes. + * This discovery supports both legacy as well as PRE and POST processor approach. Its backward compatible. + * + * @return the list of all discovered StreamProcessor + */ + @Override + public List> discoverProcessors() { + + List> processors = new ArrayList<>(); + Optional>> preProcessors = + getProcessorsFromProperties(PropertyNames.PRE_PROCESSORS, props); + if (preProcessors.isPresent()) { + processors.addAll(preProcessors.get()); + } + + if (props.containsKey(PropertyNames.SERVICE_STREAM_PROCESSORS)) { + Optional>> serviceProcessors = getProcessorsFromProperties( + PropertyNames.SERVICE_STREAM_PROCESSORS, props); + if (serviceProcessors.isPresent()) { + processors.addAll(serviceProcessors.get()); + } + } + + Optional>> postProcessors = + getProcessorsFromProperties(PropertyNames.POST_PROCESSORS, props); + if (postProcessors.isPresent()) { + processors.addAll(postProcessors.get()); + } + return processors; + } + + /** + * Gets the processors from properties. + * + * @param processorType the processor type + * @param props the props + * @return the processors from properties + */ + private Optional>> + getProcessorsFromProperties(String processorType, Properties props) { + List> processorList = null; + String processors = props.getProperty(processorType); + if (StringUtils.isNotBlank(processors)) { + processorList = new ArrayList<>(); + String[] processorsArr = processors.split(","); + for (int i = 0; i < processorsArr.length; i++) { + try { + createProcessorList(props, processorList, processorsArr[i]); + } catch (InstantiationException | IllegalAccessException + | ClassNotFoundException | NoSuchMethodException | InvocationTargetException e) { + throw new IllegalArgumentException("Unable to instantiate processor : " + processorsArr[i], e); + } + } + } + return Optional.ofNullable(processorList); + } + + /** + * Creates the processor list. + * + * @param props the props + * @param processorList the processor list + * @param processorsArr the processors arr + * @throws InstantiationException the instantiation exception + * @throws IllegalAccessException the illegal access exception + * @throws ClassNotFoundException the class not found exception + * @throws NoSuchMethodException the no such method exception + * @throws InvocationTargetException the invocation target exception + */ + private void createProcessorList(Properties props, + List> processorList, String processorsArr) + throws InstantiationException, IllegalAccessException, + ClassNotFoundException, NoSuchMethodException, InvocationTargetException { + Object streamProcessor = getClass().getClassLoader().loadClass(processorsArr) + .getDeclaredConstructor().newInstance(); + if (streamProcessor instanceof StreamProcessorFilter streamProcessorFilter) { + if (streamProcessorFilter.includeInProcessorChain(props)) { + processorList.add((StreamProcessor) streamProcessor); + } + } else { + processorList.add((StreamProcessor) streamProcessor); + } + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/discovery/SPIDiscoveryServiceImpl.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/discovery/SPIDiscoveryServiceImpl.java new file mode 100644 index 0000000..efb6f54 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/discovery/SPIDiscoveryServiceImpl.java @@ -0,0 +1,84 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.discovery; + +import org.eclipse.ecsp.analytics.stream.base.StreamProcessor; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.ServiceLoader; + +/** + * Discovers stream processors by inspecting Java SPI metadata. + * + * @author ssasidharan + * @param the generic type + * @param the generic type + * @param the generic type + * @param the generic type + */ +public class SPIDiscoveryServiceImpl + implements StreamProcessorDiscoveryService { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(SPIDiscoveryServiceImpl.class); + + /** + * Discover processors. + * + * @return the list + */ + @Override + @SuppressWarnings("rawtypes") + public List> discoverProcessors() { + ServiceLoader ldr = ServiceLoader.load(StreamProcessor.class); + Iterator procs = ldr.iterator(); + List> procList = new ArrayList<>(); + while (procs.hasNext()) { + StreamProcessor proc = procs.next(); + logger.info("Discovered processor: " + proc.getClass().getName()); + procList.add(proc); + } + return procList; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/discovery/StreamProcessorDiscoveryService.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/discovery/StreamProcessorDiscoveryService.java new file mode 100644 index 0000000..b39cf93 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/discovery/StreamProcessorDiscoveryService.java @@ -0,0 +1,73 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.discovery; + +import org.eclipse.ecsp.analytics.stream.base.StreamProcessor; +import java.util.List; +import java.util.Properties; + +/** + * Abstraction for processor discovery. + * + * @author ssasidharan + * @param the type parameter for incoming key. + * @param the type parameter for incoming value. + * @param the type parameter for outgoing key. + * @param the type parameter for outgoing value. + */ +public interface StreamProcessorDiscoveryService { + + /** + * Discover processors. + * + * @return the list + */ + List> discoverProcessors(); + + /** + * Sets the properties. + * + * @param props the new properties + */ + default void setProperties(Properties props) { + + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/BackdoorKafkaConsumerException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/BackdoorKafkaConsumerException.java new file mode 100644 index 0000000..d077509 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/BackdoorKafkaConsumerException.java @@ -0,0 +1,60 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + + +/** + * Custom runtime exception for errors in {@link BackdoorKafkaConsumer}. + */ +public class BackdoorKafkaConsumerException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new backdoor kafka consumer exception. + * + * @param message the message + * @param throwable the throwable + */ + public BackdoorKafkaConsumerException(String message, Throwable throwable) { + super(message, throwable); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ClassNotFoundException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ClassNotFoundException.java new file mode 100644 index 0000000..74940be --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ClassNotFoundException.java @@ -0,0 +1,59 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Custom runtime exception for class not found. + */ +public class ClassNotFoundException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new class not found exception. + * + * @param message the message + * @param throwable the throwable + */ + public ClassNotFoundException(String message, Throwable throwable) { + super(message, throwable); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ClientConnectionException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ClientConnectionException.java new file mode 100644 index 0000000..97a82e1 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ClientConnectionException.java @@ -0,0 +1,58 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Custom runtime exception in case of connection errors. + */ +public class ClientConnectionException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new client connection exception. + * + * @param message the message + */ + public ClientConnectionException(String message) { + super(message); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ClientInterruptedException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ClientInterruptedException.java new file mode 100644 index 0000000..90b3b77 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ClientInterruptedException.java @@ -0,0 +1,59 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Custom runtime exception for when client is interrupted. + */ +public class ClientInterruptedException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new client interrupted exception. + * + * @param message the message + * @param throwable the throwable + */ + public ClientInterruptedException(String message, Throwable throwable) { + super(message, throwable); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/DeviceMessagingMqttClientTrustStoreException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/DeviceMessagingMqttClientTrustStoreException.java new file mode 100644 index 0000000..84690b9 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/DeviceMessagingMqttClientTrustStoreException.java @@ -0,0 +1,59 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Custom exception for DMA SSL Truststore. + */ +public class DeviceMessagingMqttClientTrustStoreException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new device messaging mqtt client trust store exception. + * + * @param message the message + * @param cause the cause + */ + public DeviceMessagingMqttClientTrustStoreException(String message, Throwable cause) { + super(message); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/HeaderUpdateException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/HeaderUpdateException.java new file mode 100644 index 0000000..03d96c4 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/HeaderUpdateException.java @@ -0,0 +1,58 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Custom runtime exception for when DMA is unable to update the event with messageId or correlationId. + */ +public class HeaderUpdateException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new header update exception. + * + * @param message the message + */ + public HeaderUpdateException(String message) { + super(message); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InputStreamMaxSizeExceededException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InputStreamMaxSizeExceededException.java new file mode 100644 index 0000000..f89f6b4 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InputStreamMaxSizeExceededException.java @@ -0,0 +1,59 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Custom exception for when number of characters in input stream exceeds the defined limit. + */ +public class InputStreamMaxSizeExceededException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 445688994354L; + + /** + * Instantiates a new input stream max size exceeded exception. + * + * @param message the message + */ + public InputStreamMaxSizeExceededException(String message) { + super(message); + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidKeyOrValueException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidKeyOrValueException.java new file mode 100644 index 0000000..5588787 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidKeyOrValueException.java @@ -0,0 +1,59 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Custom runtime exception for invalid key or value arrived in stream-base. + */ +public class InvalidKeyOrValueException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new invalid key or value exception. + * + * @param message the message + */ + public InvalidKeyOrValueException(String message) { + super(message); + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidMetricSpecifiedException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidMetricSpecifiedException.java new file mode 100644 index 0000000..86025ba --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidMetricSpecifiedException.java @@ -0,0 +1,58 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Custom runtime exception for invalid configuration related to metrics. + */ +public class InvalidMetricSpecifiedException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new invalid metric specified exception. + * + * @param message the message + */ + public InvalidMetricSpecifiedException(String message) { + super(message); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidSequenceBlockException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidSequenceBlockException.java new file mode 100644 index 0000000..4a58fe4 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidSequenceBlockException.java @@ -0,0 +1,58 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Sequence block related exception. + */ +public class InvalidSequenceBlockException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new invalid sequence block exception. + * + * @param message the message + */ + public InvalidSequenceBlockException(String message) { + super(message); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidServiceNameException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidServiceNameException.java new file mode 100644 index 0000000..0f80c3a --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidServiceNameException.java @@ -0,0 +1,59 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Custom runtime exception for incorrect value of "service.name" property. + */ +public class InvalidServiceNameException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new invalid service name exception. + * + * @param message the message + */ + public InvalidServiceNameException(String message) { + super(message); + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidSourceTopicException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidSourceTopicException.java new file mode 100644 index 0000000..1833f29 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidSourceTopicException.java @@ -0,0 +1,59 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Custom exception for invalid source topic name coming from the {@link StreamProcessingContext} instance. + */ +public class InvalidSourceTopicException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new invalid source topic exception. + * + * @param message the message + */ + public InvalidSourceTopicException(String message) { + super(message); + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidStoreException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidStoreException.java new file mode 100644 index 0000000..7d40055 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidStoreException.java @@ -0,0 +1,58 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Exception for invalid store. + */ +public class InvalidStoreException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new invalid store exception. + * + * @param message the message + */ + public InvalidStoreException(String message) { + super(message); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidTargetIDException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidTargetIDException.java new file mode 100644 index 0000000..c426386 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidTargetIDException.java @@ -0,0 +1,59 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Custom runtime exception in case of targetDeviceId missing in the IgniteEvent. + */ +public class InvalidTargetIDException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new invalid target ID exception. + * + * @param message the message + */ + public InvalidTargetIDException(String message) { + super(message); + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidVehicleIDException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidVehicleIDException.java new file mode 100644 index 0000000..dcc443d --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/InvalidVehicleIDException.java @@ -0,0 +1,58 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Custom runtime exception in case vehicleId not set in IgniteEvent properly. + */ +public class InvalidVehicleIDException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new invalid vehicle ID exception. + * + * @param message the message + */ + public InvalidVehicleIDException(String message) { + super(message); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/MaxRetriesFailedException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/MaxRetriesFailedException.java new file mode 100644 index 0000000..2b1ddf0 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/MaxRetriesFailedException.java @@ -0,0 +1,59 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Custom exception for retries exhausted if a function doesn't return successfully. + * Check usage in {@link RetryUtils} + */ +public class MaxRetriesFailedException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new max retries failed exception. + * + * @param message the message + */ + public MaxRetriesFailedException(String message) { + super(message); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/MqttTopicException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/MqttTopicException.java new file mode 100644 index 0000000..575b014 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/MqttTopicException.java @@ -0,0 +1,59 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * This exception will be thrown if certain properties are not configured properly + * to construct the MQTT topic name dynamically. + */ +public class MqttTopicException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new mqtt topic exception. + * + * @param message the message + */ + public MqttTopicException(String message) { + super(message); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ObjectUtilsException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ObjectUtilsException.java new file mode 100644 index 0000000..a8a59f5 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/ObjectUtilsException.java @@ -0,0 +1,58 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Exception for Object's Utility. + */ +public class ObjectUtilsException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new object utils exception. + * + * @param message the message + */ + public ObjectUtilsException(String message) { + super(message); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/OfflineBufferEntriesException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/OfflineBufferEntriesException.java new file mode 100644 index 0000000..772653a --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/OfflineBufferEntriesException.java @@ -0,0 +1,59 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * This exception is thrown in case of an operation failed for the offline buffer entries. + */ +public class OfflineBufferEntriesException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new offline buffer entries exception. + * + * @param message the message + * @param throwable the throwable + */ + public OfflineBufferEntriesException(String message, Throwable throwable) { + super(message, throwable); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/PropertyNotFoundException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/PropertyNotFoundException.java new file mode 100644 index 0000000..e1e3587 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/PropertyNotFoundException.java @@ -0,0 +1,58 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Exception thrown in case a property's default value is not found. + */ +public class PropertyNotFoundException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new property not found exception. + * + * @param message the message + */ + public PropertyNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/UnableToReadFileException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/UnableToReadFileException.java new file mode 100644 index 0000000..f8e9279 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/UnableToReadFileException.java @@ -0,0 +1,59 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Exception thrown in case of failing to read a file. + */ +public class UnableToReadFileException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new unable to read file exception. + * + * @param message the message + * @param throwable the throwable + */ + public UnableToReadFileException(String message, Throwable throwable) { + super(message, throwable); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/UnsupportedTimeUnitException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/UnsupportedTimeUnitException.java new file mode 100644 index 0000000..31c899b --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/exception/UnsupportedTimeUnitException.java @@ -0,0 +1,58 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.exception; + +/** + * Exception thrown in case of invalid time unit set against "metric.logging.unit". + */ +public class UnsupportedTimeUnitException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new unsupported time unit exception. + * + * @param message the message + */ + public UnsupportedTimeUnitException(String message) { + super(message); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/healthcheck/KafkaTopicsHealthMonitor.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/healthcheck/KafkaTopicsHealthMonitor.java new file mode 100644 index 0000000..2b37942 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/healthcheck/KafkaTopicsHealthMonitor.java @@ -0,0 +1,334 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.healthcheck; + +import jakarta.annotation.PostConstruct; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.DescribeTopicsResult; +import org.apache.kafka.clients.admin.TopicDescription; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.KafkaFuture; +import org.apache.kafka.common.Node; +import org.apache.kafka.common.TopicPartitionInfo; +import org.eclipse.ecsp.analytics.stream.base.KafkaSslConfig; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.exception.UnableToReadFileException; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.healthcheck.HealthMonitor; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ExecutionException; + +import static org.eclipse.ecsp.analytics.stream.base.utils.Constants.FOR_PARTITION; + +/** + * KafkaTopicsHealthMonitor is responsible for the checking the health of kafka topics. + * If the topics' configuration is not as expected, then health status will be returned as "unhealthy". + * Check {@link #isHealthy(boolean)} for the basis of health check. + */ +@Service +public class KafkaTopicsHealthMonitor implements HealthMonitor { + + /** The Constant PROPS. */ + private static final Properties PROPS = new Properties(); + + /** The bootstrap server URLs to connect to Kafka broker. */ + @Value("${" + PropertyNames.BOOTSTRAP_SERVERS + ":}") + private String bootstrapServer; + + /** Whether to restart on getting unhealthy status by this health monitor. */ + @Value("${" + PropertyNames.KAFKA_TOPICS_NEEDS_RESTART_ON_FAILURE + ":false}") + private boolean needsRestartOnFailure; + + /** IF this health monitor is enabled or not. */ + @Value("${" + PropertyNames.ENABLE_HEALTHCHECK + ":false}") + private boolean enabled; + + /** The kafka topic file which contains topics configuration like number of partitions, replication factor etc. */ + @Value("${" + PropertyNames.KAFKA_TOPICS_FILE_PATH + ":/data/topics.txt}") + private String kafkaTopicFile; + + /** The connections max idle ms. */ + @Value("${" + PropertyNames.CONNECTIONS_MAX_IDLE_MS + ":-1}") + private String connectionsMaxIdleMs; + + /** The kafka ssl config. */ + @Autowired + private KafkaSslConfig kafkaSslConfig; + + /** The props. */ + private Properties props = new Properties(); + + /** The topic config. */ + private Map topicConfig; + + /** The topics. */ + private Set topics; + + /** The {@link AdminClient} instance. */ + private AdminClient admin; + + /** The logger. */ + private IgniteLogger logger = IgniteLoggerFactory.getLogger(KafkaTopicsHealthMonitor.class); + + /** + * Initializes properties for this Health monitor class. + */ + @PostConstruct + public void init() { + PROPS.put(PropertyNames.BOOTSTRAP_SERVERS, bootstrapServer); + PROPS.put(ConsumerConfig.CONNECTIONS_MAX_IDLE_MS_CONFIG, connectionsMaxIdleMs); + kafkaSslConfig.setSslPropsIfEnabled(props); + logger.debug("Admin client config for topics health monitor {}", PROPS); + topicConfig = getTopicsConfig(); + topics = topicConfig.keySet(); + logger.info("Creating admin client with properties : {}", props); + admin = AdminClient.create(PROPS); + logger.info("admin client created - {}", admin.getClass()); + logger.info("Get topic descriptions for topics {}", topics); + } + + /** + * The logic for kafka topics healthcheck is based on whether the topics that have been created have + * the expected configuration or not. + * + * @param forceHealthCheck if to perform force health check + * @return true, if it is healthy + */ + @Override + public boolean isHealthy(boolean forceHealthCheck) { + boolean flag = true; + String[] topicConfigured = null; + Map> topicPartitionMap = null; + try { + DescribeTopicsResult topicResult = admin.describeTopics(topics); + topicPartitionMap = topicResult.topicNameValues(); + } catch (Exception e) { + logger.error("Kafka topics monitor unabe to describe topics", e); + } + KafkaFuture topicDescription = null; + List topicPartitionInfoList = null; + StringBuilder errBuilder = null; + StringBuilder warnBuilder = null; + int expectedPartitionSize = 0; + int configuredReplicationFactor = 0; + int expectedMinIsr = 0; + for (String topic : topics) { + errBuilder = new StringBuilder(); + warnBuilder = new StringBuilder(); + topicDescription = topicPartitionMap.get(topic); + topicConfigured = topicConfig.get(topic); + + try { + topicPartitionInfoList = topicDescription.get().partitions(); + } catch (InterruptedException | ExecutionException e) { + logger.error("Kafka topics monitor error : Unable to fetch partitions {}", e); + return false; + } + expectedPartitionSize = Integer.parseInt(topicConfigured[1]); + configuredReplicationFactor = Integer.parseInt(topicConfigured[Constants.TWO]); + expectedMinIsr = configuredReplicationFactor - 1; + // Expected ISR should be atleast 1. + expectedMinIsr = (expectedMinIsr >= 1) ? expectedMinIsr : 1; + int partitionSizeFromKafka = topicPartitionInfoList.size(); + if (expectedPartitionSize != partitionSizeFromKafka) { + flag = false; + errBuilder.append("Partiton mismatch :: expectedPartitionSize=").append(expectedPartitionSize) + .append(", partitionSizeFromKafka=").append(partitionSizeFromKafka).append("\n"); + } + for (TopicPartitionInfo partition : topicPartitionInfoList) { + flag = checkFlagForPartions(flag, errBuilder, warnBuilder, + configuredReplicationFactor, expectedMinIsr, partition); + } + String error = errBuilder.toString(); + String warn = warnBuilder.toString(); + if (error.length() > 0) { + logger.error("For topic : " + topic + ", following needs to be fixed \n" + error); + } + if (warn.length() > 0) { + logger.warn("For topic : " + topic + ", following may be a potential issue \n" + warn); + } + } + + return flag; + } + + /** + * Check flag for partions. + * + * @param flag the flag + * @param errBuilder the err builder + * @param warnBuilder the warn builder + * @param configuredReplicationFactor the configured replication factor + * @param expectedMinIsr the expected min isr + * @param partition the partition + * @return true, if successful + */ + private static boolean checkFlagForPartions(boolean flag, StringBuilder errBuilder, + StringBuilder warnBuilder, int configuredReplicationFactor, + int expectedMinIsr, TopicPartitionInfo partition) { + List isrList; + isrList = partition.isr(); + int actualReplicationFactor = partition.replicas().size(); + Node partitionLeader = partition.leader(); + int partitionLeaderId = partitionLeader == null ? Constants.NEGATIVE_ONE : partition.leader().id(); + int actualIsr = isrList.size(); + int partitionId = partition.partition(); + if (actualReplicationFactor < configuredReplicationFactor) { + warnBuilder.append(FOR_PARTITION).append(partitionId) + .append(":: Actual number of replicas ") + .append(actualReplicationFactor).append(" is less than configured replication factor ") + .append(configuredReplicationFactor).append("\n"); + } + if (partitionLeaderId < 0 || actualIsr < expectedMinIsr + || actualReplicationFactor < expectedMinIsr) { + flag = false; + errBuilder.append(FOR_PARTITION).append(partitionId) + .append(" :: Leader not assigned (or) expected Replication factor is not available " + + "(or) actual isr less than expected isr . Partition leader=") + .append(partitionLeaderId) + .append(", actualReplicationFactor=").append(actualReplicationFactor) + .append(", replication=").append(actualReplicationFactor) + .append(", actualISR=").append(actualIsr) + .append(", expectedMinIsr=").append(expectedMinIsr).append("\n"); + + } + boolean isrLeaderFlag = false; + for (Node node : isrList) { + if (partitionLeaderId == node.id()) { + isrLeaderFlag = true; + break; + } + } + if (!isrLeaderFlag) { + flag = false; + errBuilder.append(FOR_PARTITION).append(partitionId).append(" :: leader ") + .append(partitionId).append(" is not available in isr list"); + } + return flag; + } + + /** + * Name of the health monitor. + * + * @return the string + */ + @Override + public String monitorName() { + return Constants.KAFKA_TOPICS_HEALTH_MONITOR; + } + + /** + * Needs restart on failure. + * + * @return true, if successful + */ + @Override + public boolean needsRestartOnFailure() { + return needsRestartOnFailure; + } + + /** + * Metric name under which health metrics will be published. + * + * @return the string + */ + @Override + public String metricName() { + return Constants.KAFKA_TOPICS_METRIC_NAME; + } + + /** + * Checks if is enabled. + * + * @return true, if is enabled + */ + @Override + public boolean isEnabled() { + return enabled; + } + + /** + * Gets the topics config. + * + * @return the topics config + */ + private Map getTopicsConfig() { + Map topicInfoConfigMap = new HashMap<>(); + if (isEnabled()) { + try { + BufferedReader br = new BufferedReader(new FileReader(kafkaTopicFile)); + String line = null; + String[] params = null; + while ((line = br.readLine()) != null) { + params = line.split(Constants.DELIMITER); + topicInfoConfigMap.put(params[0], params); + } + br.close(); + if (topicInfoConfigMap.size() == 0) { + logger.error("No topics configured in the mounted path {}", kafkaTopicFile); + } + } catch (Exception e) { + logger.error( + "Kafka topics monitor : Error while reading topics.txt. " + + "Please check if /data has been mounted in the container", + e); + throw new UnableToReadFileException( + "Error while reading topics.txt. Please check if " + + kafkaTopicFile + " has been mounted in the container", e); + } + } else { + logger.info("Kafka topics monitor is disabled. Topics config will not be fetched from data volume"); + } + return topicInfoConfigMap; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/http/HttpClient.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/http/HttpClient.java new file mode 100644 index 0000000..f27d530 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/http/HttpClient.java @@ -0,0 +1,294 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.http; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import okhttp3.Headers; +import okhttp3.Headers.Builder; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; + +/** + * This class does the Http hit using {@link OkHttpClient}. + */ +@Component +public class HttpClient { + + /** The response code. */ + public static String RESPONSE_CODE = "responseCode"; + + /** The response json. */ + public static String RESPONSE_JSON = "responseJson"; + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(HttpClient.class); + + /** The {@link HttpClientFactory} instance. */ + @Autowired + private HttpClientFactory httpClientFactory; + + /** The {@link OkHttpClient} instance. */ + private OkHttpClient okHttpClient; + + /** The {@link ObjectMapper} instance for serialization /deserialization. */ + private ObjectMapper responseMapper; + + /** The {@link Response} instance. */ + private Response response; + + /** + * Gets the request builder. + * + * @param method the method + * @param parameters the parameters + * @param requestBuilder the request builder + * @return the request builder + * @throws JsonProcessingException the json processing exception + */ + @NotNull + private static Request.Builder getRequestBuilder(HttpReqMethod method, Map parameters, + Request.Builder requestBuilder) throws JsonProcessingException { + String jsonBody = "{}"; + if (parameters != null) { + jsonBody = new ObjectMapper().writeValueAsString(parameters); + } + MediaType json = MediaType.parse("application/json; charset=utf-8"); + RequestBody requestBody = RequestBody.create(json, jsonBody); + + requestBuilder = HttpReqMethod.PUT.equals(method) + ? requestBuilder.put(requestBody) : requestBuilder.post(requestBody); + return requestBuilder; + } + + /** + * Enum for request type. + */ + public enum HttpReqMethod { + + /** The put. */ + PUT, + /** The post. */ + POST, + /** The get. */ + GET; + } + + /** + * Invokes the API with the specified URL. + * + * @param httpUrl httpUrl + * @return JsonNode Response data. + */ + public JsonNode invokeJsonResource(String httpUrl) { + Map header = new HashMap<>(); + Map params = new HashMap<>(); + + Map responseData = invokeJsonResource(HttpReqMethod.GET, + httpUrl, header, params, Constants.THREE, Constants.LONG_60000); + + return (JsonNode) responseData.get(RESPONSE_JSON); + } + + /** + * Invokes the API with the specified URL and query params. + * + * @param httpUrl httpUrl + * @param keyValue Query parameters. + * @return JsonNode Response. + */ + public JsonNode invokeJsonResource(String httpUrl, Map keyValue) { + Map parameters = new HashMap<>(); + keyValue.forEach(parameters::put); + Map header = new HashMap<>(); + + Map responseData = invokeJsonResource(HttpReqMethod.GET, + httpUrl, header, parameters, Constants.THREE, Constants.LONG_60000); + + return (JsonNode) responseData.get(RESPONSE_JSON); + } + + /** + * Executes given HTTP GET/PUT/POST request URL with headers and parameters. + * For PUT/POST, parameters go into the request body as JSON; + * and for GET, parameters are appended to the URL. + * + * @param method method + * @param httpUrl httpUrl + * @param headers headers + * @param parameters parameters + * @param retryCount the retry count + * @param retryInterval the retry interval + * @return responseData has HTTP response status code and JSON + */ + public Map invokeJsonResource(HttpReqMethod method, String httpUrl, Map headers, + Map parameters, int retryCount, long retryInterval) { + if ((!(HttpReqMethod.GET.equals(method) | HttpReqMethod.PUT + .equals(method) | HttpReqMethod.POST.equals(method)))) { + throw new IllegalArgumentException("Accepts only 'GET', 'PUT', 'POST' method."); + } + + Map responseData = new HashMap<>(); + try { + Builder requestHeader = new Headers.Builder(); + if (headers != null) { + headers.forEach(requestHeader::add); + } + + Request.Builder requestBuilder = new Request.Builder(); + + if (HttpReqMethod.PUT.equals(method) || HttpReqMethod.POST.equals(method)) { + requestBuilder = getRequestBuilder(method, parameters, requestBuilder); + } else { + HttpUrl.Builder urlBuilder = getUrlBuilder(httpUrl, parameters); + requestBuilder = requestBuilder.get(); + HttpUrl url = urlBuilder.build(); + httpUrl = url.toString(); + } + + requestBuilder.url(httpUrl).headers(requestHeader.build()); + + Request request = requestBuilder.build(); + + int retryAttempt = 0; + do { + JsonNode jsonNode = sendRequest(request, responseData, httpUrl, headers, parameters); + if (jsonNode != null) { + responseData.put(RESPONSE_JSON, jsonNode); + break; + } + retryAttempt++; + logger.info("Retrying URL={}, headers={}, parameters={}, retryAttempt={}, retryInterval={}", + httpUrl, headers, parameters, retryAttempt, retryInterval); + Thread.sleep(retryInterval); + } while (retryAttempt <= retryCount); + + logger.debug("Executed URL={}, headers={}, parameters={}, responseData={}", httpUrl, headers, parameters, + responseData); + } catch (InterruptedException exception) { + logger.error("Interrupted exception occurred while executing URL={}, headers={}, " + + "parameters={}, exception={}", httpUrl, headers, parameters, exception); + Thread.currentThread().interrupt(); + } catch (Exception e) { + logger.error("Error while executing URL={}, headers={}, parameters={}, " + + "response={}, error={}", httpUrl, headers, parameters, e.getMessage()); + } finally { + if (response != null) { + response.close(); + } + } + return responseData; + } + + /** + * Invokes the API. + * + * @param request the request + * @param responseData the response data + * @param httpUrl the http url + * @param headers the headers + * @param parameters the parameters + * @return the json node + */ + private JsonNode sendRequest(Request request, Map responseData, String httpUrl, + Map headers, Map parameters) { + try { + response = okHttpClient.newCall(request).execute(); + + int respStatusCode = response.code(); + + responseData.put(RESPONSE_CODE, String.valueOf(respStatusCode)); + + String responseBody = response.body().string(); + return responseMapper.readTree(responseBody); + + } catch (Exception e) { + logger.error("Error while executing URL={}, headers={}, parameters={}, response={}, " + + "error={}", httpUrl, headers, parameters, e.getMessage()); + return null; + } + } + + /** + * Gets the url builder. + * + * @param httpUrl the http url + * @param parameters the parameters + * @return the url builder + */ + @NotNull + private static HttpUrl.Builder getUrlBuilder(String httpUrl, Map parameters) { + HttpUrl.Builder urlBuilder = HttpUrl.parse(httpUrl).newBuilder(); + + if (parameters != null) { + for (Entry entry : parameters.entrySet()) { + urlBuilder.addQueryParameter(entry.getKey(), entry.getValue().toString()); + } + } + return urlBuilder; + } + + /** + * Initializer for this class. + */ + @PostConstruct + public void init() { + responseMapper = new ObjectMapper(); + okHttpClient = httpClientFactory.createDefaultHttpClient(Optional.empty(), false); + logger.info("HttpClient initialized."); + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/http/HttpClientFactory.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/http/HttpClientFactory.java new file mode 100644 index 0000000..b51d2cd --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/http/HttpClientFactory.java @@ -0,0 +1,146 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.http; + +import jakarta.annotation.PostConstruct; +import okhttp3.Authenticator; +import okhttp3.ConnectionPool; +import okhttp3.Credentials; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.Route; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Http client which uses for HTTP call. + */ +@Component +public class HttpClientFactory { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(HttpClientFactory.class); + + /** The connection timeout in sec. */ + @Value("${" + PropertyNames.HTTP_CONNECTION_TIMEOUT_IN_SEC + ":120}") + private long connectionTimeoutInSec; + + /** The read timeout in sec. */ + @Value("${" + PropertyNames.HTTP_READ_TIMEOUT_IN_SEC + ":60}") + private long readTimeoutInSec; + + /** The write timeout in sec. */ + @Value("${" + PropertyNames.HTTP_WRITE_TIMEOUT_IN_SEC + ":60}") + private long writeTimeoutInSec; + + /** The keep alive duration in sec. */ + @Value("${" + PropertyNames.HTTP_KEEP_ALIVE_DURATION_IN_SEC + ":120}") + private long keepAliveDurationInSec; + + /** The max idle connections. */ + @Value("${" + PropertyNames.HTTP_MAX_IDLE_CONNECTIONS + ":20}") + private int maxIdleConnections; + + /** The http auth header. */ + @Value("${" + PropertyNames.HTTP_VP_SERVICE_AUTH_HEADER + ":Authorization}") + private String httpAuthHeader; + + /** The http vp service user. */ + @Value("${" + PropertyNames.HTTP_VP_SERVICE_USER + ":}") + private String httpVpServiceUser; + + /** The http vp service password. */ + @Value("${" + PropertyNames.HTTP_VP_SERVICE_PASSWORD + ":}") + private String httpVpServicePassword; + + /** The connection pool. */ + private ConnectionPool connectionPool; + + /** + * init(). + */ + @PostConstruct + public void init() { + connectionPool = new ConnectionPool(maxIdleConnections, keepAliveDurationInSec, TimeUnit.SECONDS); + logger.info( + "Connection pool of HTTPClient. connectionTimeoutInSec:{} readTimeoutInSec:{}" + + " writeTimeoutInSec:{} keepAliveDurationInSec:{} maxIdleConnections:{} " + + "httpAuthHeader:{} httpVPServiceUser:{} ", + connectionTimeoutInSec, readTimeoutInSec, writeTimeoutInSec, + keepAliveDurationInSec, maxIdleConnections, httpAuthHeader, + httpVpServiceUser); + } + + /** + * It returns the OkHttpClient which supports HTTP protocol. + * It internally usages the connectionpool which is set in init method. + * + * @param authRequired - true, if authentication is required while connection to server + * @param retryOnConnectionFailure the retry on connection failure + * @return OkHttpClient + */ + public OkHttpClient createDefaultHttpClient(Optional authRequired, boolean retryOnConnectionFailure) { + OkHttpClient.Builder builder = new OkHttpClient().newBuilder(); + builder.connectTimeout(connectionTimeoutInSec, TimeUnit.SECONDS) + .readTimeout(readTimeoutInSec, TimeUnit.SECONDS).writeTimeout(writeTimeoutInSec, TimeUnit.SECONDS) + .connectionPool(connectionPool) + .retryOnConnectionFailure(retryOnConnectionFailure); + if (authRequired.isPresent() && Boolean.TRUE.equals(authRequired.get())) { + builder.authenticator(new Authenticator() { + + @Override + public Request authenticate(Route route, Response response) throws IOException { + String credential = Credentials.basic(httpVpServiceUser, httpVpServicePassword); + return response.request().newBuilder().header(httpAuthHeader, credential).build(); + } + }); + } + + return builder.build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/MessageIdGenerator.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/MessageIdGenerator.java new file mode 100644 index 0000000..a899ae0 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/MessageIdGenerator.java @@ -0,0 +1,47 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.idgen; + +/** + * Interface for generating the IDs for the events coming into stream-base library. + */ +public interface MessageIdGenerator { + public String generateUniqueMsgId(String serviceName); +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/MessageIdPartGenerator.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/MessageIdPartGenerator.java new file mode 100644 index 0000000..c666cf8 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/MessageIdPartGenerator.java @@ -0,0 +1,47 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.idgen; + +/** + * interface {@link MessageIdPartGenerator}. + */ +public interface MessageIdPartGenerator { + public String generateIdPart(String serviceName); +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/GlobalMessageIdGenerator.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/GlobalMessageIdGenerator.java new file mode 100644 index 0000000..84811fa --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/GlobalMessageIdGenerator.java @@ -0,0 +1,198 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.idgen.internal; + +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.exception.InvalidSequenceBlockException; +import org.eclipse.ecsp.analytics.stream.base.idgen.MessageIdGenerator; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * GlobalMessageGenerator is responsible for generating message unique ID + * and store in memory to serve caching using LRU algorithm and this + * is not thread safe. + * This class has a dependency on Mongo so need to give + * permission to access the mongo collection "sequenceBlock" (for all CRUD + * operations). + * + * @author Binoy Mandal + */ +@Component +@Scope("prototype") +public class GlobalMessageIdGenerator implements MessageIdGenerator { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(GlobalMessageIdGenerator.class); + + /** The threshold. */ + @Value("${lru.map.threshold:100000}") + private int threshold; + + /** The block value. */ + @Value("${" + PropertyNames.SEQUENCE_BLOCK_MAXVALUE + ":10000}") + private int blockValue; + + /** The retry counter. */ + @Value("${message.generation.retry.counter:5}") + private byte retryCounter; + + /** The retry interval. */ + @Value("${message.generation.retry.interval:500}") + private long retryInterval; + + /** The vehicle id counter map. */ + private Map vehicleIdCounterMap; + + /** The sequence block service. */ + @Autowired + private SequenceBlockService sequenceBlockService; + + /** + * init(). + */ + @PostConstruct + public void init() { + logger.info("Initializing vehicleID counter map with " + + "capacity threshold of : {}", threshold); + vehicleIdCounterMap = Collections.synchronizedMap( + new LinkedHashMap(threshold, Constants.FLOAT_DECIMAL75, true) { + private static final long serialVersionUID = 1L; + + @Override + public boolean removeEldestEntry(Map.Entry eldest) { + return size() > threshold; + } + }); + } + + /** + * Get messageId for provided vechileId. If vehicleId is present in cache + * then generate message id using cached information else get + * data from MongoDB for provided vehicleId and generate. + * + * @param vehicleId + * for which message id need to be generated. + * @return String + */ + @Override + public String generateUniqueMsgId(String vehicleId) { + if (StringUtils.isEmpty(vehicleId)) { + logger.error("SequenceBlock can't be fetched as input value passed is empty or null"); + throw new InvalidSequenceBlockException("SequenceBlock can't be fetched as input value for" + + " vehicleID is passed as empty or null"); + } + SequenceBlock sequenceBlock = vehicleIdCounterMap.get(vehicleId); + if (Objects.isNull(sequenceBlock) || (sequenceBlock.getCurrentValue() >= sequenceBlock.getMaxValue())) { + sequenceBlock = getLastUpdatedValue(vehicleId); + } + int nextId = sequenceBlock.getCurrentValue() + 1; + sequenceBlock.setCurrentValue(nextId); + vehicleIdCounterMap.put(vehicleId, sequenceBlock); + logger.info("MessageId generated for vehicleId {} is {}", vehicleId, nextId); + return Integer.toString(nextId); + } + + /** + * Gets the last updated value. + * + * @param vehicleId the vehicle id + * @return the last updated value + */ + private SequenceBlock getLastUpdatedValue(String vehicleId) { + SequenceBlock sequenceBlock; + byte currentRetry = 0; + while ((sequenceBlock = sequenceBlockService.getMessageIdConfig(vehicleId)) == null + && currentRetry++ < retryCounter) { + logger.debug("Sleeping for {}, unable to fetch sequenceblock from dao " + "for vehicleId {}, " + + "in attempt number : {}", retryInterval, vehicleId, currentRetry); + sleep(retryInterval); + } + if (Objects.nonNull(sequenceBlock)) { + logger.debug("Fetched updated max value from mongo for vehicleId : {}, as : {}, and current value as {}", + vehicleId, sequenceBlock.getMaxValue(), sequenceBlock.getCurrentValue()); + return sequenceBlock; + } else { + logger.error("Unable to fetch updated max value from mongo for " + + "vehicleId: {}. Max retries exhausted.", vehicleId); + throw new InvalidSequenceBlockException("Unable to fetch updated max value from mongo " + + "for vehicleId: " + vehicleId); + } + } + + /** + * Sleep. + * + * @param val the val + */ + private void sleep(long val) { + try { + TimeUnit.MILLISECONDS.sleep(val); + } catch (InterruptedException e) { + logger.error("Thread: {} interrupted while retrying for getting data from mongodb", + Thread.currentThread().getName()); + Thread.currentThread().interrupt(); + } + } + + /** + * Gets the block value. + * + * @return the block value + */ + // Getter used for unit test cases + int getBlockValue() { + return blockValue; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/IdGenConstants.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/IdGenConstants.java new file mode 100644 index 0000000..905b395 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/IdGenConstants.java @@ -0,0 +1,62 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.idgen.internal; + +/** + * class {@link IdGenConstants}: constants file. + */ +public class IdGenConstants { + + /** The Constant SEQUENCE_BLOCK_TIMESTAMP_FIELD_NAME. */ + public static final String SEQUENCE_BLOCK_TIMESTAMP_FIELD_NAME = "timeStamp"; + + /** The Constant SEQUENCE_BLOCK_MAXVALUE_FIELD_NAME. */ + public static final String SEQUENCE_BLOCK_MAXVALUE_FIELD_NAME = "maxValue"; + + /** The Constant SEQUENCE_BLOCK_CURRVALUE_FIELD_NAME. */ + public static final String SEQUENCE_BLOCK_CURRVALUE_FIELD_NAME = "currentValue"; + + /** + * Instantiates a new id gen constants. + */ + private IdGenConstants() { + throw new IllegalStateException("IdGenConstants is a utility class, and cannot be instantiated."); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlock.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlock.java new file mode 100644 index 0000000..fba0c3f --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlock.java @@ -0,0 +1,148 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.idgen.internal; + +import dev.morphia.annotations.Entity; +import dev.morphia.annotations.Id; +import org.eclipse.ecsp.entities.AbstractIgniteEntity; + +/** + * class {@link SequenceBlock} extends {@link AbstractIgniteEntity}. + */ +@Entity(value = "sequenceBlock") +public class SequenceBlock extends AbstractIgniteEntity { + + /** The vehicle id. */ + @Id + private String vehicleId; + + /** The max value. */ + private int maxValue; + + /** The time stamp. */ + private long timeStamp; + + /** The current value. */ + private int currentValue; + + /** + * Gets the time stamp. + * + * @return the time stamp + */ + public long getTimeStamp() { + return timeStamp; + } + + /** + * Sets the time stamp. + * + * @param timeStamp the new time stamp + */ + public void setTimeStamp(long timeStamp) { + this.timeStamp = timeStamp; + } + + /** + * Gets the vehicle id. + * + * @return the vehicle id + */ + public String getVehicleId() { + return vehicleId; + } + + /** + * Sets the vehicle id. + * + * @param vehicleId the new vehicle id + */ + public void setVehicleId(String vehicleId) { + this.vehicleId = vehicleId; + } + + /** + * Gets the max value. + * + * @return the max value + */ + public int getMaxValue() { + return maxValue; + } + + /** + * Sets the max value. + * + * @param maxValue the new max value + */ + public void setMaxValue(int maxValue) { + this.maxValue = maxValue; + } + + /** + * Gets the current value. + * + * @return the current value + */ + public int getCurrentValue() { + return currentValue; + } + + /** + * Sets the current value. + * + * @param currentValue the new current value + */ + public void setCurrentValue(int currentValue) { + this.currentValue = currentValue; + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return "SequenceBlock [vehicleId=" + vehicleId + ", maxValue=" + maxValue + + ", timeStamp=" + timeStamp + ", currentValue=" + currentValue + "]"; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockDAO.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockDAO.java new file mode 100644 index 0000000..862477a --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockDAO.java @@ -0,0 +1,49 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.idgen.internal; + +import org.eclipse.ecsp.nosqldao.IgniteBaseDAO; + +/** + * interface SequenceBlockDAO extends IgniteBaseDAO. + */ +public interface SequenceBlockDAO extends IgniteBaseDAO { + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockDAOImpl.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockDAOImpl.java new file mode 100644 index 0000000..aac4ec7 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockDAOImpl.java @@ -0,0 +1,52 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.idgen.internal; + +import org.eclipse.ecsp.nosqldao.mongodb.IgniteBaseDAOMongoImpl; +import org.springframework.stereotype.Repository; + +/** + * class {@link SequenceBlockDAOImpl} extends {@link IgniteBaseDAOMongoImpl} + * implements {@link SequenceBlockDAO}. + */ +@Repository +public class SequenceBlockDAOImpl extends IgniteBaseDAOMongoImpl implements SequenceBlockDAO { + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockService.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockService.java new file mode 100644 index 0000000..5ef0ce5 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockService.java @@ -0,0 +1,54 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.idgen.internal; + +/** + * SequenceBlockService used to generate messageId block. + */ +public interface SequenceBlockService { + + /** + * Gets the message id config. + * + * @param vehicleId the vehicle id + * @return the message id config + */ + public SequenceBlock getMessageIdConfig(String vehicleId); +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockServiceImpl.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockServiceImpl.java new file mode 100644 index 0000000..ca92d8a --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockServiceImpl.java @@ -0,0 +1,198 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.idgen.internal; + +import com.mongodb.ReadPreference; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.nosqldao.IgniteCriteria; +import org.eclipse.ecsp.nosqldao.IgniteCriteriaGroup; +import org.eclipse.ecsp.nosqldao.IgniteQuery; +import org.eclipse.ecsp.nosqldao.Operator; +import org.eclipse.ecsp.nosqldao.Updates; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import java.util.List; + +/** + * SequenceBlockService used to generate messageId block Implementation. + */ +@Service +public class SequenceBlockServiceImpl implements SequenceBlockService { + + /** The block value. */ + @Value("${" + PropertyNames.SEQUENCE_BLOCK_MAXVALUE + ":10000}") + private int blockValue; + + /** The logger. */ + private IgniteLogger logger = IgniteLoggerFactory.getLogger(SequenceBlockServiceImpl.class); + + /** The sequence block dao. */ + @Autowired + private SequenceBlockDAO sequenceBlockDao; + + /** + * getMessageIdConfig(). + * + * @param vehicleId vehicleId + * @return SequenceBlock + */ + public SequenceBlock getMessageIdConfig(String vehicleId) { + SequenceBlock sb = findSequenceBlockById(vehicleId); + logger.info("SequenceBlock fetch from MongoDB for vehicleId {} is {}", vehicleId, sb); + if (null != sb) { + if ((Integer.MAX_VALUE - (sb.getMaxValue() + blockValue)) <= 0) { + resetCollection(vehicleId); + return insertSequenceBlock(vehicleId); + } + long currSeqBlockTimeStamp = sb.getTimeStamp(); + int updatedMaxValue = sb.getMaxValue() + blockValue; + sb.setCurrentValue(sb.getMaxValue()); + sb.setMaxValue(updatedMaxValue); + sb.setTimeStamp(System.currentTimeMillis()); + boolean isSequenceBlockUpdated = updateSequenceBlock(sb, currSeqBlockTimeStamp); + if (isSequenceBlockUpdated) { + logger.info("Updated and returning sequenceBlock for vehicleId {}, is {}", vehicleId, sb); + } else { + // This is to handle the case where document was updated by + // another thread, and the current thread needs to retry. + logger.info("No sequence block found for vehicleID {}, with " + + "maxValue < {}. Attempting again with increased max value.", + vehicleId, updatedMaxValue); + return null; + } + } else { + sb = insertSequenceBlock(vehicleId); + logger.info("Saved and returning sequenceBlock for vehicleId {}, is {}", vehicleId, sb); + } + return sb; + } + + /** + * Insert sequence block. + * + * @param vehicleId the vehicle id + * @return the sequence block + */ + private SequenceBlock insertSequenceBlock(String vehicleId) { + SequenceBlock sequenceBlock = createSequenceBlock(vehicleId, blockValue); + return sequenceBlockDao.save(sequenceBlock); + } + + /** + * Find sequence block by id. + * + * @param vehicleId the vehicle id + * @return the sequence block + */ + private SequenceBlock findSequenceBlockById(String vehicleId) { + IgniteCriteria filterByIdCriteria = new IgniteCriteria("_id", Operator.EQ, vehicleId); + IgniteCriteriaGroup igniteCriteriaGrp = new IgniteCriteriaGroup(filterByIdCriteria); + IgniteQuery igniteQuery = new IgniteQuery(igniteCriteriaGrp); + igniteQuery.setReadPreference(ReadPreference.primaryPreferred()); + List seqBlockList = sequenceBlockDao.find(igniteQuery); + if (!CollectionUtils.isEmpty(seqBlockList)) { + return seqBlockList.get(0); + } + return null; + } + + /** + * Update sequence block. + * + * @param sequenceBlock the sequence block + * @param currSeqBlockTimeStamp the curr seq block time stamp + * @return true, if successful + */ + private boolean updateSequenceBlock(SequenceBlock sequenceBlock, long currSeqBlockTimeStamp) { + String vehicleId = sequenceBlock.getVehicleId(); + IgniteCriteria filterByIdCriteria = new IgniteCriteria("_id", Operator.EQ, vehicleId); + IgniteCriteria equalTSCriteria = new IgniteCriteria(IdGenConstants + .SEQUENCE_BLOCK_TIMESTAMP_FIELD_NAME, Operator.EQ, + currSeqBlockTimeStamp); + IgniteCriteria lessThanMaxValueCriteria = new IgniteCriteria(IdGenConstants + .SEQUENCE_BLOCK_MAXVALUE_FIELD_NAME, Operator.LT, + sequenceBlock.getMaxValue()); + IgniteCriteriaGroup igniteCriteriaGrp = new IgniteCriteriaGroup(filterByIdCriteria) + .and(equalTSCriteria) + .and(lessThanMaxValueCriteria); + logger.debug("Attempting to update sequenceBlock for vehicleId {}" + + " with IgniteQueryGroup {}", vehicleId, igniteCriteriaGrp); + + Updates update = new Updates(); + update.addFieldSet(IdGenConstants.SEQUENCE_BLOCK_MAXVALUE_FIELD_NAME, sequenceBlock.getMaxValue()); + update.addFieldSet(IdGenConstants.SEQUENCE_BLOCK_CURRVALUE_FIELD_NAME, sequenceBlock.getCurrentValue()); + update.addFieldSet(IdGenConstants.SEQUENCE_BLOCK_TIMESTAMP_FIELD_NAME, sequenceBlock.getTimeStamp()); + IgniteQuery igniteQuery = new IgniteQuery(igniteCriteriaGrp); + return sequenceBlockDao.update(igniteQuery, update); + } + + /** + * Creates the sequence block. + * + * @param vehicleId the vehicle id + * @param maxValue the max value + * @return the sequence block + */ + private SequenceBlock createSequenceBlock(String vehicleId, int maxValue) { + SequenceBlock seqBlock = new SequenceBlock(); + seqBlock.setVehicleId(vehicleId); + seqBlock.setTimeStamp(System.currentTimeMillis()); + seqBlock.setMaxValue(maxValue); + seqBlock.setCurrentValue(0); + return seqBlock; + } + + /** + * Reset collection. + * + * @param vehicleId the vehicle id + */ + private void resetCollection(String vehicleId) { + logger.info("Resetting sequenceBlock as max value for vehicleId {} " + + "has crossed {}", vehicleId, Integer.MAX_VALUE); + sequenceBlockDao.deleteById(vehicleId); + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/ShortCounterIdPartGenerator.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/ShortCounterIdPartGenerator.java new file mode 100644 index 0000000..48e1843 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/ShortCounterIdPartGenerator.java @@ -0,0 +1,82 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.idgen.internal; + +import org.eclipse.ecsp.analytics.stream.base.idgen.MessageIdPartGenerator; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.stereotype.Component; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * class ShortCounterIdPartGenerator implements MessageIdPartGenerator. + */ +@Component +public class ShortCounterIdPartGenerator implements MessageIdPartGenerator { + + /** The msg id suffix. */ + private static AtomicInteger msgIdSuffix = new AtomicInteger(0); + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(ShortCounterIdPartGenerator.class); + + /** + * Gets the msg id suffix. + * + * @return the msg id suffix + */ + public static AtomicInteger getMsgIdSuffix() { + return msgIdSuffix; + } + + /** + * generateIdPart(). + * + * @param serviceName serviceName + * @return String + */ + public String generateIdPart(String serviceName) { + msgIdSuffix.compareAndSet(Short.MAX_VALUE, 0); + int suffix = msgIdSuffix.incrementAndGet(); + logger.debug("Short Counter generated the ShortCounterIdPartGenerator {}", suffix); + return String.valueOf(suffix); + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/ShortHashCodeIdPartGenerator.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/ShortHashCodeIdPartGenerator.java new file mode 100644 index 0000000..677a918 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/ShortHashCodeIdPartGenerator.java @@ -0,0 +1,98 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.idgen.internal; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.idgen.MessageIdPartGenerator; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.stereotype.Component; + +/** + * class ShortHashCodeIdPartGenerator implements MessageIdPartGenerator. + */ +@Component +public class ShortHashCodeIdPartGenerator implements MessageIdPartGenerator { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(ShortHashCodeIdPartGenerator.class); + + /** + * generateIdPart(). + * + * @param input input + * @return String + */ + public String generateIdPart(String input) { + logger.debug("Input String to generate Short hashCode is {}", input); + if (StringUtils.isEmpty(input)) { + throw new IllegalArgumentException("Input String cannot be empty for generating hashcode"); + } + + String hc = null; + try { + hc = String.valueOf(generateShortHashCode(input)); + } catch (Exception e) { + logger.error("Error while generating hashcode with input {}", input); + } + return hc; + } + + /** + * Generate short hash code. + * + * @param serviceName the service name + * @return the short + */ + private short generateShortHashCode(String serviceName) { + int serviceHc = serviceName.hashCode(); + + // to convert int to short + // first XOR high 16 bits with the low 16 bits (helps in spreading + // entropy), and + // & with Short.MAX_VALUE for getting positive value + short shortHashCode = (short) ((serviceHc ^ (serviceHc >>> Constants.INT_16)) & Short.MAX_VALUE); + logger.debug("For ServiceName {}, hash code is {}, short hashcode is {} ", serviceName, serviceHc, + shortHashCode); + return shortHashCode; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaConsumer.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaConsumer.java new file mode 100644 index 0000000..e669b32 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaConsumer.java @@ -0,0 +1,1017 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka.internal; + +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.common.PartitionInfo; +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.common.errors.WakeupException; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.KafkaStreams.State; +import org.eclipse.ecsp.analytics.stream.base.KafkaSslConfig; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.exception.BackdoorKafkaConsumerException; +import org.eclipse.ecsp.analytics.stream.base.exception.InvalidKeyOrValueException; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.ObjectUtils; +import org.eclipse.ecsp.analytics.stream.base.utils.ThreadUtils; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.healthcheck.HealthMonitor; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.transform.GenericIgniteEventTransformer; +import org.eclipse.ecsp.transform.IgniteKeyTransformer; +import org.eclipse.ecsp.transform.Transformer; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; + +import java.lang.reflect.InvocationTargetException; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.eclipse.ecsp.analytics.stream.base.utils.Constants.CANNOT_BE_EMPTY; + +/** + * BackdoorKafkaConsumer is a Kafka consumer + * It subscribes to the topic provided in the properties. + * serviceCallBack instance is provided by the respective SPs for accessing the Kafka consumer records. + * ConsumerRecords{@code <}byte[], byte[]{@code >} is transformed to + * IgniteKey and IgniteEvent with the help of transformers instantiated based on the + * properties that has been set. + * + * @author avadakkootko + */ + +public abstract class BackdoorKafkaConsumer implements HealthMonitor { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(BackdoorKafkaConsumer.class); + + /** The consumer. */ + protected KafkaConsumer consumer = null; + + /** The value transformer. */ + private GenericIgniteEventTransformer valueTransformer = new GenericIgniteEventTransformer(); + + /** The key transformer. */ + private IgniteKeyTransformer keyTransformer; + + /** The poll. */ + private long poll; + + /** The Constant DEFAULT_POLL_VALUE. */ + private static final long DEFAULT_POLL_VALUE = 50L; + // RTC-155383 - Running Kafka and Zookeeper on dynamic ports to resolve + /** The Constant OVERRIDDEN_BOOT_STRAP_SERVER. */ + // bind address issue in streambase project while running test cases + public static final String OVERRIDDEN_BOOT_STRAP_SERVER = null; + + /** The closed. */ + protected final AtomicBoolean closed = new AtomicBoolean(false); + + /** The call back map. */ + protected ConcurrentHashMap callBackMap = new ConcurrentHashMap<>(); + + /** The persist offset map. */ + private ConcurrentHashMap persistOffsetMap = new ConcurrentHashMap<>(); + + // startedConsumer ensures that for the same service backdoor kafka consumer + // is not re started multiple times. Earlier BackDoor kafka consumer factory + /** The started consumer. */ + // used to handle it. + protected final AtomicBoolean startedConsumer = new AtomicBoolean(false); + + /** The healthy. */ + private final AtomicBoolean healthy = new AtomicBoolean(false); + + /** The kafka consumer run executor. */ + protected ExecutorService kafkaConsumerRunExecutor; + + /** The offsets mgmt executor. */ + protected ScheduledExecutorService offsetsMgmtExecutor = null; + + /** The ignite key transformer impl. */ + @Value("${" + PropertyNames.IGNITE_KEY_TRANSFORMER + ":}") + private String igniteKeyTransformerImpl; + + /** The kafka bootstrap servers. */ + @Value("${" + PropertyNames.BOOTSTRAP_SERVERS + ":}") + private String kafkaBootstrapServers; + + /** The kafka ssl enable. */ + @Value("${" + PropertyNames.KAFKA_SSL_ENABLE + ":false}") + private boolean kafkaSslEnable; + + /** The kafka one way tls enable. */ + @Value("${" + PropertyNames.KAFKA_ONE_WAY_TLS_ENABLE + ":false}") + private boolean kafkaOneWayTlsEnable; + + /** The keystore. */ + @Value("${" + PropertyNames.KAFKA_CLIENT_KEYSTORE + ":}") + private String keystore; + + /** The keystore pwd. */ + @Value("${" + PropertyNames.KAFKA_CLIENT_KEYSTORE_PASSWORD + ":}") + private String keystorePwd; + + /** The key pwd. */ + @Value("${" + PropertyNames.KAFKA_CLIENT_KEY_PASSWORD + ":}") + private String keyPwd; + + /** The truststore. */ + @Value("${" + PropertyNames.KAFKA_CLIENT_TRUSTSTORE + ":}") + private String truststore; + + /** The sasl mechanism. */ + @Value("${" + PropertyNames.KAFKA_SASL_MECHANISM + ":}") + private String saslMechanism; + + /** The sasl jaas config. */ + @Value("${" + PropertyNames.KAFKA_SASL_JAAS_CONFIG + ":}") + private String saslJaasConfig; + + /** The ssl endpoint algo. */ + @Value("${" + PropertyNames.KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM + ":}") + private String sslEndpointAlgo; + + /** The truststore pwd. */ + @Value("${" + PropertyNames.KAFKA_CLIENT_TRUSTSTORE_PASSWORD + ":}") + private String truststorePwd; + + /** The ssl client auth. */ + @Value("${" + PropertyNames.KAFKA_SSL_CLIENT_AUTH + ":}") + private String sslClientAuth; + + /** The convert topic to lower case. */ + @Value("${" + PropertyNames.CONVERT_BACKDOOR_KAFKA_TOPIC_TO_LOWERCASE + ":true}") + private boolean convertTopicToLowerCase; + + /** The max poll interval ms. */ + @Value("${" + PropertyNames.BACKDOOR_KAFKA_MAX_POLL_INTERVAL_MS + ":600000}") + private int maxPollIntervalMs; + + /** The request timeout ms. */ + @Value("${" + PropertyNames.BACKDOOR_KAFKA_REQUEST_TIMEOUT_MS + ":605000}") + private int requestTimeoutMs; + + /** The session timeout ms. */ + @Value("${" + PropertyNames.BACKDOOR_KAFKA_SESSION_TIMEOUT_MS + ":250000}") + private int sessionTimeoutMs; + + /** The max restart attempts. */ + @Value("${" + PropertyNames.BACKDOOR_KAFKA_MAX_RESTART_ATTEMPTS + ":5}") + private int maxRestartAttempts; + + /** The restart attempt reset interval min. */ + @Value("${" + PropertyNames.BACKDOOR_KAFKA_ATTEMPTS_RESET_INTERVAL_MIN + ":30}") + private int restartAttemptResetIntervalMin; + + /** The enable auto commit. */ + @Value("${" + PropertyNames.BACKDOOR_KAFKA_ENABLE_AUTO_COMMIT + ":false}") + private boolean enableAutoCommit; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The offset persistence delay. */ + @Value("${" + PropertyNames.BACKDOOR_KAFKA_OFFSET_PERSISTENCE_DELAY + ":60000}") + private int offsetPersistenceDelay; + + /** The connection msg value transformer. */ + @Value("${" + PropertyNames.DMA_CONNECTION_MSG_VALUE_TRANSFORMER + ":}") + private String connectionMsgValueTransformer; + + /** The connection msg key transformer. */ + @Value("${" + PropertyNames.DMA_CONNECTION_MSG_KEY_TRANSFORMER + ":}") + private String connectionMsgKeyTransformer; + + /** The default api timeout ms. */ + @Value("${" + PropertyNames.BACKDOOR_KAFKA_CONSUMER_DEFAULT_API_TIMEOUT_MS + ":60000}") + private int defaultApiTimeoutMs; + + /** The connections max idle ms. */ + @Value("${" + PropertyNames.CONNECTIONS_MAX_IDLE_MS + ":-1}") + private int connectionsMaxIdleMs; + + /** The client id. */ + @Value("${" + PropertyNames.CLIENT_ID + ":}") + private String clientId; + + /** The payload value transformer. */ + private Transformer payloadValueTransformer = null; + + /** The payload key transformer. */ + private IgniteKeyTransformer payloadKeyTransformer = null; + + /** The topic offset dao. */ + @Autowired + private BackdoorKafkaTopicOffsetDAOMongoImpl topicOffsetDao; + + /** The ctx. */ + @Autowired + private ApplicationContext ctx; + + /** The kafka ssl config. */ + @Autowired + private KafkaSslConfig kafkaSslConfig; + + /** The kafka consumer props. */ + private Properties kafkaConsumerProps; + + /** The name. */ + private String name; + + /** The kafka consumer topic. */ + private String kafkaConsumerTopic; + + /** The restart attempts. */ + private AtomicInteger restartAttempts = new AtomicInteger(0); + + /** The previous restart. */ + private long previousRestart = 0L; + + /** The kafka admin client. */ + private AdminClient kafkaAdminClient = null; + + /** + * Gets the kafka consumer props. + * + * @return the kafka consumer props + */ + public Properties getKafkaConsumerProps() { + return kafkaConsumerProps; + } + + /** + * Sets the ignite key transformer impl. + * + * @param igniteKeyTransformerImpl the new ignite key transformer impl + */ + public void setIgniteKeyTransformerImpl(String igniteKeyTransformerImpl) { + this.igniteKeyTransformerImpl = igniteKeyTransformerImpl; + } + + /** + * Sets the kafka bootstrap servers. + * + * @param kafkaBootstrapServers the new kafka bootstrap servers + */ + public void setKafkaBootstrapServers(String kafkaBootstrapServers) { + this.kafkaBootstrapServers = kafkaBootstrapServers; + } + + /** + * Gets the name. + * + * @return the name + */ + public abstract String getName(); + + /** + * Gets the kafka consumer group id. + * + * @return the kafka consumer group id + */ + public abstract String getKafkaConsumerGroupId(); + + /** + * Gets the kafka consumer topic. + * + * @return the kafka consumer topic + */ + public abstract String getKafkaConsumerTopic(); + + /** + * Sets the kafka consumer topic. + * + * @param kafkaConsumerTopic the new kafka consumer topic + */ + protected void setKafkaConsumerTopic(String kafkaConsumerTopic) { + this.kafkaConsumerTopic = kafkaConsumerTopic; + } + + /** + * Gets the poll. + * + * @return the poll + */ + public abstract long getPoll(); + + // The reset flag is set to false after the initial reset of offset. So that + /** + * Checks if is offsets reset complete. + * + * @return true, if is offsets reset complete + */ + // its not reset for each execution. + public abstract boolean isOffsetsResetComplete(); + + /** + * Sets the reset offsets. + * + * @param reset the new reset offsets + */ + public abstract void setResetOffsets(boolean reset); + + /** + * Gets the stream state. + * + * @return the stream state + */ + public abstract State getStreamState(); + + /** + * Sets the stream state. + * + * @param newState the new stream state + */ + public abstract void setStreamState(State newState); + + /** + * initialize properties. + */ + @PostConstruct + public void initializeProperties() { + kafkaConsumerProps = new Properties(); + if (StringUtils.isEmpty(igniteKeyTransformerImpl)) { + throw new IllegalArgumentException(PropertyNames.IGNITE_KEY_TRANSFORMER + CANNOT_BE_EMPTY); + } + try { + keyTransformer = (IgniteKeyTransformer) getClass().getClassLoader().loadClass(igniteKeyTransformerImpl) + .getDeclaredConstructor().newInstance(); + } catch (InstantiationException | NoSuchMethodException | ClassNotFoundException | IllegalAccessException + | InvocationTargetException e) { + throw new IllegalArgumentException( + PropertyNames.IGNITE_KEY_TRANSFORMER + " refers to a class that is not available on the classpath"); + } + if (StringUtils.isNotEmpty(connectionMsgValueTransformer)) { + payloadValueTransformer = (Transformer) getInstance(connectionMsgValueTransformer); + logger.info("Class {} loaded as connection status value transformer", connectionMsgValueTransformer); + } + + if (StringUtils.isNotEmpty(connectionMsgKeyTransformer)) { + payloadKeyTransformer = (IgniteKeyTransformer) getInstance(connectionMsgKeyTransformer); + logger.info("Class {} loaded as connection status value transformer", connectionMsgKeyTransformer); + } + + if (StringUtils.isEmpty(kafkaBootstrapServers)) { + throw new IllegalArgumentException(PropertyNames.BOOTSTRAP_SERVERS + CANNOT_BE_EMPTY); + } + kafkaConsumerProps.setProperty(PropertyNames.BOOTSTRAP_SERVERS, kafkaBootstrapServers); + kafkaConsumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().deserializer().getClass().getName()); + kafkaConsumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().deserializer().getClass().getName()); + kafkaConsumerProps.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, maxPollIntervalMs); + kafkaConsumerProps.put(ConsumerConfig.REQUEST_TIMEOUT_MS_CONFIG, requestTimeoutMs); + kafkaConsumerProps.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, sessionTimeoutMs); + kafkaConsumerProps.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, enableAutoCommit); + kafkaConsumerProps.put(ConsumerConfig.DEFAULT_API_TIMEOUT_MS_CONFIG, defaultApiTimeoutMs); + kafkaConsumerProps.put(ConsumerConfig.CONNECTIONS_MAX_IDLE_MS_CONFIG, connectionsMaxIdleMs); + if (StringUtils.isNotBlank(clientId)) { + kafkaConsumerProps.put(ConsumerConfig.CLIENT_ID_CONFIG, clientId); + } + + kafkaSslConfig.setSslPropsIfEnabled(kafkaConsumerProps); + + if (this.kafkaAdminClient == null) { + this.kafkaAdminClient = AdminClient.create(kafkaConsumerProps); + } + } + + /** + * Gets the single instance of BackdoorKafkaConsumer. + * + * @param className the class name + * @return single instance of BackdoorKafkaConsumer + */ + private Object getInstance(String className) { + Class classObject = null; + Object instance = null; + try { + classObject = getClass().getClassLoader().loadClass(className); + instance = ctx.getBean(classObject); + } catch (Exception ex) { + try { + if (classObject == null) { + throw new IllegalArgumentException("Could not load the class " + className); + } + instance = classObject.getDeclaredConstructor().newInstance(); + } catch (Exception exception) { + String msg = String.format("Class %s could not be loaded. Not found on classpath.%n", + className); + logger.error(msg + ExceptionUtils.getStackTrace(exception)); + throw new IllegalArgumentException(msg); + } + } + return instance; + } + + /** + * Poll from the subscribed topic. And commits the offset values. + * ConsumerRecords{@code <}byte[], byte[]{@code >} is transformed in to IgniteKey, IgniteEvent. + */ + private void runConsumer() { + startedConsumer.set(true); + try { + while (!closed.get()) { + ConsumerRecords consumerRecords = consumer.poll(Duration.ofMillis(poll)); + healthy.set(true); + if (!consumerRecords.isEmpty()) { + if (isOffsetsResetComplete()) { + resetKafkaConsumerOffset(); + } + + consumerRecords.forEach(this::processCallBack); + consumer.commitSync(); + } + } + + } catch (WakeupException e) { + healthy.set(false); + logger.error("WakeupException in BackDoor Kafka Consumer", e); + if (!closed.get()) { + throw e; + } + consumer.close(); + } catch (Exception e) { + healthy.set(false); + logger.error("Unhandled BackDoor Kafka Consumer error !!! ", e); + if (getStreamState() == State.RUNNING) { + logger.info( + "Attempting to shutdown and restart Backdoor Kafka Consumer, " + + "as an exception occured and Kafka Streams is in RUNNING state"); + restartKafkaBackDoorConsumer(); + } else { + logger.error("Closing Backdoor Kafka Consumer, Unhandled exception " + + "occured and as Kafka Streams is not RUNNING"); + shutdownWithOutWakeup(); + } + } + } + + /** + * Process call back. + * + * @param consumerRecord the consumer record + */ + private void processCallBack(ConsumerRecord consumerRecord) { + byte[] key = consumerRecord.key(); + byte[] value = consumerRecord.value(); + int partition = consumerRecord.partition(); + long offset = consumerRecord.offset(); + try { + BackdoorKafkaConsumerCallback callBack = callBackMap.get(partition); + if (callBack != null) { + processCallBack(consumerRecord, key, value, partition, offset, callBack); + } else { + logger.trace("Partition {}, not part of current stream thread", partition); + } + + } catch (Exception e) { + logger.error("Error occured while invoking callback {}", e); + } + } + + /** + * Process call back. + * + * @param consumerRecord the consumer record + * @param key the key + * @param value the value + * @param partition the partition + * @param offset the offset + * @param callBack the call back + */ + private void processCallBack(ConsumerRecord consumerRecord, byte[] key, byte[] value, + int partition, long offset, BackdoorKafkaConsumerCallback callBack) { + IgniteKey igniteKey = transformKey(key); + IgniteEvent igniteEvent = transformValue(value); + if (igniteKey == null || igniteEvent == null) { + String msg = "Either Key or Value of connection status message could not be transformed. " + + "No further processing will be done for this message."; + throw new InvalidKeyOrValueException(msg); + } + OffsetMetadata meta = new OffsetMetadata(new TopicPartition(consumerRecord.topic(), partition), + consumerRecord.offset()); + String persistOffsetKey = getKey(kafkaConsumerTopic, partition); + logger.debug("Forward to serviceCallBack - Key : {}, Value : {}, Offset : {}, Partition : {}", igniteKey, + igniteEvent, consumerRecord.offset(), consumerRecord.partition()); + callBack.process(igniteKey, igniteEvent, meta); + BackdoorKafkaTopicOffset backdoorKafkaTopicOffset = persistOffsetMap.get(persistOffsetKey); + if (backdoorKafkaTopicOffset == null) { + persistOffsetMap.put(persistOffsetKey, + new BackdoorKafkaTopicOffset(kafkaConsumerTopic, partition, offset)); + } else { + backdoorKafkaTopicOffset.setOffset(offset); + } + } + + /** + * Transform key. + * + * @param key the key + * @return the ignite key + */ + private IgniteKey transformKey(byte[] key) { + if (this.payloadKeyTransformer != null) { + logger.debug("Transforming the key part of connection message using {}", this.connectionMsgKeyTransformer); + return this.payloadKeyTransformer.fromBlob(key); + } else { + logger.debug("Transforming the key part of connection message using {}", this.igniteKeyTransformerImpl); + return this.keyTransformer.fromBlob(key); + } + } + + /** + * Transform value. + * + * @param value the value + * @return the ignite event + */ + private IgniteEvent transformValue(byte[] value) { + if (this.payloadValueTransformer != null) { + logger.debug("Transforming the value part of connection message using {}", connectionMsgValueTransformer); + return this.payloadValueTransformer.fromBlob(value, Optional.empty()); + } else { + logger.debug("Transforming the value part of connection " + + "message using default GenericIgniteEventTransformer"); + return this.valueTransformer.fromBlob(value, Optional.empty()); + } + } + + /** + * Gets the key. + * + * @param topic the topic + * @param partition the partition + * @return the key + */ + protected String getKey(String topic, int partition) { + return topic + ":" + partition; + } + + /** + * Reset kafka consumer offset. + */ + protected void resetKafkaConsumerOffset() { + Map topicOffsetMap = getTopicOffsetMap(); + + logger.info("Attempting to reset offset for backdoor kafka consumer topic - {}", kafkaConsumerTopic); + List partitions = consumer.partitionsFor(kafkaConsumerTopic); + List topicPartitions = partitions.stream() + .map(p -> new TopicPartition(kafkaConsumerTopic, p.partition())) + .toList(); + + Map endOffsetMap = consumer.endOffsets(topicPartitions); + Map beginningOffsetMap = consumer.beginningOffsets(topicPartitions); + // Below set contains the partitions that are ASSIGNED to this consumer + // for this kafkaConsumerTopic. + Set assignedPartitions = new HashSet<>(); + Set partitionSet = consumer.assignment(); + for (TopicPartition topicPartition : partitionSet) { + assignedPartitions.add(topicPartition.partition()); + } + + for (TopicPartition topicPartition : topicPartitions) { + // Skip offset reset for the partitions whose messages are not meant + // to be processed by this consumer. + if (!assignedPartitions.contains(topicPartition.partition()) + && !this.callBackMap.containsKey(topicPartition.partition())) { + logger.debug("Skipping offset reset for partition: {}", + topicPartition.partition()); + continue; + } + int partition = topicPartition.partition(); + long endOffset = endOffsetMap.get(topicPartition); + long beginningOffset = beginningOffsetMap.get(topicPartition); + + Long offsetToSeek = topicOffsetMap.get(partition); + + if (offsetToSeek != null && offsetToSeek <= endOffset && offsetToSeek >= beginningOffset) { + consumer.seek(topicPartition, offsetToSeek); + logger.info( + "Reset offset to {} for topic {} and partition {} with beginningOffset {} and endOffset {}", + offsetToSeek, kafkaConsumerTopic, partition, beginningOffset, endOffset); + } else if (offsetToSeek == null) { + // offsetToSeek == null implies this instance of + // stream processor is not responsible for this + // partition. So its ok to seek to beginning. + consumer.seekToEnd(Collections.singletonList(topicPartition)); + logger.info( + "Reset to offset to end as seek offset was {} for " + + "topic {} and partition {} with beginningOffset {} and endOffset {}", + offsetToSeek, kafkaConsumerTopic, partition, beginningOffset, endOffset); + } else { + // offsetToSeek > endOffset can only happen if + // someone deletes the topic. Hence seek to + // beginning. + + // offsetToSeek < beginningOffset is rare but it can + // happen if the sp was not up for a very long + // duration say 3 days and kafka retention was 2 + // days. Hence seek to beginning. + consumer.seekToBeginning(Collections.singletonList(topicPartition)); + logger.info("Reset to offset to beginning as seek offset was {} for " + "topic {} " + + "and partition {} with beginningOffset {} and endOffset {}", + offsetToSeek, kafkaConsumerTopic, partition, beginningOffset, endOffset); + } + setResetOffsets(false); + } + } + + /** + * Gets the topic offset map. + * + * @return the topic offset map + */ + protected Map getTopicOffsetMap() { + Map map = new HashMap<>(); + List topicOffsetList = topicOffsetDao.getTopicOffsetList(kafkaConsumerTopic); + for (BackdoorKafkaTopicOffset topicOffset : topicOffsetList) { + if (callBackMap.containsKey(topicOffset.getPartition())) { + map.put(topicOffset.getPartition(), topicOffset.getOffset()); + } + } + return map; + } + + /** + * Restart kafka back door consumer. + */ + // This method is invoked when BackDoor kafka consumer has an exception + private void restartKafkaBackDoorConsumer() { + shutdownWithOutWakeup(); + if ((restartAttempts.get() <= maxRestartAttempts)) { + startBackDoorKafkaConsumer(); + restartAttempts.incrementAndGet(); + + // reset number of attempts to zero if interval between two restarts + // is + // > restartAttemptResetIntervalMin + long currentTime = System.currentTimeMillis(); + long diff = currentTime - previousRestart; + diff = diff / Constants.THREAD_SLEEP_TIME_60000; + if (diff > restartAttemptResetIntervalMin) { + restartAttempts = new AtomicInteger(0); + } + previousRestart = currentTime; + } else { + logger.info("BackDoor restart attempts has exceeded {}. " + + "Shutting down Stream Processor", maxRestartAttempts); + System.exit(1); + } + } + + /** + * Shutdown with out wakeup. + */ + // This method is invoked when BackDoor kafka consumer has an exception + private void shutdownWithOutWakeup() { + try { + consumer.close(); + } catch (Exception e) { + logger.error("Error while trying to close backdoor kafka consumer", e); + } finally { + closed.set(true); + startedConsumer.set(false); + ThreadUtils.shutdownExecutor(kafkaConsumerRunExecutor, Constants.THREAD_SLEEP_TIME_10000, false); + ThreadUtils.shutdownExecutor(offsetsMgmtExecutor, Constants.THREAD_SLEEP_TIME_10000, false); + logger.info("Closed Backdoor Kafka Consumer"); + } + } + + /** + * This method starts the kafka consumer by invoking runConsumer(). + */ + public void startBackDoorKafkaConsumer() { + String kafkaConsumerGroupId; + if (!startedConsumer.get()) { + name = getName(); + if (this.callBackMap.isEmpty()) { + logger.error("Callback map for service {} is found to be empty. " + + "Backdoor kafka consumer will not be started.", name); + return; + } + + kafkaConsumerTopic = getKafkaConsumerTopic(); + if (convertTopicToLowerCase) { + kafkaConsumerTopic = kafkaConsumerTopic.toLowerCase(); + } + kafkaConsumerGroupId = getKafkaConsumerGroupId(); + poll = getPoll(); + + closed.set(false); + ObjectUtils.requireNonEmpty(name, "BackDoor Kafka Consumer name must be provided"); + ObjectUtils.requireNonEmpty(kafkaConsumerTopic, "Kafka Consumer topic must be provided"); + if (poll <= 0) { + poll = DEFAULT_POLL_VALUE; + logger.info("Poll value being changed to default value {} for {}", DEFAULT_POLL_VALUE, name); + } + ObjectUtils.requireNonEmpty(kafkaConsumerGroupId, "Group Id cannot be null for Kafka Consumer"); + kafkaConsumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, kafkaConsumerGroupId); + + consumer = new KafkaConsumer<>(kafkaConsumerProps); + logger.info("Backdoor Kafka Consumer initialized with properties : {}", kafkaConsumerProps); + + consumer.subscribe(Collections.singletonList(kafkaConsumerTopic)); + logger.info("Kafka consumer group {} subscribed to topic {}", kafkaConsumerGroupId, kafkaConsumerTopic); + + kafkaConsumerRunExecutor = Executors.newFixedThreadPool(1, getThreadFactory(name + "-kafkaConsumerDt")); + + kafkaConsumerRunExecutor.execute(() -> { + try { + logger.info("Running backdoor kafka consumer ... :"); + runConsumer(); + } catch (Exception e) { + throw new BackdoorKafkaConsumerException("Exception occurred in backdoor kafka consumer", e); + } + }); + + offsetsMgmtExecutor = Executors.newSingleThreadScheduledExecutor(getThreadFactory(name + "-topicOffsetDt")); + + offsetsMgmtExecutor.scheduleWithFixedDelay(new Runnable() { + @Override + public void run() { + try { + persistOffset(); + } catch (Exception e) { + logger.error("Error occured while persisting offset to database by backdoor consumer", e); + } + } + + private void persistOffset() { + persistForEachTopicOffSet(); + } + }, Constants.THIRTY_THOUSAND, offsetPersistenceDelay, TimeUnit.MILLISECONDS); + } else { + logger.warn("BackDoor Kafka Consumer already started for service " + name + ". Cannot Restart again !!!"); + } + } + + /** + * Persist for each topic off set. + */ + private void persistForEachTopicOffSet() { + for (BackdoorKafkaTopicOffset topicOffset : persistOffsetMap.values()) { + try { + topicOffsetDao.save(new BackdoorKafkaTopicOffset(topicOffset)); + logger.debug("Persisted kafka topic offset to database. {}", topicOffset.toString()); + } catch (Exception e) { + logger.error("Error occured while persisting offset {} by backdoor consumer with exception:", + topicOffset.toString(), e); + } + } + } + + /** + * Gets the thread factory. + * + * @param threadName the thread name + * @return the thread factory + */ + private ThreadFactory getThreadFactory(String threadName) { + return runnable -> { + Thread thread = new Thread(runnable); + thread.setName(threadName); + thread.setDaemon(true); + thread.setUncaughtExceptionHandler((thread1, t) -> + logger.error("Uncaught exception detected! . Exception is: {}", t)); + return thread; + }; + } + + /** + * Shut down kafka consumer. This method is invoked when Stream closes. + */ + public void shutdown() { + if (getStartedConsumer().get() && !getClosed().get()) { + closed.set(true); + startedConsumer.set(false); + if (consumer != null) { + consumer.wakeup(); + } + + // RTC-192213 - Added to clear the cache held by + // IntegrationFeedCacheUpdateCallBack. More specifically added to + // ensure that the third party kafka broker producers are flushed + // before the application shuts down. This will ensure that the data + // are flushed immediately in case of kafka batching. + callBackMap.forEach((k, v) -> v.close()); + + callBackMap.clear(); + logger.info("Cleared Callback map"); + + ThreadUtils.shutdownExecutor(kafkaConsumerRunExecutor, Constants.THREAD_SLEEP_TIME_10000, false); + ThreadUtils.shutdownExecutor(offsetsMgmtExecutor, Constants.THREAD_SLEEP_TIME_10000, false); + removeConsumerGroup(getKafkaConsumerGroupId()); + logger.info("Closed Backdoor Kafka Consumer"); + + } + } + + /** + * Removes the consumer group. + * + * @param consumerGroupId the consumer group id + */ + protected void removeConsumerGroup(String consumerGroupId) { + logger.info("Group ID received to be removed is: {}", consumerGroupId); + if (!StringUtils.isEmpty(consumerGroupId)) { + kafkaAdminClient.deleteConsumerGroups(Arrays.asList(consumerGroupId)); + logger.debug("Removed {} consumer group from cluster.", consumerGroupId); + } + } + + /** + * Gets the kafka consumer run executor. + * + * @return the kafka consumer run executor + */ + // Setters for unit test + protected ExecutorService getKafkaConsumerRunExecutor() { + return kafkaConsumerRunExecutor; + } + + /** + * Sets the kafka consumer run executor. + * + * @param kafkaConsumerRunExecutor the new kafka consumer run executor + */ + protected void setKafkaConsumerRunExecutor(ExecutorService kafkaConsumerRunExecutor) { + this.kafkaConsumerRunExecutor = kafkaConsumerRunExecutor; + } + + /** + * Gets the offsets mgmt executor. + * + * @return the offsets mgmt executor + */ + protected ScheduledExecutorService getOffsetsMgmtExecutor() { + return offsetsMgmtExecutor; + } + + /** + * Sets the offsets mgmt executor. + * + * @param offsetsMgmtExecutor the new offsets mgmt executor + */ + protected void setOffsetsMgmtExecutor(ScheduledExecutorService offsetsMgmtExecutor) { + this.offsetsMgmtExecutor = offsetsMgmtExecutor; + } + + /** + * Gets the closed. + * + * @return the closed + */ + protected AtomicBoolean getClosed() { + return closed; + } + + /** + * Gets the started consumer. + * + * @return the started consumer + */ + protected AtomicBoolean getStartedConsumer() { + return startedConsumer; + } + + /** + * Gets the kafka admin client. + * + * @return the kafka admin client + */ + protected AdminClient getKafkaAdminClient() { + return kafkaAdminClient; + } + + /** + * Sets the kafka admin client. + * + * @param kafkaAdminClient the new kafka admin client + */ + protected void setKafkaAdminClient(AdminClient kafkaAdminClient) { + this.kafkaAdminClient = kafkaAdminClient; + } + + /** + * addCallback(). + * + * @param callBack callBack + * @param partition partition + */ + public void addCallback(BackdoorKafkaConsumerCallback callBack, int partition) { + callBackMap.put(partition, callBack); + logger.info("Adding Call back for service {}, for partition {} " + "and current size of map is {}", + getName(), partition, callBackMap.size()); + } + + /** + * Sets the consumer. + * + * @param consumer the consumer + */ + protected void setConsumer(KafkaConsumer consumer) { + this.consumer = consumer; + } + + /** + * Checks if is healthy. + * + * @param forceHealthCheck the force health check + * @return true, if is healthy + */ + @Override + public boolean isHealthy(boolean forceHealthCheck) { + State currState = getStreamState(); + if (currState != null && currState != State.RUNNING) { + return true; + } + return healthy.get(); + } + + /** + * Sets the connection msg value transformer. + * + * @param connectionMsgValueTransformer the new connection msg value transformer + */ + // The below setter and getter are for test cases + public void setConnectionMsgValueTransformer(String connectionMsgValueTransformer) { + this.connectionMsgValueTransformer = connectionMsgValueTransformer; + } + + /** + * Gets the payload value transformer. + * + * @return the payload value transformer + */ + public Transformer getPayloadValueTransformer() { + return payloadValueTransformer; + } + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaConsumerCallback.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaConsumerCallback.java new file mode 100644 index 0000000..ad87a68 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaConsumerCallback.java @@ -0,0 +1,78 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka.internal; + +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; + +import java.util.Optional; + +/** + * Stream Processors that needs to access records from kafka back door consumer, + * should implement this interface. It acts a call back + * mechanism. + * + * @author avadakkootko + */ +public interface BackdoorKafkaConsumerCallback { + + /** + * Process. + * + * @param key the key + * @param value the value + * @param meta the meta + */ + public void process(IgniteKey key, IgniteEvent value, OffsetMetadata meta); + + /** + * Gets the committable offset. + * + * @return the committable offset + */ + public Optional getCommittableOffset(); + + /** + * Close. + */ + public default void close() { + // Provide use case specific implementation. + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffset.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffset.java new file mode 100644 index 0000000..998ae53 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffset.java @@ -0,0 +1,89 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka.internal; + +import dev.morphia.annotations.Entity; +import org.eclipse.ecsp.analytics.stream.base.offset.TopicOffset; + +/** + * BackdoorKafkaTopicOffset extends {@link TopicOffset}. + */ +@Entity +public class BackdoorKafkaTopicOffset extends TopicOffset { + + /** + * Instantiates a new backdoor kafka topic offset. + */ + public BackdoorKafkaTopicOffset() { + super(); + } + + /** + * Instantiates a new backdoor kafka topic offset. + * + * @param kafkaTopic the kafka topic + * @param partition the partition + * @param offset the offset + */ + public BackdoorKafkaTopicOffset(String kafkaTopic, int partition, long offset) { + super(kafkaTopic, partition, offset); + } + + /** + * Instantiates a new backdoor kafka topic offset. + * + * @param topicOffset the topic offset + */ + public BackdoorKafkaTopicOffset(BackdoorKafkaTopicOffset topicOffset) { + this(topicOffset.getKafkaTopic(), topicOffset.getPartition(), topicOffset.getOffset()); + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return "BackdoorKafkaTopicOffset [getId()=" + getId() + ", getKafkaTopic()=" + getKafkaTopic() + + ", getPartition()=" + getPartition() + ", getOffset()=" + getOffset() + "]"; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffsetDAOMongoImpl.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffsetDAOMongoImpl.java new file mode 100644 index 0000000..3147904 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffsetDAOMongoImpl.java @@ -0,0 +1,81 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka.internal; + +import jakarta.annotation.PostConstruct; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.offset.OffsetManagementDaoMongoImpl; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Repository; + +/** + * BackdoorKafkaTopicOffsetDAOMongoImpl extends {@link OffsetManagementDaoMongoImpl} which is a abstract class that + * extends {@link org.eclipse.ecsp.nosqldao.mongodb.IgniteBaseDAOMongoImpl}. + */ +@Repository +public class BackdoorKafkaTopicOffsetDAOMongoImpl extends + OffsetManagementDaoMongoImpl { + + /** The service name identifier. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceNameIdentifier; + + /** The collection. */ + private String collection; + + /** + * Gets the overriding collection name. + * + * @return the overriding collection name + */ + @Override + public String getOverridingCollectionName() { + return collection; + } + + /** + * Sets the up. + */ + @PostConstruct + public void setUp() { + collection = new StringBuilder().append("backdoorOffset").append(serviceNameIdentifier).toString(); + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/MutationId.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/MutationId.java new file mode 100644 index 0000000..b23b673 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/MutationId.java @@ -0,0 +1,50 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka.internal; + +import dev.morphia.annotations.Entity; + +/** + * MutationId interface. + */ +@Entity +public interface MutationId { + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/OffsetMetadata.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/OffsetMetadata.java new file mode 100644 index 0000000..4ee4199 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/OffsetMetadata.java @@ -0,0 +1,131 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka.internal; + +import org.apache.kafka.common.TopicPartition; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; + +/** + * class OffsetMetadata implements MutationId. + */ +public class OffsetMetadata implements MutationId { + + /** The partition. */ + private TopicPartition partition; + + /** The offset. */ + private long offset; + + /** + * OffsetMetadata(). + * + * @param partition partition + * @param offset offset + */ + public OffsetMetadata(TopicPartition partition, long offset) { + super(); + this.partition = partition; + this.offset = offset; + } + + /** + * Gets the partition. + * + * @return the partition + */ + public TopicPartition getPartition() { + return partition; + } + + /** + * Gets the offset. + * + * @return the offset + */ + public long getOffset() { + return offset; + } + + /** + * Hash code. + * + * @return the int + */ + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + (int) (offset ^ (offset >>> Constants.OFFSET_VALUE)); + result = prime * result + ((partition == null) ? 0 : partition.hashCode()); + return result; + } + + /** + * Equals. + * + * @param obj the obj + * @return true, if successful + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + OffsetMetadata other = (OffsetMetadata) obj; + if (offset != other.offset) { + return false; + } + if (partition == null) { + if (other.partition != null) { + return false; + } + } else if (!partition.equals(other.partition)) { + return false; + } + return true; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/KafkaStreamsThreadStatusPrinter.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/KafkaStreamsThreadStatusPrinter.java new file mode 100644 index 0000000..098fc06 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/KafkaStreamsThreadStatusPrinter.java @@ -0,0 +1,207 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka.support; + +import io.prometheus.client.CollectorRegistry; +import io.prometheus.client.Gauge; +import org.apache.kafka.streams.KafkaStreams; +import org.apache.kafka.streams.ThreadMetadata; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.ThreadUtils; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Prints status of stream threads and their allocated tasks. + * + * @author ssasidharan + */ +@Component +public class KafkaStreamsThreadStatusPrinter { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(KafkaStreamsThreadStatusPrinter.class); + + /** The thread metadata logger enabled. */ + @Value("${print.threads.metadata.enabled:true}") + private boolean threadMetadataLoggerEnabled = true; + + /** The interval. */ + @Value("${print.threads.metadata.interval.ms:30000}") + private long interval = 100; + + /** The service name. */ + @Value("${service.name:}") + private String serviceName = "stream-base"; + + /** The node name. */ + @Value("${" + PropertyNames.NODE_NAME + ":}") + private String nodeName = "localhost"; + + /** The stream thread alive states. */ + @Value("#{'${stream.threads.active.states}'.split(',')}") + private List streamThreadAliveStates = new ArrayList<>( + Arrays.asList("CREATED", "STARTING", "PARTITIONS_REVOKED", + "PARTITIONS_ASSIGNED", "RUNNING", "PENDING_SHUTDOWN")); + + /** The stream thread dead states. */ + @Value("#{'${stream.threads.dead.states}'.split(',')}") + private List streamThreadDeadStates = new ArrayList<>( + Arrays.asList("DEAD")); + + /** The enable prometheus. */ + @Value("${" + PropertyNames.ENABLE_PROMETHEUS + "}") + private boolean enablePrometheus = true; + + /** The total stream threads. */ + @Value("${" + PropertyNames.NUM_STREAM_THREADS + ":1}") + private Integer totalStreamThreads = 1; + + /** The exec. */ + private ScheduledExecutorService exec; + + /** The kafka streams. */ + private KafkaStreams kafkaStreams; + + /** The thread liveness metrics. */ + private volatile Gauge threadLivenessMetrics; + + /** The total thread metrics. */ + private volatile Gauge totalThreadMetrics; + + /** + * init(): to initialize the properties. + * + * @param ks ks + */ + + public void init(KafkaStreams ks) { + if (enablePrometheus) { + + threadLivenessMetrics = Gauge + .build("thread_liveness", "Track stream-processor alive stream thread count") + .labelNames("service", "node").register(CollectorRegistry.defaultRegistry); + + totalThreadMetrics = Gauge.build("total_thread", "Track total thread stream-processor started") + .labelNames("service", "node").register(CollectorRegistry.defaultRegistry); + + } + + if (threadMetadataLoggerEnabled || enablePrometheus) { + kafkaStreams = ks; + exec = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = Executors.defaultThreadFactory().newThread(r); + t.setDaemon(true); + t.setName("thread-status-printer"); + return t; + }); + exec.scheduleWithFixedDelay(new Runnable() { + @Override + public void run() { + logAndUpdateMetrics(); + } + + private void logAndUpdateMetrics() { + try { + logThreadMetadata(kafkaStreams.metadataForLocalThreads()); + updateStreamThreadMetrics(kafkaStreams.metadataForLocalThreads()); + } catch (Exception e) { + logger.error("Exception in printing threads metadata", e); + if (kafkaStreams.state() == KafkaStreams.State.ERROR) { + logger.info("KafkaStream in ERROR state. Terminating thread-status-printer"); + close(); + } + } + } + + private void updateStreamThreadMetrics(Set tmSet) { + if (!enablePrometheus) { + return; + } + long aliveThreads = tmSet.stream().filter( + tm -> streamThreadAliveStates.contains(tm.threadState()) + && !streamThreadDeadStates.contains(tm.threadState())) + .count(); + threadLivenessMetrics.labels(serviceName, nodeName).set(aliveThreads); + totalThreadMetrics.labels(serviceName, nodeName).set(totalStreamThreads); + + } + + }, interval, interval, TimeUnit.MILLISECONDS); + } + } + + /** + * Log thread metadata. + * + * @param tmSet the tm set + */ + private void logThreadMetadata(Set tmSet) { + if (!threadMetadataLoggerEnabled) { + return; + } + for (ThreadMetadata tm : tmSet) { + logger.info("Thread {} is in state {} with activeTasks: [{}] and standbyTasks [{}]", + tm.threadName(), tm.threadState(), tm.activeTasks(), tm.standbyTasks()); + } + + } + + /** + * close(): close the opened resources. + */ + public void close() { + if (threadMetadataLoggerEnabled || enablePrometheus) { + ThreadUtils.shutdownExecutor(exec, + Constants.THREAD_SLEEP_TIME_10000, false); + } + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/LoggingStateRestoreListener.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/LoggingStateRestoreListener.java new file mode 100644 index 0000000..a25ba0d --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/LoggingStateRestoreListener.java @@ -0,0 +1,100 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka.support; + +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.streams.processor.StateRestoreListener; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; + +/** + * Logs the progress of state store restoration. + * + * @author ssasidharan + */ +public class LoggingStateRestoreListener implements StateRestoreListener { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(LoggingStateRestoreListener.class); + + /** + * On restore start. + * + * @param topicPartition the topic partition + * @param storeName the store name + * @param startingOffset the starting offset + * @param endingOffset the ending offset + */ + @Override + public void onRestoreStart(TopicPartition topicPartition, String storeName, + long startingOffset, long endingOffset) { + logger.info("State store restoration for store named {} with topic {} and partition {} has started. " + + "Total records: {}", storeName, topicPartition.topic(), topicPartition.partition(), + (endingOffset - startingOffset)); + } + + /** + * On batch restored. + * + * @param topicPartition the topic partition + * @param storeName the store name + * @param batchEndOffset the batch end offset + * @param numRestored the num restored + */ + @Override + public void onBatchRestored(TopicPartition topicPartition, String storeName, + long batchEndOffset, long numRestored) { + logger.info("State store restoration progress update for store named {} with topic {} and partition {}. " + + "Restored {}", storeName, topicPartition.topic(), topicPartition.partition(), numRestored); + } + + /** + * On restore end. + * + * @param topicPartition the topic partition + * @param storeName the store name + * @param totalRestored the total restored + */ + @Override + public void onRestoreEnd(TopicPartition topicPartition, String storeName, long totalRestored) { + logger.info("State store restoration for store named {} with topic {} and partition {} has completed. " + + "Total records: {}", storeName, topicPartition.topic(), topicPartition.partition(), totalRestored); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/ConsoleMetricReporter.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/ConsoleMetricReporter.java new file mode 100644 index 0000000..94b658a --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/ConsoleMetricReporter.java @@ -0,0 +1,192 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.metrics.reporter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.common.metrics.KafkaMetric; +import org.apache.kafka.common.metrics.MetricsReporter; +import org.eclipse.ecsp.analytics.stream.base.context.StreamBaseSpringContext; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Class that will write the metrics on to the console. + * Which will be added to KafkaStream reporter You need to add the property + * metrics.reporter=org.eclipse.ecsp.analytics.stream.base.metrics.reporter. + * ConsoleMetricReporter in your application properties file + */ +@Component +public class ConsoleMetricReporter implements MetricsReporter { + + /** The Constant LOCK. */ + private static final Object LOCK = new Object(); + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(ConsoleMetricReporter.class); + + /** The metric list. */ + List metricList = new ArrayList<>(); + + /** The Constant JSON_MAPPER. */ + private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + + static { + JSON_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + /** + * Configure. + * + * @param configs the configs + */ + @Override + public void configure(Map configs) { + //overridden method + } + + /** + * Inits the. + * + * @param metrics the metrics + */ + @Override + public void init(List metrics) { + + synchronized (LOCK) { + metricList = metrics; + ScheduledExecutorService exec = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = Executors.defaultThreadFactory().newThread(r); + t.setDaemon(true); + return t; + }); + exec.scheduleWithFixedDelay(() -> { + try { + printMetrics(); + } catch (Exception e) { + logger.error("Exception while printing the metrics", e); + } + }, Constants.FOUR, Constants.SIXTY, TimeUnit.MINUTES); + } + + } + + /** + * Prints the metrics. + */ + private void printMetrics() { + HarmanRocksDBMetricsExporter exporter; + synchronized (LOCK) { + logger.debug("Printing Metrics:"); + for (KafkaMetric metric : metricList) { + try { + + Map kv = new HashMap<>(); + kv.put("metricName", metric.metricName().name()); + kv.put("groupName", metric.metricName().group()); + kv.put("metricValue", metric.metricValue()); + kv.put("description", metric.metricName().description()); + kv.putAll(metric.config().tags()); + logger.info("Printing metrics : {}", JSON_MAPPER.writeValueAsString(kv)); + } catch (JsonProcessingException e) { + logger.error("Unable to print the metrics for {} ", metric.metricName().name()); + } + } + if (!metricList.isEmpty() && metricList.stream() + .anyMatch(e -> e.metricName().name().equalsIgnoreCase("bytes-written-rate"))) { + exporter = StreamBaseSpringContext.getBean(HarmanRocksDBMetricsExporter.class); + logger.info("Fetched bean: {} from spring context", exporter.getClass().getName()); + exporter.publishMetrics(metricList); + } + } + } + + /** + * Metric change. + * + * @param metric the metric + */ + @Override + public void metricChange(KafkaMetric metric) { + synchronized (LOCK) { + logger.debug("Registering metric ! Name:{}, group:{},client-id:{},value:{}", + metric.metricName().name(), metric.metricName().group(), + metric.config().tags().get("client-id"), metric.metricValue().toString()); + if (metricList.contains(metric)) { + metricList.remove(metric); + } + metricList.add(metric); + } + } + + /** + * Metric removal. + * + * @param metric the metric + */ + @Override + public void metricRemoval(KafkaMetric metric) { + synchronized (LOCK) { + logger.debug("Removing metric:{}", metric.metricName().name()); + metricList.remove(metric); + } + + } + + /** + * Close. + */ + @Override + public void close() { + //overridden method + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/CumulativeLogger.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/CumulativeLogger.java new file mode 100644 index 0000000..c70867f --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/CumulativeLogger.java @@ -0,0 +1,161 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.metrics.reporter; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Aggregate and print given counters at configured time interval. + */ +public final class CumulativeLogger { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(CumulativeLogger.class); + + /** The Constant SPACE. */ + private static final String SPACE = " "; + + /** The Constant STATE. */ + private static final Map STATE = new ConcurrentHashMap<>(); + + /** The log every X minute. */ + private static int logEveryXMinute = 5; + + /** + * The Class CumulativeLoggerHolder. + */ + private static class CumulativeLoggerHolder { + + /** The Constant C_LOGGER. */ + private static final CumulativeLogger C_LOGGER = new CumulativeLogger(logEveryXMinute); + + /** + * Instantiates a new cumulative logger holder. + */ + private CumulativeLoggerHolder() { + } + } + + /** + * Instantiates a new cumulative logger. + * + * @param logEveryXminute the log every xminute + */ + private CumulativeLogger(int logEveryXminute) { + ScheduledExecutorService ses = Executors.newSingleThreadScheduledExecutor( + runnable -> { + Thread t = Executors.defaultThreadFactory().newThread(runnable); + t.setDaemon(true); + t.setName("CumulativeLogger:" + Thread.currentThread().getName()); + return t; + }); + ses.scheduleAtFixedRate(CumulativeLogger::resetAndLog, logEveryXMinute, logEveryXMinute, TimeUnit.MINUTES); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + CumulativeLogger.resetAndLog(); + logger.info("Flushed Cumulative Logger state"); + })); + logger.info("Cumulative logger initialized."); + } + + /** + * init() to setup logEveryXMinute property. + * + * @param properties Properties + */ + public static void init(Properties properties) { + logEveryXMinute = Integer.parseInt(properties.getProperty(PropertyNames.LOG_COUNTS_MINUTES, "5")); + if (logEveryXMinute < 1) { + throw new IllegalArgumentException("Log count must be greater than 0"); + } + } + + /** + * Gets the logger. + * + * @return the logger + */ + public static final CumulativeLogger getLogger() { + return CumulativeLoggerHolder.C_LOGGER; + } + + /** + * Reset and log. + */ + private static void resetAndLog() { + StringBuilder str = new StringBuilder(); + STATE.forEach((k, v) -> { + str.delete(0, str.length()); + long count = v.getAndSet(0); + if (count > 0) { + str.append(k).append(SPACE).append(count); + logger.info(str.toString()); + } + }); + } + + /** + * Increment by one. + * + * @param counter the counter + */ + public void incrementByOne(String counter) { + incrementBy(counter, 1); + } + + /** + * Increment by. + * + * @param counter the counter + * @param count the count + */ + public void incrementBy(String counter, int count) { + STATE.putIfAbsent(counter, new AtomicLong(0)); + STATE.get(counter).addAndGet(count); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/HarmanRocksDBMetricsExporter.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/HarmanRocksDBMetricsExporter.java new file mode 100644 index 0000000..5155dee --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/HarmanRocksDBMetricsExporter.java @@ -0,0 +1,195 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.metrics.reporter; + +import jakarta.annotation.PostConstruct; +import org.apache.kafka.common.metrics.KafkaMetric; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.eclipse.ecsp.utils.metrics.IgniteRocksDBGuage; +import org.rocksdb.RocksDB; +import org.rocksdb.RocksDBException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.math.BigInteger; +import java.util.List; + +/** + * Fetches RocksDB's property based metrics from the RocksDB library + * through a Scheduled thread executor at configured time interval + * and sets the value of each metric to its corresponding Prometheus guage. + * + * @author hbadshah + */ +@Component +@ConditionalOnProperty(name = PropertyNames.ROCKSDB_METRICS_ENABLED, havingValue = "true") +@EnableScheduling +public class HarmanRocksDBMetricsExporter { + + /** The Constant LOGGER. */ + private static final IgniteLogger LOGGER = IgniteLoggerFactory.getLogger(HarmanRocksDBMetricsExporter.class); + + /** The rocks db metrics enabled. */ + @Value("${" + PropertyNames.ROCKSDB_METRICS_ENABLED + ":false}") + private boolean rocksDbMetricsEnabled; + + /** The rocks db metrics list. */ + @Value("#{'${" + PropertyNames.ROCKSDB_METRICS_LIST + "}'.split(',')}") + private List rocksDbMetricsList; + + /** The prometheus enabled. */ + @Value("${" + PropertyNames.ENABLE_PROMETHEUS + "}") + private boolean prometheusEnabled; + + /** The svc. */ + @Value("${" + PropertyNames.SERVICE_NAME + "}") + private String svc; + + /** The node name. */ + @Value("${NODE_NAME:localhost}") + private String nodeName; + + /** The rocksdb guage. */ + @Autowired + private IgniteRocksDBGuage rocksdbGuage; + + /** The db. */ + private RocksDB db; + + /** The is valid list. */ + private boolean isValidList = false; + + /** + * init(). + */ + @PostConstruct + public void init() { + if (prometheusEnabled && rocksDbMetricsEnabled) { + LOGGER.info("RocksDB metrics is enabled"); + if (rocksDbMetricsList == null || rocksDbMetricsList.isEmpty()) { + LOGGER.info("RocksDB metrics is enabled but rocksdb.metrics.list is empty." + + " None of the RocksDB metrics will be exported to Prometheus."); + return; + } + rocksdbGuage.setup(); + prefix(); + isValidList = true; + } + } + + /** + * RocksDB accepts the name of the metric in the format: "rocksdb.{@code <}metric_name{@code >} + * For example: if metricName = compaction-pending, then to get this + * metric's value from RocksDB, we must convert it into the format = + * rocksdb.compaction-pending. + * Therefore this method prefixes each metric name with "rocksdb." + * in the input {@link HarmanRocksDBMetricsExporter#rocksDBMetricsList} + * + */ + private void prefix() { + rocksDbMetricsList = rocksDbMetricsList.stream().map(property -> Constants.ROCKSDB_PREFIX + property) + .toList(); + LOGGER.info("Modified rocksdb.metrics.list is: {}", rocksDbMetricsList); + } + + /** + * This method fetches each metric name one by one in + * {@link HarmanRocksDBMetricsExporter#rocksDBMetricsList} and gets its value from + * the RocksDB's instance that's been initialized and opened in {@code HarmanRocksDBStore} + * The value fetched above is then set to the corresponding Prometheus guage by the same name as in + * {@link HarmanRocksDBMetricsExporter#rocksDBMetricsList}. + * + */ + @Scheduled(fixedDelayString = "${" + PropertyNames.ROCKSDB_METRICS_THREAD_FREQUENCY_MS + "}", + initialDelayString = "${" + PropertyNames.ROCKSDB_METRICS_THREAD_INITIAL_DELAY_MS + "}") + public void fetchMetrics() { + if (db != null && isValidList) { + int i = 0; + while (true) { + if (i == rocksDbMetricsList.size()) { + break; + } + try { + String metricName = rocksDbMetricsList.get(i); + i++; + long val = db.getLongProperty(metricName); + LOGGER.info("Got metrics: {} value: {} from RocksDB", metricName, val); + rocksdbGuage.set(val, metricName, svc, nodeName); + } catch (RocksDBException exception) { + LOGGER.error("Unable to fetch metrics for property: {}, exception is {}. Exception status is {}"); + } + } + } else { + LOGGER.debug("Either RocksDB instance is null or RocksDB metrics " + + "list not populated correctly. Unable to publish RocksDB metrics"); + } + } + + /** + * Sets the rocks db. + * + * @param db the new rocks db + */ + public void setRocksDb(RocksDB db) { + this.db = db; + } + + /** + * Sets the value of each metric in the metricList which will subsequently publish the metrics' + * value to prometheus. + * + * @param metricList List of metrics to publish. + */ + public void publishMetrics(List metricList) { + if (prometheusEnabled) { + LOGGER.debug("Publishing Rocks db metrics to prometheus"); + metricList.stream().filter(e -> e.metricName().group().equalsIgnoreCase("stream-state-metrics")) + .forEach(e -> rocksdbGuage.set(e.metricValue() instanceof Double metricValue ? metricValue + : ((BigInteger) e.metricValue()).doubleValue(), e.metricName().name(), svc, nodeName)); + } + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsOffsetManagementDAOMongoImpl.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsOffsetManagementDAOMongoImpl.java new file mode 100644 index 0000000..5849ddd --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsOffsetManagementDAOMongoImpl.java @@ -0,0 +1,93 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.offset; + +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.exception.InvalidServiceNameException; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Repository; + +/** + * DAO class for maintaining the kafka topic offsets in MongoDB for + * stream-base's manual offset management feature. + */ +@Repository +public class KafkaStreamsOffsetManagementDAOMongoImpl extends + OffsetManagementDaoMongoImpl { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(KafkaStreamsOffsetManagementDAOMongoImpl.class); + + /** The service name identifier. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceNameIdentifier; + + /** The mongo collection name. */ + private String mongoCollectionName; + + /** + * Gets the overriding collection name. + * + * @return the overriding collection name + */ + @Override + public String getOverridingCollectionName() { + return mongoCollectionName; + } + + /** + * setUp(). + */ + @PostConstruct + public void setUp() { + // Underscore will not be removed + serviceNameIdentifier = serviceNameIdentifier.replaceAll("[^\\w\\s]", "").toLowerCase(); + if (StringUtils.isEmpty(serviceNameIdentifier)) { + throw new InvalidServiceNameException("Service name unavailable"); + } + logger.debug("Servicename after removing special characters is {}", serviceNameIdentifier); + mongoCollectionName = new StringBuilder().append("kafkastreamsoffset").append(serviceNameIdentifier).toString(); + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsTopicOffset.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsTopicOffset.java new file mode 100644 index 0000000..932306b --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsTopicOffset.java @@ -0,0 +1,80 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.offset; + +import dev.morphia.annotations.Entity; + +/** + * class KafkaStreamsTopicOffset extends TopicOffset. + */ +@Entity() +public class KafkaStreamsTopicOffset extends TopicOffset { + + /** + * Instantiates a new kafka streams topic offset. + */ + public KafkaStreamsTopicOffset() { + super(); + } + + /** + * Instantiates a new kafka streams topic offset. + * + * @param kafkaTopic the kafka topic + * @param partition the partition + * @param offset the offset + */ + public KafkaStreamsTopicOffset(String kafkaTopic, int partition, long offset) { + super(kafkaTopic, partition, offset); + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return "KafkaStreamsTopicOffset [getId()=" + getId() + ", getKafkaTopic()=" + + getKafkaTopic() + ", getPartition()=" + getPartition() + + ", getOffset()=" + getOffset() + "]"; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManagementDaoMongoImpl.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManagementDaoMongoImpl.java new file mode 100644 index 0000000..a6839ee --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManagementDaoMongoImpl.java @@ -0,0 +1,89 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.offset; + +import org.eclipse.ecsp.entities.IgniteEntity; +import org.eclipse.ecsp.nosqldao.IgniteCriteria; +import org.eclipse.ecsp.nosqldao.IgniteCriteriaGroup; +import org.eclipse.ecsp.nosqldao.IgniteQuery; +import org.eclipse.ecsp.nosqldao.Operator; +import org.eclipse.ecsp.nosqldao.mongodb.IgniteBaseDAOMongoImpl; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; + +import java.util.List; + +/** + * /** + * DAO class for maintaining the kafka topic offsets in MongoDB for + * stream-base's manual offset management feature. + * + * @param the generic type + * @param the element type + */ +public abstract class OffsetManagementDaoMongoImpl extends IgniteBaseDAOMongoImpl { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(OffsetManagementDaoMongoImpl.class); + + /** + * This method is used to get all documents form mongocollection when filtered on kafkatopic. + * + * @param kafkaTopic kafkaTopic + * @return List + */ + public List getTopicOffsetList(String kafkaTopic) { + logger.debug("Get Topic Offset for kafkaTopic {}", kafkaTopic); + IgniteCriteria criteriaKafkaTopic = new IgniteCriteria("kafkaTopic", Operator.EQ, kafkaTopic); + + IgniteCriteriaGroup criteriaGroup = new IgniteCriteriaGroup(criteriaKafkaTopic); + IgniteQuery query = new IgniteQuery(criteriaGroup); + + List topicOffsetList = find(query); + if (!topicOffsetList.isEmpty()) { + logger.debug("Recieved {} offsets for topic:{} and entries are:{}", + topicOffsetList.size(), kafkaTopic, topicOffsetList); + } else { + logger.debug("No entries found for kafkaTopic {}", kafkaTopic); + } + return topicOffsetList; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManager.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManager.java new file mode 100644 index 0000000..8d34c09 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManager.java @@ -0,0 +1,284 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.offset; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.ThreadUtils; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Offset manager is responsible to ensure that the services do not process duplicate offsets. + * It also facilitates storage of offsets + * periodically to a persistent storage layer in order to ensure that state is maintained. + * + * @author avadakkootko + */ +@Service +public class OffsetManager { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(OffsetManager.class); + + /** The offset dao. */ + @Autowired + private KafkaStreamsOffsetManagementDAOMongoImpl offsetDao; + + /** The offsets mgmt executor. */ + private volatile ScheduledExecutorService offsetsMgmtExecutor = null; + + /** The offset persistence delay. */ + @Value("${" + PropertyNames.KAFKA_STREAMS_OFFSET_PERSISTENCE_DELAY + ":60000}") + private int offsetPersistenceDelay; + + /** The offset persistence init delay. */ + @Value("${" + PropertyNames.KAFKA_STREAMS_OFFSET_PERSISTENCE_INIT_DELAY + ":10000}") + private int offsetPersistenceInitDelay; + + /** The offset persistence enabled. */ + @Value("${" + PropertyNames.KAFKA_STREAMS_OFFSET_PERSISTENCE_ENABLED + ":false}") + private boolean offsetPersistenceEnabled; + + /** The persist offset map. */ + // Current offsets that needs to be persisted to mongo will be stored here. + private volatile ConcurrentHashMap persistOffsetMap = + new ConcurrentHashMap(); + + // Reference map will be updated once from mongo. This is to reduce + // frequent queries to mongo in case the persistence map doesnt have any + /** The refrence map. */ + // value in it. + private volatile ConcurrentHashMap refrenceMap = + new ConcurrentHashMap(); + + /** The started offsets mgmt executor. */ + private final AtomicBoolean startedOffsetsMgmtExecutor = new AtomicBoolean(false); + + /** The closed offsets mgmt executor. */ + private final AtomicBoolean closedOffsetsMgmtExecutor = new AtomicBoolean(false); + + + /** + * Update previously processed offset in local memory. + * + * @param kafkaTopic kafkaTopic + * @param partition partition + * @param offset offset + */ + public void updateProcessedOffset(String kafkaTopic, int partition, long offset) { + if (offsetPersistenceEnabled) { + String key = getKey(kafkaTopic, partition); + KafkaStreamsTopicOffset kafkaStreamsTopicOffset = persistOffsetMap.get(key); + if (kafkaStreamsTopicOffset == null) { + kafkaStreamsTopicOffset = new KafkaStreamsTopicOffset(kafkaTopic, partition, offset); + persistOffsetMap.put(key, kafkaStreamsTopicOffset); + } else { + kafkaStreamsTopicOffset.setOffset(offset); + } + } + } + + /** + * Check if this offset should be processed. + * + * @param kafkaTopic kafkaTopic + * @param partition partition + * @param offset offset + * @return boolean + */ + public boolean doSkipOffset(String kafkaTopic, int partition, long offset) { + boolean skipOffset = false; + if (offsetPersistenceEnabled) { + String key = getKey(kafkaTopic, partition); + KafkaStreamsTopicOffset currVal = persistOffsetMap.get(key); + KafkaStreamsTopicOffset refVal = refrenceMap.get(key); + if (currVal != null && offset < currVal.getOffset()) { + logger.debug("Skipping offset {} being processed in offsetmanager is less that offset {} " + + "from persistent map for key {}", offset, currVal.getOffset(), key); + skipOffset = true; + } else if (currVal == null && refVal != null && offset < refVal.getOffset()) { + logger.debug("Skipping offset {} being processed in offsetmanager is less that offset {} " + + "from reference map for key {}", offset, refVal.getOffset(), key); + skipOffset = true; + } + } + return skipOffset; + } + + /** + * Gets the thread factory. + * + * @param threadName the thread name + * @return the thread factory + */ + private ThreadFactory getThreadFactory(String threadName) { + return runnable -> { + Thread thread = new Thread(runnable); + thread.setName(threadName); + thread.setDaemon(true); + thread.setUncaughtExceptionHandler((thread1, t) -> + logger.error("Uncaught exception detected in offsetmanager! " + + t + " st: " + Arrays.toString(t.getStackTrace()))); + return thread; + }; + } + + /** + * Initialize refrence map. + */ + protected void initializeRefrenceMap() { + List topicOffsetList = offsetDao.findAll(); + logger.info("TopicOffset list of size {} for offsetmanager", topicOffsetList.size()); + topicOffsetList.parallelStream().forEach(topicOffset -> refrenceMap.put(topicOffset.getId(), topicOffset)); + } + + /** + * Gets the key. + * + * @param topic the topic + * @param partition the partition + * @return the key + */ + protected String getKey(String topic, int partition) { + return topic + ":" + partition; + } + + /** + * This method will be invoked when the sp chages state to RUNNING. + */ + public void setUp() { + if (offsetPersistenceEnabled && !startedOffsetsMgmtExecutor.get()) { + startedOffsetsMgmtExecutor.set(true); + offsetsMgmtExecutor = Executors.newSingleThreadScheduledExecutor(getThreadFactory("kafkaStreamsOffsetDt")); + closedOffsetsMgmtExecutor.set(false); + initializeRefrenceMap(); + logger.info("Running offsetmanager executer"); + offsetsMgmtExecutor.scheduleWithFixedDelay(() -> { + try { + logger.trace("Executing offsetmanager at fixed delay"); + persistOffset(); + } catch (Exception e) { + logger.error("Error offsetmanager :", e); + } + }, offsetPersistenceInitDelay, offsetPersistenceDelay, TimeUnit.MILLISECONDS); + } else { + logger.warn("Not attempting to run offsetmanager executer as offsetPersistence flag is {} " + + "and started state is {}", offsetPersistenceEnabled, startedOffsetsMgmtExecutor.get()); + } + } + + /** + * This method is used to save the offset data per topic per partition in a periodic fashion to mongo. + */ + protected void persistOffset() { + if (persistOffsetMap != null && !persistOffsetMap.isEmpty()) { + for (KafkaStreamsTopicOffset topicOffset : persistOffsetMap.values()) { + try { + offsetDao.save(topicOffset); + logger.debug("Persisted kafka topic offset to database by offsetmanager. {}", + topicOffset.toString()); + } catch (Exception e) { + logger.error("Error occured while persisting offset by kafka streams offsetmanager:", e); + } + } + } else { + logger.trace("No offset to persist for kafka streams by offsetmanager"); + } + } + + /** + * shutdown(): to close opened resources. + */ + public void shutdown() { + if (startedOffsetsMgmtExecutor.get() && !closedOffsetsMgmtExecutor.get() && offsetPersistenceEnabled) { + startedOffsetsMgmtExecutor.set(false); + persistOffset(); + persistOffsetMap.clear(); + refrenceMap.clear(); + ThreadUtils.shutdownExecutor(offsetsMgmtExecutor, Constants.THREAD_SLEEP_TIME_10000, false); + logger.info("Closing kafka streams offsetmanager"); + closedOffsetsMgmtExecutor.set(true); + } + } + + /** + * Sets the offset persistence enabled. + * + * @param offsetPersistenceEnabled the new offset persistence enabled + */ + // Used for test case + void setOffsetPersistenceEnabled(boolean offsetPersistenceEnabled) { + this.offsetPersistenceEnabled = offsetPersistenceEnabled; + } + + /** + * Sets the persist offset map. + * + * @param persistOffsetMap the persist offset map + */ + // Used for test case + void setPersistOffsetMap(ConcurrentHashMap persistOffsetMap) { + this.persistOffsetMap = persistOffsetMap; + } + + /** + * Sets the refrence map. + * + * @param refrenceMap the refrence map + */ + // Used for test case + void setRefrenceMap(ConcurrentHashMap refrenceMap) { + this.refrenceMap = refrenceMap; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/TopicOffset.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/TopicOffset.java new file mode 100644 index 0000000..b25d880 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/offset/TopicOffset.java @@ -0,0 +1,236 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.offset; + +import dev.morphia.annotations.Entity; +import dev.morphia.annotations.Id; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.entities.AbstractIgniteEntity; + +/** + * TopicOffset serves as the base data structure for topic to partition to offset mapping. + * This is used by BackdoorOffsetManager and + * KafksStreamsOffset manager to store offsets per topic per partition. + * + * @author avadakkootko + */ +@Entity +public class TopicOffset extends AbstractIgniteEntity { + + /** The Constant COLON. */ + private static final String COLON = ":"; + + /** The id. */ + @Id + private String id; + + /** The kafka topic. */ + private String kafkaTopic; + + /** The partition. */ + private int partition; + + /** The offset. */ + private long offset; + + /** + * Instantiates a new topic offset. + */ + public TopicOffset() { + + } + + /** + * Constructor to generate TopicOffset. + * + * @param kafkaTopic topic name + * @param partition partition name + * @param offset offset value + **/ + public TopicOffset(String kafkaTopic, int partition, long offset) { + super(); + this.id = new StringBuilder().append(kafkaTopic).append(COLON) + .append(partition).toString(); + this.kafkaTopic = kafkaTopic; + this.partition = partition; + this.offset = offset; + } + + /** + * Gets the id. + * + * @return the id + */ + public String getId() { + return id; + } + + /** + * Sets the id. + * + * @param id the new id + */ + public void setId(String id) { + this.id = id; + } + + /** + * Gets the kafka topic. + * + * @return the kafka topic + */ + public String getKafkaTopic() { + return kafkaTopic; + } + + /** + * Sets the kafka topic. + * + * @param kafkaTopic the new kafka topic + */ + public void setKafkaTopic(String kafkaTopic) { + this.kafkaTopic = kafkaTopic; + } + + /** + * Gets the partition. + * + * @return the partition + */ + public int getPartition() { + return partition; + } + + /** + * Sets the partition. + * + * @param partition the new partition + */ + public void setPartition(int partition) { + this.partition = partition; + } + + /** + * Gets the offset. + * + * @return the offset + */ + public long getOffset() { + return offset; + } + + /** + * Sets the offset. + * + * @param offset the new offset + */ + public void setOffset(long offset) { + this.offset = offset; + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return "TopicOffset [id=" + id + ", kafkaTopic=" + kafkaTopic + + ", partition=" + partition + ", offset=" + offset + "]"; + } + + /** + * Hash code. + * + * @return the int + */ + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((id == null) ? 0 : id.hashCode()); + result = prime * result + ((kafkaTopic == null) ? 0 : kafkaTopic.hashCode()); + result = prime * result + (int) (offset ^ (offset >>> Constants.OFFSET_VALUE)); + result = prime * result + partition; + return result; + } + + /** + * Equals. + * + * @param obj the obj + * @return true, if successful + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + TopicOffset other = (TopicOffset) obj; + if (id == null) { + if (other.id != null) { + return false; + } + } else if (!id.equals(other.id)) { + return false; + } + if (kafkaTopic == null) { + if (other.kafkaTopic != null) { + return false; + } + } else if (!kafkaTopic.equals(other.kafkaTopic)) { + return false; + } + if (offset != other.offset) { + return false; + } + if (partition != other.partition) { + return false; + } + return partition == other.partition; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/DeviceConnectionStatusParser.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/DeviceConnectionStatusParser.java new file mode 100644 index 0000000..f2c81f3 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/DeviceConnectionStatusParser.java @@ -0,0 +1,72 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.parser; + +import java.util.Map; + +/** + * An interface for services to implement when they want the connection + * status of the devices to be retrieved through a third party + * API. DMA will hit the API and get the response JSON. + * Services then have to provide an implementation of below interface to + * extract the connection status information from the response and return it to DMA. + * Parsing of this response has been avoided in DMA as response may vary + * from service to service, hence to avoid any specific kind + * of parsing specific to any particular service. + * If a service wants DMA to get connection status data through API, + * and yet it did not provide implementation of this interface, + * then by default null will be returned and no connection status data for + * devices will be stored in the in-memory of DMA. + * + * + * @author hbadshah + */ +public interface DeviceConnectionStatusParser { + + /** + * Gets the connection status. + * + * @param responseData the response data + * @return the connection status + */ + public default String getConnectionStatus(Map responseData) { + return null; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventParseException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventParseException.java new file mode 100644 index 0000000..fc2e459 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventParseException.java @@ -0,0 +1,65 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.parser; + +/** + * class EventParseException extends Exception. + */ +public class EventParseException extends Exception { + + /** + * Instantiates a new event parse exception. + * + * @param cause the cause + */ + public EventParseException(Throwable cause) { + super(cause); + } + + /** + * Instantiates a new event parse exception. + * + * @param message the message + * @param cause the cause + */ + public EventParseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventParser.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventParser.java new file mode 100644 index 0000000..8de60c2 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventParser.java @@ -0,0 +1,125 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.parser; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * EventParser: utility class. + * + * @param the key type + * @param the value type + */ +public class EventParser { + + /** The jf. */ + private JsonFactory jf = new JsonFactory(); + + /** The mapper. */ + private ObjectMapper mapper = new ObjectMapper(); + + /** + * parseEventToMap(). + * + * @param source source + * @return Map + * @throws EventParseException EventParseException + */ + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public Map parseEventToMap(byte[] source) throws EventParseException { + try { + Class> clazz = (Class) Map.class; + return mapper.readValue(jf.createParser(source), clazz); + } catch (IOException e) { + throw new EventParseException(e); + } + } + + /** + * parseEventToList(). + * + * @param source source + * @return List + * @throws EventParseException EventParseException + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + public List parseEventToList(byte[] source) throws EventParseException { + try { + Class> clazz = (Class) List.class; + return mapper.readValue(jf.createParser(source), clazz); + } catch (IOException e) { + throw new EventParseException(e); + } + } + + /** + * parseEventMapToWrapper(). + * + * @param source source + * @return EventWrapperBase + */ + public EventWrapperBase parseEventMapToWrapper(byte[] source) { + try { + return new EventWrapperForMap(mapper.readValue(jf.createParser(source), Map.class)); + } catch (IOException e) { + return new EventWrapperForMap(source, e); + } + } + + /** + * parseEventSequenceToWrapper(). + * + * @param source source + * @return EventWrapperForSequence + */ + public EventWrapperForSequence parseEventSequenceToWrapper(byte[] source) { + try { + return new EventWrapperForSequence(mapper.readValue(jf.createParser(source), List.class)); + } catch (IOException e) { + return new EventWrapperForSequence(source, e); + } + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperBase.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperBase.java new file mode 100644 index 0000000..9209bff --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperBase.java @@ -0,0 +1,112 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.parser; + +import java.util.Map; + +/** + * interface {@link EventWrapperBase}. + */ +public interface EventWrapperBase { + + /** + * To json. + * + * @return the string + */ + String toJson(); + + /** + * Gets the property. + * + * @param wrapper the wrapper + * @param name the name + * @return the property + */ + Object getProperty(Map wrapper, String name); + + /** + * Gets the property. + * + * @param name the name + * @return the property + */ + Object getProperty(String name); + + /** + * Gets the property by expr. + * + * @param name the name + * @return the property by expr + */ + Object getPropertyByExpr(String name); + + /** + * Gets the property by expr. + * + * @param name the name + * @param byFieldToBeSorted the by field to be sorted + * @param sort the sort + * @return the property by expr + */ + Object getPropertyByExpr(String name, String byFieldToBeSorted, String sort); + + /** + * Gets the raw event. + * + * @return the raw event + */ + byte[] getRawEvent(); + + /** + * Gets the parses the exception. + * + * @return the parses the exception + */ + Exception getParseException(); + + /** + * Checks if is valid. + * + * @return true, if is valid + */ + boolean isValid(); + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperForMap.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperForMap.java new file mode 100644 index 0000000..859ccb4 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperForMap.java @@ -0,0 +1,615 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.parser; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; + +/** + * class EventWrapperForMap implements EventWrapperBase. + */ +public class EventWrapperForMap implements EventWrapperBase { + + /** The event data. */ + private Map eventData; + + /** The parse exception. */ + private Exception parseException; + + /** The raw event. */ + private byte[] rawEvent; + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(EventWrapperForMap.class); + + /** The Constant NAME_PATTERN. */ + private static final Pattern NAME_PATTERN = Pattern.compile("\\."); + + /** The Constant QUALIFIER_SEP_PATTERN. */ + private static final Pattern QUALIFIER_SEP_PATTERN = Pattern.compile("\\,"); + + /** The Constant COMPILED_QUALIFIERS. */ + private static final ConcurrentHashMap>> COMPILED_QUALIFIERS = + new ConcurrentHashMap<>(); + + /** The Constant OBJECT_MAPPER. */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * Instantiates a new event wrapper for map. + * + * @param events the events + */ + public EventWrapperForMap(Map events) { + this.eventData = events; + } + + /** + * Instantiates a new event wrapper for map. + * + * @param rawEvent the raw event + * @param e the e + */ + public EventWrapperForMap(byte[] rawEvent, Exception e) { + this.rawEvent = rawEvent; + this.parseException = e; + } + + /** + * Checks if is valid. + * + * @return true, if is valid + */ + @Override + public boolean isValid() { + return parseException == null; + } + + /** + * Gets the parses the exception. + * + * @return the parses the exception + */ + @Override + public Exception getParseException() { + return parseException; + } + + /** + * Gets the raw event. + * + * @return the raw event + */ + @Override + public byte[] getRawEvent() { + return rawEvent; + } + + /** + * Gets the property. + * + * @param wrapper the wrapper + * @param name the name + * @return the property + */ + @Override + public Object getProperty(Map wrapper, String name) { + if (wrapper == null) { + return null; + } + String[] splits = NAME_PATTERN.split(name); + // optimize for 1-2 levels of nesting + if (splits.length == 1) { + return wrapper.get(splits[0]); + } + if (splits.length == Constants.TWO) { + Object r = wrapper.get(splits[0]); + if (r instanceof Map) { + return ((Map) r).get(splits[1]); + } else if (r instanceof List) { + return ((Map) ((List) r).get(0)).get(splits[1]); + } else { + return null; + } + } + // deeper nesting + Object ret = eventData; + for (int i = 0; i < splits.length; i++) { + if (ret instanceof Map map) { + ret = map.get(splits[i]); + } else if (ret instanceof List s) { + List l = new ArrayList<>(s.size()); + Iterator it = s.iterator(); + while (it.hasNext()) { + l.add(((Map) it.next()).get(splits[i])); + } + ret = l; + } + } + return ret; + } + + /** + * Gets the property. + * + * @param name the name + * @return the property + */ + @Override + public Object getProperty(String name) { + if (eventData == null) { + return null; + } + String[] splits = NAME_PATTERN.split(name); + List> qualifiers = getQualifiers(name, splits); + clearQualifiers(splits); + + Object ret = eventData; + for (int i = 0; i < splits.length; i++) { + if (ret instanceof Map) { + ret = getObjectForMap(splits, qualifiers, ret, i); + } else if (ret instanceof List s) { + List l = new ArrayList<>(s.size()); + Iterator it = s.iterator(); + while (it.hasNext()) { + l.add(((Map) it.next()).get(splits[i])); + } + ret = l; + } + if (ret == null) { + break; + } + } + return ret; + } + + /** + * Gets the object for map. + * + * @param splits the splits + * @param qualifiers the qualifiers + * @param ret the ret + * @param i the i + * @return the object for map + */ + private Object getObjectForMap(String[] splits, List> qualifiers, Object ret, int i) { + ret = splits[i].length() == 0 ? ret : ((Map) ret).get(splits[i]); + Optional qlist = qualifiers.get(i); + if (ret instanceof List list && qlist.isPresent()) { + List> flist = filterList(list, qlist.get()); + ret = (flist.size() == 1) ? flist.get(0) : flist; + } + return ret; + } + + /** + * Clear qualifiers. + * + * @param splits the splits + */ + private void clearQualifiers(String[] splits) { + for (int i = 0; i < splits.length; i++) { + int qualifierStart = splits[i].indexOf("["); + if (qualifierStart != Constants.NEGATIVE_ONE) { + splits[i] = splits[i].substring(0, qualifierStart); + } + } + } + + /** + * Gets the qualifiers. + * + * @param name the name + * @param splits the splits + * @return the qualifiers + */ + private List> getQualifiers(String name, + String[] splits) { + List> qualifiers = COMPILED_QUALIFIERS.get(name); + if (qualifiers == null) { + qualifiers = buildQualifiers(splits); + COMPILED_QUALIFIERS.putIfAbsent(name, qualifiers); + } + return qualifiers; + } + + /** + * Filter list. + * + * @param retAsList the ret as list + * @param qualifiers the qualifiers + * @return the list + */ + private List> filterList(List retAsList, Qualifier[] qualifiers) { + List> l = new ArrayList<>(); + for (int j = 0; j < retAsList.size(); j++) { + Map v = (Map) retAsList.get(j); + boolean all = true; + for (Qualifier q : qualifiers) { + all = all && (q.matches(v)); + } + if (all) { + l.add(v); + } + } + return l; + } + + /** + * Return one event after sorting by sorting criteria. Ex : Say + * we have multiple EID=Location events in the message, and we want the + * Location event which has the latest TimeStamp. Client will call like : + * generalEvent.getPropertyByExpr("data[EventID=Location].Data.longitude", "TimeStamp", "descending") + * + * @param name + * : Ex : + * @param byFieldToBeSorted + * : TimeStamp, Version + * @param sort + * : ascending or descending + * @return Object + */ + @Override + public Object getPropertyByExpr(String name, String byFieldToBeSorted, + String sort) { + if (this.eventData == null) { + return null; + } + String[] splits = NAME_PATTERN.split(name); + List> qualifiers = this.getQualifiers(name, + splits); + this.clearQualifiers(splits); + Object ret = this.eventData; + for (int i = 0; i < splits.length; ++i) { + if (ret instanceof Map map) { + ret = map.get(splits[i]); + Optional qlist = qualifiers.get(i); + if (ret instanceof List list && qlist.isPresent()) { + ret = this.getEventBySortedField(list, qlist.get(), + byFieldToBeSorted, sort); + } + } else if (ret instanceof List s) { + ArrayList l = new ArrayList<>(s.size()); + Iterator it = s.iterator(); + while (it.hasNext()) { + l.add(((Map) it.next()).get(splits[i])); + } + ret = l; + } + if (ret == null) { + break; + } + } + return ret; + } + + + /** + * Can qualify the results of navigating a path with additional + * operators. For ex data[EventID=EngineRPM].Data.value will look up the + * event object for 'data' the result of which is filtered for + * the expr 'EventID=EngineRPM'; the result of which is looked up for 'Data' + * followed by 'value' and the final result is returned. + * + * @param name name + * @return Object + */ + @Override + public Object getPropertyByExpr(String name) { + if (eventData == null) { + return null; + } + String[] splits = NAME_PATTERN.split(name); + List> qualifiers = getQualifiers(name, splits); + clearQualifiers(splits); + + Object ret = eventData; + for (int i = 0; i < splits.length; i++) { + if (ret instanceof Map) { + ret = getObjectForMap(splits, qualifiers, ret, i); + } else if (ret instanceof List s) { + List l = new ArrayList<>(s.size()); + Iterator it = s.iterator(); + while (it.hasNext()) { + l.add(((Map) it.next()).get(splits[i])); + } + ret = l; + } + if (ret == null) { + break; + } + } + return ret; + + } + + /** + * Return one event after sorting by sorting criteria. Ex : Say we + * have multiple EID=Location events in the message, and we want the + * Location event which has the latest TimeStamp. + * + * @param retAsList retAsList + * @param qualifiers qualifiers + * : + * @param byFieldToBeSorted + * : Ex : TimeStamp, Version + * @param sort + * : ascending/descending + * @return Map + */ + private Map getEventBySortedField(List retAsList, Qualifier[] qualifiers, + String byFieldToBeSorted, String sort) { + Comparator> comparator = "asc".equals(sort) ? new AscendingComparator( + byFieldToBeSorted) : new DescendingComparator(byFieldToBeSorted); + ArrayList> list = new ArrayList<>(); + for (int j = 0; j < retAsList.size(); ++j) { + Map v = (Map) retAsList.get(j); + for (Qualifier q : qualifiers) { + if (q.matches(v)) { + // empty if block + } + list.add(v); + } + } + list.sort(comparator); + return list.get(0); + } + + /** + * Builds the qualifiers. + * + * @param splits the splits + * @return the list + */ + private List> buildQualifiers(String[] splits) { + List> qualifiers = new ArrayList<>(splits.length); + for (int i = 0; i < splits.length; i++) { + int qualifierStart = splits[i].indexOf("["); + if (qualifierStart != Constants.NEGATIVE_ONE) { + String qstring = splits[i].substring(qualifierStart + 1, + splits[i].length() - 1); + String[] qstrings = QUALIFIER_SEP_PATTERN.split(qstring); + Qualifier[] qs = new Qualifier[qstrings.length]; + for (int j = 0; j < qstrings.length; j++) { + qs[j] = new Qualifier(qstrings[j]); + } + qualifiers.add(Optional.of(qs)); + } else { + qualifiers.add(Optional.empty()); + } + } + return qualifiers; + } + + /** + * The Class Qualifier. + */ + private static class Qualifier { + + /** The Constant OPERATORS. */ + private static final Pattern OPERATORS = Pattern.compile("="); + + /** The field. */ + private String field; + + /** The value. */ + private String value; + + /** + * Instantiates a new qualifier. + * + * @param expr the expr + */ + public Qualifier(String expr) { + String[] tokens = OPERATORS.split(expr); + field = tokens[0].trim(); + value = tokens[1].trim(); + } + + /** + * The Enum Op. + */ + private enum Op { + + /** The equals. */ + EQUALS("="); + + /** The operator. */ + private String operator; + + /** + * Instantiates a new op. + * + * @param op the op + */ + private Op(String op) { + this.operator = op; + } + + /** + * Find. + * + * @param op the op + * @return the op + */ + public static Op find(String op) { + if (op.equals("=")) { + return EQUALS; + } + return EQUALS; + } + } + + /** + * Matches. + * + * @param data the data + * @return true, if successful + */ + public boolean matches(Map data) { + Object v = data.get(field); + if (v == null) { + return false; + } else { + return value.equals(v.toString()); + } + } + } + + /** + * To json. + * + * @return the string + */ + @Override + public String toJson() { + Map m = new HashMap<>(); + m.put("exception", parseException); + m.put("payload", rawEvent); + try { + return OBJECT_MAPPER.writeValueAsString(m); + } catch (JsonProcessingException e) { + logger.error("Unexpected exception: ", e); // this really shouldn't be happening + return e.getMessage(); + } + } + + /** + * The Class AscendingComparator. + */ + private class AscendingComparator implements Comparator> { + + /** The by field to be sorted. */ + private String byFieldToBeSorted; + + /** + * Instantiates a new ascending comparator. + * + * @param byFieldToBeSorted the by field to be sorted + */ + public AscendingComparator(String byFieldToBeSorted) { + this.byFieldToBeSorted = null; + this.byFieldToBeSorted = byFieldToBeSorted; + } + + /** + * Compare. + * + * @param o1 the o 1 + * @param o2 the o 2 + * @return the int + */ + @Override + public int compare(Map o1, Map o2) { + int index = Constants.NEGATIVE_ONE; + if (o1.get(this.byFieldToBeSorted) instanceof String o1string + && o2.get(this.byFieldToBeSorted) instanceof String o2string) { + index = o1string.compareTo(o2string); + } else if (o1.get(this.byFieldToBeSorted) instanceof Integer o1integer + && o2.get(this.byFieldToBeSorted) instanceof Integer o2integer) { + index = o1integer.compareTo(o2integer); + } else if (o1.get(this.byFieldToBeSorted) instanceof Long o1long + && o2.get(this.byFieldToBeSorted) instanceof Long o2long) { + index = o1long.compareTo(o2long); + } else if (o1.get(this.byFieldToBeSorted) instanceof Double o1Double + && o2.get(this.byFieldToBeSorted) instanceof Double o2Double) { + index = o1Double.compareTo(o2Double); + } + return index; + } + } + + /** + * The Class DescendingComparator. + */ + private class DescendingComparator implements Comparator> { + + /** The by field to be sorted. */ + private String byFieldToBeSorted; + + /** + * Instantiates a new descending comparator. + * + * @param byFieldToBeSorted the by field to be sorted + */ + public DescendingComparator(String byFieldToBeSorted) { + this.byFieldToBeSorted = byFieldToBeSorted; + } + + /** + * Compare. + * + * @param o1 the o 1 + * @param o2 the o 2 + * @return the int + */ + @Override + public int compare(Map o1, Map o2) { + int index = Constants.NEGATIVE_ONE; + if (o1.get(this.byFieldToBeSorted) instanceof String o1String + && o2.get(this.byFieldToBeSorted) instanceof String o2String) { + index = o2String.compareTo(o1String); + } else if (o1.get(this.byFieldToBeSorted) instanceof Integer o1Integer + && o2.get(this.byFieldToBeSorted) instanceof Integer o2integer) { + index = o2integer.compareTo(o1Integer); + } else if (o1.get(this.byFieldToBeSorted) instanceof Long o1Long + && o2.get(this.byFieldToBeSorted) instanceof Long o2Long) { + index = o2Long.compareTo(o1Long); + } else if (o1.get(this.byFieldToBeSorted) instanceof Double o1Double + && o2.get(this.byFieldToBeSorted) instanceof Double o2Double) { + index = o2Double.compareTo(o1Double); + } + return index; + } + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperForSequence.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperForSequence.java new file mode 100644 index 0000000..2083b1e --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperForSequence.java @@ -0,0 +1,564 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.parser; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; + +/** + * class EventWrapperForSequence implements EventWrapperBase. + */ +public class EventWrapperForSequence implements EventWrapperBase { + + /** The event data. */ + private List eventData; + + /** The parse exception. */ + private Exception parseException; + + /** The raw event. */ + private byte[] rawEvent; + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(EventWrapperForSequence.class); + + /** The Constant NAME_PATTERN. */ + private static final Pattern NAME_PATTERN = Pattern.compile("\\."); + + /** The Constant QUALIFIER_SEP_PATTERN. */ + private static final Pattern QUALIFIER_SEP_PATTERN = Pattern.compile("\\,"); + + /** The Constant COMPILED_QUALIFIERS. */ + private static final ConcurrentHashMap>> COMPILED_QUALIFIERS = + new ConcurrentHashMap<>(); + + /** The Constant OBJECT_MAPPER. */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * Instantiates a new event wrapper for sequence. + * + * @param events the events + */ + public EventWrapperForSequence(List events) { + this.eventData = events; + } + + /** + * Instantiates a new event wrapper for sequence. + * + * @param rawEvent the raw event + * @param e the e + */ + public EventWrapperForSequence(byte[] rawEvent, Exception e) { + this.rawEvent = rawEvent; + this.parseException = e; + } + + /** + * Checks if is valid. + * + * @return true, if is valid + */ + @Override + public boolean isValid() { + return parseException == null; + } + + /** + * Gets the parses the exception. + * + * @return the parses the exception + */ + @Override + public Exception getParseException() { + return parseException; + } + + /** + * Gets the raw event. + * + * @return the raw event + */ + @Override + public byte[] getRawEvent() { + return rawEvent; + } + + /** + * Can qualify the results of navigating a path with additional operators. + * For ex data[EventID=EngineRPM].Data.value will look up the + * event object for 'data' the result of which is filtered for + * the expr 'EventID=EngineRPM'; the result of which is looked up for 'Data' + * followed by 'value' and the final result is returned. + * + * @param name name + * @return Object + */ + @Override + public Object getPropertyByExpr(String name) { + if (eventData == null) { + return null; + } + String[] splits = NAME_PATTERN.split(name); + List> qualifiers = getQualifiers(name, splits); + clearQualifiers(splits); + + Object ret = eventData; + for (int i = 0; i < splits.length; i++) { + if (ret instanceof List) { + Optional qlist = qualifiers.get(i); + if (ret instanceof List list && qlist.isPresent()) { + List retList = list; + if (!retList.isEmpty()) { + ret = getRetObject(splits, ret, i, qlist, retList); + } + } + } else if (ret instanceof Map map) { + ret = map.get(splits[i]); + } + if (ret == null) { + break; + } + } + return ret; + + } + + /** + * Return one event after sorting by sorting criteria. Ex : Say we + * have multiple EID=Location events in the message, and we want the + * Location event which has the latest TimeStamp. + * + * @param name the name + * @param byFieldToBeSorted : Ex : TimeStamp, Version + * @param sort : ascending/descending + * @return Map + * : ascending or descending + */ + @Override + public Object getPropertyByExpr(String name, String byFieldToBeSorted, + String sort) { + if (this.eventData == null) { + return null; + } + String[] splits = NAME_PATTERN.split(name); + List> qualifiers = this.getQualifiers(name, + splits); + this.clearQualifiers(splits); + Object ret = this.eventData; + for (int i = 0; i < splits.length; ++i) { + if (ret instanceof Map map) { + ret = map.get(splits[i]); + Optional qlist = qualifiers.get(i); + if (ret instanceof List list && qlist.isPresent()) { + ret = this.getEventBySortedField(list, qlist.get(), + byFieldToBeSorted, sort); + } + } else if (ret instanceof List s) { + ArrayList l = new ArrayList<>(s.size()); + Iterator it = s.iterator(); + while (it.hasNext()) { + l.add(((Map) it.next()).get(splits[i])); + } + ret = l; + } + if (ret == null) { + break; + } + } + return ret; + } + + /** + * Gets the ret object. + * + * @param splits the splits + * @param ret the ret + * @param i the i + * @param qlist the qlist + * @param retList the ret list + * @return the ret object + */ + private Object getRetObject(String[] splits, Object ret, int i, Optional qlist, List retList) { + if (retList.get(0) instanceof Map && qlist.isPresent()) { + List> flist = filterList((List) ret, qlist.get()); + ret = (flist.size() == 1) ? flist.get(0) : flist; + } else { + List s = (List) ret; + List l = new ArrayList<>(s.size()); + Iterator it = s.iterator(); + while (it.hasNext()) { + l.add(((Map) it.next()).get(splits[i])); + } + ret = l; + } + return ret; + } + + /** + * Gets the property. + * + * @param name the name + * @return the property + */ + @Override + public Object getProperty(String name) { + throw new UnsupportedOperationException(); + } + + /** + * Gets the property. + * + * @param wrapper the wrapper + * @param name the name + * @return the property + */ + @Override + public Object getProperty(Map wrapper, String name) { + if (wrapper == null) { + return null; + } + String[] splits = NAME_PATTERN.split(name); + // optimize for 1-2 levels of nesting + if (splits.length == 1) { + return wrapper.get(splits[0]); + } + if (splits.length == Constants.TWO) { + Object r = wrapper.get(splits[0]); + if (r instanceof Map map) { + return map.get(splits[1]); + } else if (r instanceof List list) { + return ((Map) list.get(0)).get(splits[1]); + } else { + return null; + } + } + // deeper nesting + Object ret = eventData; + for (int i = 0; i < splits.length; i++) { + if (ret instanceof Map map) { + ret = map.get(splits[i]); + } else if (ret instanceof List s) { + List l = new ArrayList<>(s.size()); + Iterator it = s.iterator(); + while (it.hasNext()) { + l.add(((Map) it.next()).get(splits[i])); + } + ret = l; + } + } + return ret; + } + + /** + * Clear qualifiers. + * + * @param splits the splits + */ + private void clearQualifiers(String[] splits) { + for (int i = 0; i < splits.length; i++) { + int qualifierStart = splits[i].indexOf("["); + if (qualifierStart != Constants.NEGATIVE_ONE) { + splits[i] = splits[i].substring(0, qualifierStart); + } + } + } + + /** + * Gets the qualifiers. + * + * @param name the name + * @param splits the splits + * @return the qualifiers + */ + private List> getQualifiers(String name, + String[] splits) { + List> qualifiers = COMPILED_QUALIFIERS.get(name); + if (qualifiers == null) { + qualifiers = buildQualifiers(splits); + COMPILED_QUALIFIERS.putIfAbsent(name, qualifiers); + } + return qualifiers; + } + + /** + * Filter list. + * + * @param retAsList the ret as list + * @param qualifiers the qualifiers + * @return the list + */ + private List> filterList(List retAsList, Qualifier[] qualifiers) { + List> l = new ArrayList<>(); + for (int j = 0; j < retAsList.size(); j++) { + Map v = (Map) retAsList.get(j); + boolean all = true; + for (Qualifier q : qualifiers) { + all = all && (q.matches(v)); + } + if (all) { + l.add(v); + } + } + return l; + } + + + /** + * Return one event after sorting by sorting criteria. Ex : Say we have + * multiple EID=Location events in the message, and we want the Location + * event which has the latest TimeStamp. + + * @param retAsList List + * @param qualifiers Qualifiers + * : + * @param byFieldToBeSorted + * : Ex : TimeStamp, Version + * @param sort + * : ascending/descending + * + * @return A map containing entries sorted by a field + */ + private Map getEventBySortedField(List retAsList, Qualifier[] qualifiers, + String byFieldToBeSorted, String sort) { + Comparator> comparator = "asc".equals(sort) ? new AscendingComparator( + byFieldToBeSorted) : new DescendingComparator(byFieldToBeSorted); + ArrayList> list = new ArrayList<>(); + for (int j = 0; j < retAsList.size(); ++j) { + Map v = (Map) retAsList.get(j); + for (Qualifier q : qualifiers) { + if (q.matches(v)) { + // empty if block + } + list.add(v); + } + } + list.sort(comparator); + return list.get(0); + } + + /** + * Builds the qualifiers. + * + * @param splits the splits + * @return the list + */ + private List> buildQualifiers(String[] splits) { + List> qualifiers = new ArrayList<>(splits.length); + for (int i = 0; i < splits.length; i++) { + int qualifierStart = splits[i].indexOf("["); + if (qualifierStart != Constants.NEGATIVE_ONE) { + String qstring = splits[i].substring(qualifierStart + 1, + splits[i].length() - 1); + String[] qstrings = QUALIFIER_SEP_PATTERN.split(qstring); + Qualifier[] qs = new Qualifier[qstrings.length]; + for (int j = 0; j < qstrings.length; j++) { + qs[j] = new Qualifier(qstrings[j]); + } + qualifiers.add(Optional.of(qs)); + } else { + qualifiers.add(Optional.empty()); + } + } + return qualifiers; + } + + /** + * The Class Qualifier. + */ + private static class Qualifier { + + /** The Constant OPERATORS. */ + private static final Pattern OPERATORS = Pattern.compile("="); + + /** The field. */ + private String field; + + /** The value. */ + private String value; + + /** + * Instantiates a new qualifier. + * + * @param expr the expr + */ + public Qualifier(String expr) { + String[] tokens = OPERATORS.split(expr); + field = tokens[0].trim(); + value = tokens[1].trim(); + } + + /** + * Matches. + * + * @param data the data + * @return true, if successful + */ + public boolean matches(Map data) { + Object v = data.get(field); + if (v == null) { + return false; + } else { + return value.equals(v.toString()); + } + } + } + + /** + * Converts to JSON. + * + * @return the string + */ + public String toJson() { + Map m = new HashMap<>(); + m.put("exception", parseException); + m.put("payload", rawEvent); + try { + return OBJECT_MAPPER.writeValueAsString(m); + } catch (JsonProcessingException e) { + logger.error("Unexpected exception: ", e); // this really shouldn't be happening + return e.getMessage(); + } + } + + /** + * The Class AscendingComparator. + */ + private class AscendingComparator implements Comparator> { + + /** The by field to be sorted. */ + private String byFieldToBeSorted; + + /** + * Instantiates a new ascending comparator. + * + * @param byFieldToBeSorted the by field to be sorted + */ + public AscendingComparator(String byFieldToBeSorted) { + this.byFieldToBeSorted = null; + this.byFieldToBeSorted = byFieldToBeSorted; + } + + /** + * Compare. + * + * @param o1 the o 1 + * @param o2 the o 2 + * @return the int + */ + @Override + public int compare(Map o1, Map o2) { + int index = Constants.NEGATIVE_ONE; + if (o1.get(this.byFieldToBeSorted) instanceof String o1String + && o2.get(this.byFieldToBeSorted) instanceof String o2String) { + index = o1String.compareTo(o2String); + } else if (o1.get(this.byFieldToBeSorted) instanceof Integer o1Integer + && o2.get(this.byFieldToBeSorted) instanceof Integer o2Integer) { + index = o1Integer.compareTo(o2Integer); + } else if (o1.get(this.byFieldToBeSorted) instanceof Long o1Long + && o2.get(this.byFieldToBeSorted) instanceof Long o2Long) { + index = o1Long + .compareTo(o2Long); + } else if (o1.get(this.byFieldToBeSorted) instanceof Double o1Double + && o2.get(this.byFieldToBeSorted) instanceof Double o2Double) { + index = o1Double.compareTo(o2Double); + } + return index; + } + } + + /** + * The Class DescendingComparator. + */ + private class DescendingComparator implements Comparator> { + + /** The by field to be sorted. */ + private String byFieldToBeSorted; + + /** + * Instantiates a new descending comparator. + * + * @param byFieldToBeSorted the by field to be sorted + */ + public DescendingComparator(String byFieldToBeSorted) { + this.byFieldToBeSorted = byFieldToBeSorted; + } + + /** + * Compare. + * + * @param o1 the o 1 + * @param o2 the o 2 + * @return the int + */ + @Override + public int compare(Map o1, Map o2) { + int index = Constants.NEGATIVE_ONE; + if (o1.get(this.byFieldToBeSorted) instanceof String o1String + && o2.get(this.byFieldToBeSorted) instanceof String o2String) { + index = o2String.compareTo(o1String); + } else if (o1.get(this.byFieldToBeSorted) instanceof Integer o1Integer + && o2.get(this.byFieldToBeSorted) instanceof Integer o2Integer) { + index = o2Integer.compareTo(o1Integer); + } else if (o1.get(this.byFieldToBeSorted) instanceof Long o1Long + && o2.get(this.byFieldToBeSorted) instanceof Long o2Long) { + index = o2Long.compareTo(o1Long); + } else if (o1.get(this.byFieldToBeSorted) instanceof Double o1Double + && o2.get(this.byFieldToBeSorted) instanceof Double o2Double) { + index = o2Double.compareTo(o1Double); + } + return index; + } + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/GenericValue.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/GenericValue.java new file mode 100644 index 0000000..826dcb4 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/parser/GenericValue.java @@ -0,0 +1,251 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.parser; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * class {@link GenericValue}. + */ +public class GenericValue { + + /** The object. */ + private Object object; + + /** The type. */ + private Type type; + + /** + * GenericValue(). + * + * @param v v + */ + public GenericValue(Object v) { + this.object = v; + if (object == null) { + type = Type.EMPTY; + } else if (v instanceof String) { + type = Type.STRING; + } else if (v instanceof Number) { + type = Type.NUMBER; + } else if (v instanceof Boolean) { + type = Type.BOOL; + } else if (v instanceof List) { + type = Type.LIST; + } else if (v instanceof Map) { + type = Type.MAP; + } else { + type = Type.STRING; + this.object = v.toString(); + } + } + + /** + * asDouble(). + * + * @return double + * @throws NumberFormatException NumberFormatException + */ + public double asDouble() throws NumberFormatException { + if (type == Type.EMPTY) { + return 0.0D; + } + if (type == Type.NUMBER) { + return ((Number) object).doubleValue(); + } else { + return Double.parseDouble(object.toString()); + } + } + + /** + * Returns a value as double. + + * @param v value. + * @param d A double value. + * @return the value as double. + */ + public static double asDouble(Object v, double d) { + if (v == null) { + return d; + } else if (v instanceof String value) { + try { + return Double.parseDouble(value); + } catch (NumberFormatException nfe) { + if ("nan".equals(value)) { + return Double.NaN; + } + } + } else if (v instanceof Number number) { + return number.doubleValue(); + } + return d; + } + + /** + * asLong(). + * + * @return long + * @throws NumberFormatException NumberFormatException + */ + public long asLong() throws NumberFormatException { + if (type == Type.EMPTY) { + return Constants.LONG_MINUS_ONE; + } + if (type == Type.NUMBER) { + return ((Number) object).longValue(); + } else { + return Long.parseLong(object.toString()); + } + } + + /** + * asLong(). + * + * @param v v + * @param l l + * @return long + */ + public static long asLong(Object v, long l) { + if (v == null) { + return l; + } else if (v instanceof String value) { + return Long.parseLong(value); + } else if (v instanceof Number number) { + return number.longValue(); + } + return l; + } + + /** + * asString(). + * + * @return String + */ + + public String asString() { + if (type == Type.EMPTY) { + return null; + } + if (type == Type.STRING) { + return (String) object; + } else { + return object.toString(); + } + } + + /** + * asBoolean(). + * + * @return boolean + */ + public boolean asBoolean() { + if (type == Type.EMPTY) { + return false; + } + if (type == Type.BOOL) { + return ((Boolean) object).booleanValue(); + } else { + return Boolean.parseBoolean(object.toString()); + } + } + + /** + * asOptionalDouble(). + * + * @return Double + */ + public Optional asOptionalDouble() { + if (type == Type.EMPTY) { + return Optional.empty(); + } + if (type == Type.NUMBER) { + return Optional.of(((Number) object).doubleValue()); + } else { + try { + return Optional.of(Double.valueOf(object.toString())); + } catch (NumberFormatException nfe) { + return Optional.empty(); + } + } + } + + /** + * asOptionalLong(). + * + * @return Long + */ + public Optional asOptionalLong() { + if (type == Type.EMPTY) { + return Optional.empty(); + } + if (type == Type.NUMBER) { + return Optional.of(((Number) object).longValue()); + } else { + try { + return Optional.of(Long.valueOf(object.toString())); + } catch (NumberFormatException nfe) { + return Optional.empty(); + } + } + } + + /** + * The Enum Type. + */ + private enum Type { + + /** The string. */ + STRING, + /** The number. */ + NUMBER, + /** The bool. */ + BOOL, + /** The list. */ + LIST, + /** The map. */ + MAP, + /** The empty. */ + EMPTY; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/platform/IgnitePlatform.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/platform/IgnitePlatform.java new file mode 100644 index 0000000..3ec9c5d --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/platform/IgnitePlatform.java @@ -0,0 +1,61 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.platform; + +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; + +/** + * interface {@link IgnitePlatform}. + */ +public interface IgnitePlatform { + + /** + * platformId. + * + * @param cxt the cxt + * @param arg0 the arg 0 + * @return String + */ + String getPlatformId(StreamProcessingContext, IgniteEvent> cxt, + Record, IgniteEvent> arg0); +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/platform/MqttTopicNameGenerator.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/platform/MqttTopicNameGenerator.java new file mode 100644 index 0000000..4df7695 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/platform/MqttTopicNameGenerator.java @@ -0,0 +1,61 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.platform; + +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; + +import java.util.Optional; + +/** + * Interface for creating MQTT Topic name dynamically. + */ +public interface MqttTopicNameGenerator { + + /** + * Gets the mqtt topic name. + * + * @param key the key + * @param header the header + * @param eventId the event id + * @return the mqtt topic name + */ + Optional getMqttTopicName(IgniteKey key, DeviceMessageHeader header, String eventId); +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/platform/utils/PlatformUtils.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/platform/utils/PlatformUtils.java new file mode 100644 index 0000000..244114b --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/platform/utils/PlatformUtils.java @@ -0,0 +1,93 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.platform.utils; + +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +/** + * {@link PlatformUtils} Util class for {@link scala.compat.Platform}. + */ +@Component +public class PlatformUtils { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(PlatformUtils.class); + + /** The ctx. */ + @Autowired + private ApplicationContext ctx; + + /** + * getInstanceByClassName(): to get instance by class name. + * + * @param canonicalClassName canonicalClassName + * @return Object + */ + public Object getInstanceByClassName(String canonicalClassName) { + logger.info("Attempting to load class {}", canonicalClassName); + Object instance = null; + Class classObject = null; + try { + classObject = getClass().getClassLoader().loadClass(canonicalClassName); + instance = ctx.getBean(classObject); + logger.info("Class {} loaded from spring application context", classObject.getName()); + } catch (Exception ex) { + try { + if (classObject == null) { + throw new IllegalArgumentException("Could not load the class " + canonicalClassName); + } + logger.info("Class {} could not be loaded as spring bean. " + + "Attempting to create new instance.", canonicalClassName); + instance = classObject.getDeclaredConstructor().newInstance(); + } catch (Exception exception) { + String msg = String.format("Class %s could not be loaded. Not found on classpath.%n", + canonicalClassName); + logger.error(msg + ExceptionUtils.getStackTrace(exception)); + throw new IllegalArgumentException(msg); + } + } + return instance; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/DeviceMessagingAgentPostProcessor.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/DeviceMessagingAgentPostProcessor.java new file mode 100644 index 0000000..0e1e256 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/DeviceMessagingAgentPostProcessor.java @@ -0,0 +1,182 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessorFilter; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.handler.DeviceMessagingHandlerChain; +import org.eclipse.ecsp.stream.dma.handler.DeviceStatusBackDoorKafkaConsumer; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.util.Properties; + +/** + * DMA or DeviceMessagingAgent stream processor is responsible for pushing the data to the MQTT topic. + * Event which has to be sent to MQTT topic has to implement DeviceRoutableInterface + */ +@Service +@ConditionalOnProperty(name = PropertyNames.DMA_ENABLED, havingValue = "true") +public class DeviceMessagingAgentPostProcessor implements IgniteEventStreamProcessor, StreamProcessorFilter { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DeviceMessagingAgentPostProcessor.class); + + /** The ctxt. */ + private StreamProcessingContext, IgniteEvent> ctxt; + + /** The dma handler. */ + @Autowired + private DeviceMessagingHandlerChain dmaHandler; + + /** The device status back door kafka consumer. */ + @Autowired + private DeviceStatusBackDoorKafkaConsumer deviceStatusBackDoorKafkaConsumer; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.ctxt = spc; + long currentTime = System.currentTimeMillis(); + String taskId = spc.getTaskID(); + dmaHandler.constructChain(taskId, spc); + long endTime = System.currentTimeMillis(); + logger.info("Time taken to Initialize DeviceMessagingAgentPostProcessor for taskId {} is {} seconds", taskId, + (endTime - currentTime) / Constants.THREAD_SLEEP_TIME_1000); + } + + /** + * Inits the config. + * + * @param props the props + */ + @Override + public void initConfig(Properties props) { + //overridden method + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "DeviceMessagingAgent"; + } + + /** + * Method will be called when data (key-value) is available. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + dmaHandler.handle(kafkaRecord.key(), kafkaRecord.value()); + this.ctxt.forward(kafkaRecord); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + // + } + + /** + * Close. + */ + @Override + public void close() { + logger.info("Closing DeviceMessagingAgentPostProcessor and shutting down DMA backdoor consumer"); + deviceStatusBackDoorKafkaConsumer.shutdown(ctxt); + logger.info("Closing device message handlers"); + dmaHandler.close(); + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + // + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * returns if current stream processor is enabled or not. + * + * @param props props + * @return boolean + */ + @Override + public boolean includeInProcessorChain(Properties props) { + return Boolean.parseBoolean(props.getProperty(PropertyNames.DMA_ENABLED)); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/DeviceMessagingAgentPreProcessor.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/DeviceMessagingAgentPreProcessor.java new file mode 100644 index 0000000..bcd4b27 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/DeviceMessagingAgentPreProcessor.java @@ -0,0 +1,233 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessorFilter; +import org.eclipse.ecsp.analytics.stream.base.exception.InvalidKeyOrValueException; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.InternalCacheConstants; +import org.eclipse.ecsp.analytics.stream.base.utils.ObjectUtils; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMARetryRecordDAOCacheBackedInMemoryImpl; +import org.eclipse.ecsp.stream.dma.dao.key.RetryRecordKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.Properties; + +/** + * DeviceMessagingAgentPreProcessor is responsible to removing events + * from retry map when an acknowledgement is received. + * + * @author avadakkootko + */ +@Service +public class DeviceMessagingAgentPreProcessor implements IgniteEventStreamProcessor, StreamProcessorFilter { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DeviceMessagingAgentPreProcessor.class); + + /** The retry event dao. */ + @Autowired + private DMARetryRecordDAOCacheBackedInMemoryImpl retryEventDao; + + /** The map key. */ + private String mapKey; + + /** The task id. */ + private String taskId; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** + * Inits the. + */ + @PostConstruct + public void init() { + ObjectUtils.requireNonEmpty(serviceName, "Service Name cannot be empty in DMA PreProcessor"); + } + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + this.taskId = spc.getTaskID(); + this.mapKey = RetryRecordKey.getMapKey(serviceName, this.taskId); + logger.info("Initialized DeviceMessagingAgentPreProcessor stream processor and mapKey is {}", mapKey); + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "DeviceMessagingAgentPreProcessor"; + } + + /** + * Check if correlationId is present in retry map. If yes delete it. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + if (null == kafkaRecord) { + logger.error("Input record to DeviceMessagingAgentPreProcessor cannot be null"); + throw new IllegalArgumentException("Input record to DeviceMessagingAgentPreProcessor cannot be null"); + } + IgniteEvent value = kafkaRecord.value(); + IgniteKey key = kafkaRecord.key(); + if (key == null) { + throw new InvalidKeyOrValueException("IgniteKey cannot be null in input record"); + } + logger.info(value, "DeviceMessagingAgentPreProcessor processing event with key {} ", key); + String correlationId = value.getCorrelationId(); + + if (StringUtils.isNotEmpty(correlationId)) { + String igniteKey = (String) key.getKey(); + String retryRecordKeyPart = RetryRecordKey.createVehiclePart(igniteKey, correlationId); + removeEventFromCache(retryRecordKeyPart); + } + this.spc.forward(new Record<>(key, value, System.currentTimeMillis())); + } + + /** + * Removes the event from cache. + * + * @param retryRecordKey the retry record key + */ + private void removeEventFromCache(String retryRecordKey) { + RetryRecordKey retryEventKey = constructKey(retryRecordKey); + retryEventDao.deleteFromMap(mapKey, retryEventKey, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + logger.debug("Deleted retry event with key {} from in-memory map", retryEventKey.convertToString()); + } + + /** + * Construct key. + * + * @param retryRecordKey the retry record key + * @return the retry record key + */ + private RetryRecordKey constructKey(String retryRecordKey) { + return new RetryRecordKey(retryRecordKey, this.taskId); + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + // + } + + /** + * Close. + */ + @Override + public void close() { + // + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + //overridden method + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sets the map key. + * + * @param mapKey the new map key + */ + protected void setMapKey(String mapKey) { + this.mapKey = mapKey; + } + + /** + * returns if current stream processor is enabled or not. + * + * @param props props + * @return boolean + */ + @Override + public boolean includeInProcessorChain(Properties props) { + return Boolean.parseBoolean(props.getProperty(PropertyNames.DMA_ENABLED)); + } + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/MessageFilter.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/MessageFilter.java new file mode 100644 index 0000000..047f07f --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/MessageFilter.java @@ -0,0 +1,60 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; + +/** + * Contract for custom message filter to identify duplicate message. + * + * @author MaKumari + */ +public interface MessageFilter { + + /** + * filter(). + * + * @param igniteKey igniteKey + * @param igniteEvent igniteEvent + * @return key Unique string used as key in ignite cache to identify the dupilcate message + */ + public String filter(IgniteKey igniteKey, IgniteEvent igniteEvent); +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/MessgeFilterAgent.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/MessgeFilterAgent.java new file mode 100644 index 0000000..c391023 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/MessgeFilterAgent.java @@ -0,0 +1,123 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamBaseConstant; +import org.eclipse.ecsp.cache.GetStringRequest; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.cache.PutStringRequest; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * class MessgeFilterAgent. + */ +@ConditionalOnProperty(name = PropertyNames.MSG_FILTER_ENABLED, havingValue = "true") +@Component +public class MessgeFilterAgent { + + /** The Constant LOGGER. */ + private static final IgniteLogger LOGGER = IgniteLoggerFactory.getLogger(MessgeFilterAgent.class); + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + "}") + private String serviceName; + + /** The ttl. */ + @Value("${" + PropertyNames.MSG_FILTER_TTL_MS + ":60000}") + private long ttl; + + /** The cache. */ + @Autowired + private IgniteCache cache; + + /** The message filter. */ + @Autowired + private MessageFilter messageFilter; + + /** + * isDuplicate(). + * + * @param igniteKey igniteKey + * @param igniteEvent igniteEvent + * @return boolean + */ + public boolean isDuplicate(IgniteKey igniteKey, IgniteEvent igniteEvent) { + LOGGER.info(igniteEvent, "MessgeFilterAgent processing for serviceName: {} , igniteKey: {}", + serviceName, igniteKey); + boolean isDuplicateFlag = false; + String cacheKey = StreamBaseConstant.MSG_FILTER + serviceName + StreamBaseConstant.UNDERSCORE + + messageFilter.filter(igniteKey, igniteEvent); + String cacheValue = cache.getString(new GetStringRequest().withKey(cacheKey).withNamespaceEnabled(false)); + LOGGER.debug("Cache value retreived by DuplicateMessgeFilteringAgent: {} for cache key: {}", + cacheValue, cacheKey); + + int duplicateMsgCount = 1; + + PutStringRequest request = new PutStringRequest(); + request.withKey(cacheKey); + + if (cacheValue == null) { + request.withValue(String.valueOf(duplicateMsgCount)); + isDuplicateFlag = false; + } else { + duplicateMsgCount = Integer.parseInt(cacheValue); + request.withValue(String.valueOf(duplicateMsgCount + 1)); + isDuplicateFlag = true; + } + request.withTtlMs(ttl); + request.withNamespaceEnabled(false); + cache.putString(request); + + LOGGER.debug("Cache updated with key:{}, value: {} and ttl: {}", request.getKey(), + request.getValue(), request.getTtlMs()); + + LOGGER.info(igniteEvent, "Returning :{} for MessgeFilterAgent duplicate check for serviceName: {}, " + + "igniteKey: {}", isDuplicateFlag, serviceName, igniteKey); + return isDuplicateFlag; + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/MsgSeqPreProcessor.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/MsgSeqPreProcessor.java new file mode 100644 index 0000000..f60e7ce --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/MsgSeqPreProcessor.java @@ -0,0 +1,439 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.kafka.streams.processor.api.Record; +import org.apache.kafka.streams.state.KeyValueStore; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.SequenceBuffer; +import org.eclipse.ecsp.analytics.stream.base.StreamBaseConstant; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.stores.ObjectStateStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.ObjectUtils; +import org.eclipse.ecsp.analytics.stream.base.utils.Pair; +import org.eclipse.ecsp.analytics.stream.base.utils.ThreadUtils; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.utils.metrics.IgniteErrorCounter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * MsgSeqPreProcessor is preprocessor which is responsible for ordering the messages. + * This Processor is disabled [means msg.seq.time.interval.in.millis's value is not positive] by default. + * Time-based ordering is applicable for a specific time boundary. The + * ordering of the messages are across all Kafka topics and per partition. + */ +public class MsgSeqPreProcessor implements IgniteEventStreamProcessor { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(MsgSeqPreProcessor.class); + + /** The Constant MSG_SEQ_STATE_STORE_NAME. */ + private static final String MSG_SEQ_STATE_STORE_NAME = "msg-seq-state-store"; + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** The state store. */ + private KeyValueStore stateStore; + // SequenceBuffer stores only the list of Keys. Key is nothing but a salted + // unique Id which maps with a IgniteEvent is StateStore. For example, when + // new event comes into this Processor, Processor will store that event in + // StateStore with key as + // ___ + // and that generated key will store in SequenceBuffer. So now + // SequenceBuffer only updates these keys in StateStore frequently and size + // of the Set is also less. + + /** The sequence buffer. */ + private SequenceBuffer sequenceBuffer; + + /** The task id. */ + private String taskId; + + /** The last processed key. */ + private IgniteKey lastProcessedKey; + + /** The msg seq topic name. */ + @Value("${msg.seq.topic.name}") + private String msgSeqTopicName; + + /** The msg seq time int in millis. */ + @Value("${msg.seq.time.interval.in.millis:0}") + private long msgSeqTimeIntInMillis; + + /** The change log enabled. */ + @Value("${msg.seq.state.store.changelog.enabled:true}") + private boolean changeLogEnabled; + + /** The sequence buffer impl class. */ + @Value("${msg.seq.buffer.impl.class}") + private String sequenceBufferImplClass; + + /** The last flush timestamp. */ + private long lastFlushTimestamp; + + /** The exec. */ + private ScheduledExecutorService exec = null; + + /** The error counter. */ + @Autowired + private IgniteErrorCounter errorCounter; + + /** + * At the time of initialization, MsgSeqProcessor spawns the fixed delayed + * Thread which sends the FlushEvent to flush the sequence + * buffer [backed by state store] messages. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + SequenceBuffer storedSequenceBuffer; + this.spc = spc; + this.taskId = spc.getTaskID(); + if (msgSeqTimeIntInMillis > 0) { + LOGGER.info("Initializing Message Ordering Processor"); + ObjectUtils.requireNonEmpty(msgSeqTopicName, "msg.seq.topic.name should be defined"); + try { + this.stateStore = this.spc.getStateStore(MSG_SEQ_STATE_STORE_NAME); + ObjectUtils.requireNonNull(stateStore, "State Store should not be null."); + sequenceBuffer = (SequenceBuffer) getClass().getClassLoader().loadClass(sequenceBufferImplClass) + .getDeclaredConstructor().newInstance(); + storedSequenceBuffer = (SequenceBuffer) stateStore.get(StreamBaseConstant.MSG_SEQ_PREFIX + taskId); + if (storedSequenceBuffer != null) { + sequenceBuffer.init(storedSequenceBuffer); + } + } catch (Exception e) { + throw new IllegalArgumentException(sequenceBufferImplClass + " refers to a class that is not " + + "available on the classpath"); + } + exec = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = Executors.defaultThreadFactory().newThread(r); + t.setDaemon(true); + t.setName("msgseq:" + Thread.currentThread().getName() + ":" + spc.getTaskID() + ":" + + System.currentTimeMillis()); + return t; + }); + + exec.scheduleWithFixedDelay(() -> { + try { + sendFlushEvent(); + } catch (Exception e) { + errorCounter.incErrorCounter(Optional.ofNullable(taskId), e.getClass()); + LOGGER.warn("Error while sending Flush Event.", e); + } + }, msgSeqTimeIntInMillis, msgSeqTimeIntInMillis, TimeUnit.MILLISECONDS); + } else { + LOGGER.info("Message ordering is disabled, because msgSeqTimeIntInMilis {} [zero]", msgSeqTimeIntInMillis); + } + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "msg-seq-pre-processor"; + } + + /** + * Sets the msg seq time int in millis. + * + * @param msgSeqTimeIntInMillis the new msg seq time int in millis + */ + void setMsgSeqTimeIntInMillis(long msgSeqTimeIntInMillis) { + this.msgSeqTimeIntInMillis = msgSeqTimeIntInMillis; + } + + /** + * Sets the state store. + * + * @param stateStore the state store + */ + void setStateStore(HarmanPersistentKVStore stateStore) { + this.stateStore = stateStore; + } + + /** + * Sets the sequence buffer impl class. + * + * @param sequenceBufferImplClass the new sequence buffer impl class + */ + void setSequenceBufferImplClass(String sequenceBufferImplClass) { + this.sequenceBufferImplClass = sequenceBufferImplClass; + } + + /** + * Sets the msg seq topic name. + * + * @param msgSeqTopicName the new msg seq topic name + */ + void setMsgSeqTopicName(String msgSeqTopicName) { + this.msgSeqTopicName = msgSeqTopicName; + } + + /** + * If the event is a FlushEvent, then it forwards all the sequence buffer + * [backed by state-store] messages to the next Processor in the + * chain. Delete those messages from In-memory Cached which are forwarded to next Processor. + * If the event is not a FlushEvent, then it + * stores the message in sequence buffer [backed by state-store]. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + + IgniteKey key = kafkaRecord.key(); + IgniteEvent value = kafkaRecord.value(); + + if (msgSeqTimeIntInMillis == 0) { + LOGGER.trace("Message ordering is disabled, so simply forwarding the Ignite event to next processor."); + spc.forward(kafkaRecord); + return; + } + lastProcessedKey = key; + Long currentProcessingEt = value.getTimestamp(); + long totalFlushEventCount = 0; + if (lastFlushTimestamp > currentProcessingEt) { + LOGGER.warn("Discarding event {} with time {} as it is older than " + + "the last flush event time {}", value, currentProcessingEt, + lastFlushTimestamp); + return; + } + if (value.getEventId().equals(EventID.MSG_SEQ_BUF_FLUSH_EVENT)) { + totalFlushEventCount = 0; + long flushTime = value.getTimestamp(); + + // Get the Time based buckets from sequence buffer + Map> flushEntries = sequenceBuffer.head(flushTime); + LOGGER.debug("Found {} sequence buckets eligible for flushing for timestamp {}", + flushEntries.size(), flushTime); + + // flushing the ordered events one by one + for (Entry> flushEntry : flushEntries.entrySet()) { + Long flushBucketTime = flushEntry.getKey(); + List flushSequenceEventKeyIds = flushEntry.getValue(); + LOGGER.debug("Flushing {} events for timestamp {}", flushSequenceEventKeyIds.size(), flushBucketTime); + for (String storedEventKeyId : flushSequenceEventKeyIds) { + // StoreEventKeyId is the Id which points to the actual + // IgniteKey and IgniteEvent in StateStore. So get the + // stored IgniteEvent from StateStore. + Pair, IgniteEvent> data = (Pair) stateStore.get(storedEventKeyId); + if (data != null) { + // Forwarding the cached IgniteEvent to down stream processor + spc.forward(new Record<>(data.getA(), data.getB(), kafkaRecord.timestamp())); + totalFlushEventCount++; + + // Remove the all time based keys from Cache + sequenceBuffer.remove(flushBucketTime, storedEventKeyId); + + // Flush Event arrives, add event has been forwarded to + // next processor, so need to update sequence buffer in + // state store + storeSequenceBuffer(); + + // Now removing the IgniteEvent from StateStore. + stateStore.delete(storedEventKeyId); + LOGGER.debug("Flushed key {} for timestamp {} from sequence buffer " + + "and deleted from state store", storedEventKeyId, + flushBucketTime); + } else { + LOGGER.warn("Event to be flushed not found in state store. Event keyId is {}", + storedEventKeyId); + } + } + // Removing Sequence key from sequence buffer + sequenceBuffer.removeSequence(flushBucketTime); + // Sequence key was removed, so updating the state store + storeSequenceBuffer(); + } + LOGGER.debug("Successfully flushed {} from sequence buffer", totalFlushEventCount); + } else { + addToSequenceBuffer(key, value); + } + + } + + /** + * Adds the to sequence buffer. + * + * @param key the key + * @param value the value + */ + private void addToSequenceBuffer(IgniteKey key, IgniteEvent value) { + StringBuilder keyIdBuffer = new StringBuilder(); + keyIdBuffer.append(System.nanoTime()).append(Constants.HYPHEN); + keyIdBuffer.append(value.getSourceDeviceId()).append(Constants.HYPHEN); + keyIdBuffer.append(value.getEventId()).append(Constants.HYPHEN); + keyIdBuffer.append(System.currentTimeMillis()); + String keyId = keyIdBuffer.toString(); + Pair, IgniteEvent> pair = new Pair<>(key, value); + // Updating sequence buffer + sequenceBuffer.add(keyId, pair); + // Either General Event is added to sequence buffer need to store + // sequence buffer in state store + storeSequenceBuffer(); + // store the pair in state store + stateStore.put(keyId, pair); + LOGGER.trace("keyId {} is added in sequence buffer {}", keyId, pair); + } + + /** + * Store sequence buffer. + */ + private void storeSequenceBuffer() { + sequenceBuffer.setLastFlushTimestamp(System.currentTimeMillis()); + stateStore.put(StreamBaseConstant.MSG_SEQ_PREFIX + taskId, sequenceBuffer); + LOGGER.debug("Sequence buffer is updated in state store"); + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + //overridden method + } + + /** + * Close. + */ + @Override + public void close() { + if (exec != null && !exec.isShutdown()) { + ThreadUtils.shutdownExecutor(exec, Constants.THREAD_SLEEP_TIME_10000, true); + LOGGER.info("pool thread shutdown completed for {} ", Thread.currentThread().getName()); + } + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + //overridden method + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return new ObjectStateStore(MSG_SEQ_STATE_STORE_NAME, changeLogEnabled); + } + + /** + * Send flush event. + */ + private void sendFlushEvent() { + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Sending FlushEvent for the taskId:{}", taskId); + } + IgniteEvent flushEvent = createFlushEvent(); + // To forward a FlushEvent, Processor needs a Key. So key is picked + // by two ways: [Statestore has some values to process] 1) if it is not + // set yet, but sequenceBuffer (backed by statestore) has the entries, + // so it picks the first key from StateStore. [use case, first time + // boot] 2) It is set by process method. The first key which comes from + // kafka source topic. + if (lastProcessedKey == null && sequenceBuffer != null) { + Pair, IgniteEvent> firstData = + (Pair, IgniteEvent>) stateStore.get(sequenceBuffer.head()); + if (firstData != null) { + lastProcessedKey = firstData.getA(); + } + } + + if (lastProcessedKey != null) { + LOGGER.debug("lastProcessedKey {} is found, so sending Flushevent.", lastProcessedKey); + spc.forwardDirectly(lastProcessedKey, flushEvent, msgSeqTopicName); + } else { + LOGGER.warn("Could not send flush event as lastProcessedKey couldn't be determined"); + } + + } + + /** + * Creates the flush event. + * + * @return the ignite event + */ + private IgniteEvent createFlushEvent() { + IgniteEventImpl igniteEventImpl = new IgniteEventImpl(); + igniteEventImpl.setEventId(EventID.MSG_SEQ_BUF_FLUSH_EVENT); + // Set the time interval which will be used for flushing those many + // events from the treemap. + // To insure the ordering for a particular interval, need to hold the + // events for just double interval. So that the boundary event should + // also ordered. That is why the Flush event's timestamp is just behind + // the configured interval time. + igniteEventImpl.setTimestamp(System.currentTimeMillis() - msgSeqTimeIntInMillis); + igniteEventImpl.setVersion(Version.V1_0); + return igniteEventImpl; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/ProtocolTranslatorPostProcessor.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/ProtocolTranslatorPostProcessor.java new file mode 100644 index 0000000..e14f190 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/ProtocolTranslatorPostProcessor.java @@ -0,0 +1,301 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.commons.lang3.StringUtils; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.exception.InvalidKeyOrValueException; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.threadlocal.ContextKey; +import org.eclipse.ecsp.analytics.stream.threadlocal.TaskContextHandler; +import org.eclipse.ecsp.domain.IgniteEventSource; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.transform.IgniteKeyTransformer; +import org.eclipse.ecsp.transform.Transformer; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; + +import java.lang.reflect.InvocationTargetException; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; + +/** + * ProtocolTranslatorPostProcessor is one of the post processors + * who intercepts the IgniteEvent and IgniteKey and converts it to byte array + * format before pushing it to the streaming brocker (eg : kafka). + * + * @author avadakkootko + * @param the generic type + */ + +public class ProtocolTranslatorPostProcessor implements StreamProcessor, IgniteEvent, byte[], byte[]> { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(ProtocolTranslatorPostProcessor.class); + + /** The spc. */ + private StreamProcessingContext spc; + + /** The handler. */ + private TaskContextHandler handler; + + /** The task id. */ + private String taskId; + + /** The transformer map. */ + private Map transformerMap = new HashMap<>(); + + /** The ignite key transformer. */ + private Optional> igniteKeyTransformer; + + /** The ctx. */ + @Autowired + private ApplicationContext ctx; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + taskId = spc.getTaskID(); + handler = TaskContextHandler.getTaskContextHandler(); + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "protocol-translator-post-processor"; + } + + /** + * In the process method IgniteKey and IgniteEvent is received which + * is then converted to byte[] format to push to the streaming + * broker. + * IgniteEventTransformer and IgniteKeyTransformer is mandatory. + * Hence if it is not available at initialization, run time exception is + * thrown and process is exited. This is the same reason why a default transformer is not used. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + if (null == kafkaRecord) { + logger.error("Input record to ProtocolTranslatorPostProcessor cannot be null"); + throw new IllegalArgumentException("Input record to ProtocolTranslatorPostProcessor cannot be null"); + } + IgniteKey key = kafkaRecord.key(); + IgniteEvent value = kafkaRecord.value(); + Optional sinkTopic = handler.getValue(taskId, ContextKey.KAFKA_SINK_TOPIC); + if (sinkTopic.isPresent()) { + if (key != null && value != null) { + logger.debug(value, "Received key {}, value {} in ProtocolTranslatorPostProcessor", key, value); + + byte[] keyInbytes = null; + byte[] msgInBytes = null; + // igniteKeyTransformer availability and validation done in + // igniteconfig + keyInbytes = igniteKeyTransformer.get().toBlob(key); + if (keyInbytes == null) { + logger.error("IgniteKeyTransformer returned null key for igniteKey : {}", key); + throw new InvalidKeyOrValueException("IgniteKeyTransformer returned null key for " + + "igniteKey : " + key); + } + + // ProtocolPosttranslator always uses BlobSource Ignite + Transformer trans = transformerMap.get(IgniteEventSource.IGNITE); + msgInBytes = getMsgInBytes(value, trans); + if (msgInBytes == null) { + logger.error( + "Transformed value in ProtocolTranslatorPostProcessor " + + "from igniteEvent cannot be null for key {}, value {}", + key, value); + throw new InvalidKeyOrValueException( + "Transformed value in ProtocolTranslatorPostProcessor from igniteEvent cannot be null"); + } + String topic = sinkTopic.get().toString(); + logger.debug(value, "Ignite Key {} and Ignite Value {} after " + + "conversion to byte array is being forwarded to topic {}", key, + value, topic); + spc.forward(new Record<>(keyInbytes, msgInBytes, kafkaRecord.timestamp()), topic); + } else { + logger.error("key or value to be processed in ProtocolTranslatorPostProcessor cannot be null"); + throw new InvalidKeyOrValueException("key or value to be processed in ProtocolTranslatorPostProcessor " + + "cannot be null"); + } + } + + } + + /** + * Gets the msg in bytes. + * + * @param value the value + * @param trans the trans + * @return the msg in bytes + */ + private static byte[] getMsgInBytes(IgniteEvent value, Transformer trans) { + byte[] msgInBytes; + if (trans != null) { + msgInBytes = trans.toBlob(value); + } else { + logger.error("Transformer Implementation for IgniteEvent to byte[] with source IGNITE is missing"); + throw new InvalidKeyOrValueException( + "Transformer Implementation for IgniteEvent to byte[] with source IGNITE is missing"); + } + return msgInBytes; + } + + /** + * Inits the config. + * + * @param properties the properties + */ + @Override + public void initConfig(Properties properties) { + String transformerList = properties.getProperty(PropertyNames.EVENT_TRANSFORMER_CLASSES); + String igniteKeyTransformerImpl = properties.getProperty(PropertyNames.IGNITE_KEY_TRANSFORMER); + + if (StringUtils.isBlank(transformerList)) { + logger.error("Event transformer list cannot be blank"); + throw new IllegalArgumentException("Event transformer list cannot be blank"); + } + + if (StringUtils.isBlank(igniteKeyTransformerImpl)) { + logger.error("Ignite key transformer cannot be blank"); + throw new IllegalArgumentException("Ignite key transformer cannot be blank"); + } + + logger.info("Event transformer List from property file : {}", transformerList); + logger.info("Ignite key transformer from property file : {}", igniteKeyTransformerImpl); + + IgniteKeyTransformer kt = null; + + // Initialize transformers from List + String[] transformerArr = transformerList.split(","); + boolean transformerInjectPropertyFlg = + Boolean.parseBoolean(properties.getProperty(PropertyNames.TRANSFORMER_INJECT_PROPERTY_ENABLE)); + for (String transformer : transformerArr) { + Transformer t = null; + // Flag which will ensure that the instances of transformers + // created via runtime class loader( Non Spring Based Bean + // creation) will have the properties available to it via the + // parameterized constructor. + if (transformerInjectPropertyFlg) { + try { + logger.info("Loading parameterized constructor for transformer"); + t = (Transformer) ctx.getAutowireCapableBeanFactory().getBean(transformer, properties); + } catch (Exception ex) { + throw new IllegalArgumentException( + transformer + " parameterized constructor is not available to accept Properties."); + } + } else { + t = (Transformer) ctx.getAutowireCapableBeanFactory().getBean(transformer); + } + + transformerMap.put(t.getSource(), t); + } + // Initialize IgniteKeyTransformer is present + if (StringUtils.isNotBlank(igniteKeyTransformerImpl)) { + try { + kt = (IgniteKeyTransformer) getClass().getClassLoader().loadClass(igniteKeyTransformerImpl) + .getDeclaredConstructor().newInstance(); + } catch (InstantiationException | IllegalAccessException + | ClassNotFoundException | InvocationTargetException | NoSuchMethodException e) { + logger.error(PropertyNames.IGNITE_KEY_TRANSFORMER + " refers to a class that is " + + "not available on the classpath"); + } + } + igniteKeyTransformer = Optional.ofNullable(kt); + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + //overridden method + } + + /** + * Close. + */ + @Override + public void close() { + //overridden method + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + //overridden method + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/ProtocolTranslatorPreProcessor.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/ProtocolTranslatorPreProcessor.java new file mode 100644 index 0000000..1d3cbba --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/ProtocolTranslatorPreProcessor.java @@ -0,0 +1,887 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import io.prometheus.client.CollectorRegistry; +import io.prometheus.client.Gauge; +import io.prometheus.client.Histogram; +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.Headers; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.exception.InvalidKeyOrValueException; +import org.eclipse.ecsp.analytics.stream.base.exception.InvalidServiceNameException; +import org.eclipse.ecsp.analytics.stream.base.platform.IgnitePlatform; +import org.eclipse.ecsp.analytics.stream.base.platform.utils.PlatformUtils; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.vehicleprofile.utils.VehicleProfileClientApiUtil; +import org.eclipse.ecsp.domain.AbstractBlobEventData; +import org.eclipse.ecsp.domain.DataUsageEventDataV1_0; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.IgniteEventSource; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.AbstractIgniteEvent; +import org.eclipse.ecsp.entities.EventData; +import org.eclipse.ecsp.entities.IgniteBlobEvent; +import org.eclipse.ecsp.entities.IgniteDeviceAwareBlobEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.serializer.IngestionSerializer; +import org.eclipse.ecsp.serializer.IngestionSerializerFactory; +import org.eclipse.ecsp.transform.IgniteKeyTransformer; +import org.eclipse.ecsp.transform.Transformer; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; + +import java.lang.reflect.InvocationTargetException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.stream.Stream; + +/** + * ProtocolTranslatorPreProcessor is one of the pre processors who intercepts + * the message and key in byte array format and converts it to an IgniteEvent + * and IgniteKey which is then forwarded to the processors ahead of it. + * + * @author avadakkootko + * + */ + +public class ProtocolTranslatorPreProcessor implements StreamProcessor, IgniteEvent> { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** The serializer. */ + private IngestionSerializer serializer; + + /** The transformer map. */ + private Map transformerMap = new HashMap<>(); + + /** The ignite key transformer. */ + @SuppressWarnings("rawtypes") + private Optional igniteKeyTransformer; + + /** The service name. */ + @Value("${service.name:}") + private String serviceName; + + /** The node name. */ + @Value("${" + PropertyNames.NODE_NAME + ":}") + private String nodeName; + + /** The enable prometheus. */ + @Value("${" + PropertyNames.ENABLE_PROMETHEUS + "}") + private boolean enablePrometheus; + + /** The enable data consumption metric. */ + @Value("${prometheus.data.consumption.metric.enabled:false}") + private boolean enableDataConsumptionMetric; + + /** The histogram buckets. */ + @Value("#{'${prometheus.histogram.buckets}'.split(',')}") + private double[] histogramBuckets; + + /** The data consumption histogram buckets. */ + @Value("#{'${prometheus.data.consumption.metric.buckets}'.split(',')}") + private double[] dataConsumptionHistogramBuckets; + + /** The device aware enable. */ + @Value("${device.aware.enabled:false}") + private boolean deviceAwareEnable; + + /** The histogram. */ + private static volatile Histogram histogram; + + /** The histogram inbound latency. */ + private static volatile Histogram histogramInboundLatency; + + /** The service data consumtion metrics. */ + private static volatile Histogram serviceDataConsumtionMetrics; + + /** The uptime. */ + private static volatile Gauge uptime; + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(ProtocolTranslatorPreProcessor.class); + + /** The Constant LOCK. */ + private static final Object LOCK = new Object(); + + /** The ctx. */ + @Autowired + private ApplicationContext ctx; + + /** The vehicle profile client api util. */ + @Autowired + private VehicleProfileClientApiUtil vehicleProfileClientApiUtil; + + /** The platform utils. */ + @Autowired + private PlatformUtils platformUtils; + + /** The platform id service impl. */ + @Value("${" + PropertyNames.IGNITE_PLATFORM_SERVICE_IMPL_CLASS_NAME + ":}") + private String platformIdServiceImpl; + + /** The duplicate messge filtering agent. */ + private MessgeFilterAgent duplicateMessgeFilteringAgent; + + /** The is enable duplicate message check. */ + @Value("${" + PropertyNames.MSG_FILTER_ENABLED + ":false}") + private boolean isEnableDuplicateMessageCheck; + + /** The is kafka data consumption metrics enabled. */ + //enabling streaming of event size to kafka for analytics dashboard RTC-301848 + @Value("${" + PropertyNames.KAFKA_DATA_CONSUMPTION_METRICS + ":false}") + private boolean isKafkaDataConsumptionMetricsEnabled; + + /** The data usage topic. */ + @Value("${" + PropertyNames.KAFKA_DATA_CONSUMPTION_METRICS_KAFKA_TOPIC + ":}") + private String dataUsageTopic; + + /** The kafka headers enabled. */ + @Value("${" + PropertyNames.KAFKA_HEADERS_ENABLED + ":false}") + private boolean kafkaHeadersEnabled; + + /** The kafka topic name platform prefixes. */ + @Value("#{'${" + PropertyNames.KAFKA_TOPIC_NAME_PLATFORM_PREFIXES + ":}'.split(',')}") + protected List kafkaTopicNamePlatformPrefixes; + + /** The vehicle profile platform ids. */ + @Value("#{'${" + PropertyNames.VEHICLE_PROFILE_PLATFORM_IDS + ":}'.split(',')}") + private List vehicleProfilePlatformIds; + + /** The ignite event. */ + private IgniteEvent igniteEvent = null; + + /** The transformer source. */ + private String transformerSource = null; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + logger.debug("enablePrometheus {}", enablePrometheus); + logger.info("{} is configured as {}", PropertyNames.KAFKA_TOPIC_NAME_PLATFORM_PREFIXES, + kafkaTopicNamePlatformPrefixes); + logger.info("{} is configured as {}", PropertyNames.VEHICLE_PROFILE_PLATFORM_IDS, vehicleProfilePlatformIds); + if ((null == histogram || null == uptime) && enablePrometheus) { + synchronized (LOCK) { + if (null == histogram) { + histogram = Histogram + .build("event_processing_duration_ms", "event_processing_duration_ms") + .labelNames("svc", "tid", "node") + .buckets(histogramBuckets) + .register(CollectorRegistry.defaultRegistry); + } + if (null == histogramInboundLatency) { + histogramInboundLatency = Histogram + .build("inbound_latency", "inbound_latency") + .labelNames("svc", "tid", "node") + .buckets(histogramBuckets) + .register(CollectorRegistry.defaultRegistry); + } + if (null == uptime) { + uptime = Gauge.build("uptime", "uptime") + .labelNames("svc", "node") + .register(CollectorRegistry.defaultRegistry); + } + if (enableDataConsumptionMetric && null == serviceDataConsumtionMetrics) { + serviceDataConsumtionMetrics = Histogram + .build("service_data_consumption", "service_data_consumption") + .labelNames("svc", "event", "node") + .buckets(dataConsumptionHistogramBuckets) + .register(CollectorRegistry.defaultRegistry); + } + } + } + if (isEnableDuplicateMessageCheck) { + duplicateMessgeFilteringAgent = ctx.getBean(MessgeFilterAgent.class); + } + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "protocol-translator-pre-processor"; + } + + /** + * In process method first it identifies using DataSniffer class if it is + * json serialized or java serialized. + * If json serialized then IgniteEventTransformer Implementation is invoked + * to convert byte[] to IgniteEvent + * If java serialized then OemProtocolTransformer is invoked to convert + * byte[] to IgniteEvent. + * NOTE : IgniteKeyTransformer is mandatory unlike the above two + * transformers. This is because usage of other two transformers is known + * only at runtime unline IgniteKeyTransformer. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record kafkaRecord) { + + if (null == kafkaRecord) { + logger.error("Input record to ProtocolTranslatorPreProcessor cannot be null"); + throw new IllegalArgumentException("Input record to ProtocolTranslatorPreProcessor cannot be null"); + } + + byte[] key = kafkaRecord.key(); + byte[] value = kafkaRecord.value(); + + Histogram.Timer htimer = getHistogramTimer(); + + if (key != null && value != null) { + validateAndForwardEvent(kafkaRecord, key, value); + if (null != htimer && enablePrometheus) { + double time = htimer.observeDuration(); + logger.debug("Time taken to complete event : {}", time); + } + } else { + logger.error("key or value to ProtocolTranslatorPreProcessor cannot be null"); + if (null != htimer && enablePrometheus) { + double time = htimer.observeDuration(); + logger.debug("Time taken to complete event : {}", time); + } + throw new IllegalArgumentException("key or value to ProtocolTranslatorPreProcessor cannot be null"); + } + } + + /** + * Validate and forward event. + * + * @param kafkaRecord the kafka record + * @param key the key + * @param value the value + */ + private void validateAndForwardEvent(Record kafkaRecord, byte[] key, byte[] value) { + Map kafkaHeaders; + IgniteKey igniteKey = null; + // If igniteKeyTransformer if present + if (igniteKeyTransformer.isPresent()) { + igniteKey = igniteKeyTransformer.get().fromBlob(key); + } + verifyIgniteKey(key, igniteKey); + kafkaHeaders = getKafkaHeadersIfEnabled(Arrays.toString(kafkaRecord.key()), kafkaRecord); + + // Use serializer to check if byte[] is json serialized or java + // serialized. + if (serializer.isSerialized(value)) { + if (!createIgniteEvent(value, igniteKey)) { + return; + } + } else if (!createIgniteEventForPlatformIfPresent(kafkaHeaders, value, igniteKey)) { + return; + } + verifyAndCollectDataConsumptionMetric(igniteEvent, igniteKey); + setKafkaHeadersIfEnabled(kafkaHeaders, (AbstractIgniteEvent) igniteEvent); + populatePlatformIdFromServiceImpl(kafkaRecord, igniteKey); + populateVehicleIdFromVehicleProfileApi(); + logger.info("Forwarding key {}, value {} from ProtocolTranslatorPreProcessor", igniteKey, igniteEvent); + spc.forward(new Record<>(igniteKey, igniteEvent, kafkaRecord.timestamp())); + } + + /** + * Verify ignite key. + * + * @param key the key + * @param igniteKey the ignite key + */ + private static void verifyIgniteKey(byte[] key, IgniteKey igniteKey) { + if (igniteKey == null) { + logger.error( + "Transformed IgniteKey in ProtocolTranslatorPreProcessor cannot be null for key byte array {}", + Arrays.toString(key)); + throw new InvalidKeyOrValueException("Transformed IgniteKey in ProtocolTranslatorPreProcessor " + + "cannot be null for key byte array {}" + Arrays.toString(key)); + } + } + + /** + * Gets the histogram timer. + * + * @return the histogram timer + */ + private Histogram.Timer getHistogramTimer() { + Histogram.Timer htimer = null; + if (enablePrometheus) { + htimer = histogram.labels(serviceName, spc.getTaskID(), nodeName).startTimer(); + uptime.labels(serviceName, nodeName).set(1); + } + return htimer; + } + + /** + * Creates the ignite event. + * + * @param value the value + * @param igniteKey the ignite key + * @return true, if successful + */ + private boolean createIgniteEvent(byte[] value, IgniteKey igniteKey) { + IgniteDeviceAwareBlobEvent igniteDeviceAwareBlobEvent = null; + IgniteBlobEvent blobEvent = null; + EventData eventData = null; + if (deviceAwareEnable) { + igniteDeviceAwareBlobEvent = getIgniteDeviceAwareBlobEvent(value, igniteKey, igniteDeviceAwareBlobEvent); + eventData = igniteDeviceAwareBlobEvent.getEventData(); + } else { + blobEvent = getIgniteBlobEvent(value, igniteKey, blobEvent); + eventData = blobEvent.getEventData(); + } + + if (eventData == null) { + logger.error("Event Data is not present in IgniteBlobEvent {} with key {}", blobEvent, igniteKey); + return false; + } + AbstractBlobEventData blobEventData = (AbstractBlobEventData) eventData; + transformerSource = blobEventData.getEventSource(); + if (StringUtils.isEmpty(transformerSource)) { + logger.error("Transformer Source not set for key {}", igniteKey); + return false; + } + Transformer trans = transformerMap.get(transformerSource); + if (!isTransformerPresent(trans, transformerSource, igniteKey)) { + return false; + } + if (deviceAwareEnable) { + igniteEvent = trans.fromBlob(blobEventData.getPayload(), Optional.ofNullable(igniteDeviceAwareBlobEvent)); + } else { + igniteEvent = trans.fromBlob(blobEventData.getPayload(), Optional.ofNullable(blobEvent)); + } + logger.debug("IgniteEvent after transformation {} for key {}", igniteEvent, igniteKey); + return isIgniteEventNotNull(igniteEvent, igniteKey); + } + + /** + * Checks if is transformer present. + * + * @param trans the trans + * @param source the source + * @param igniteKey the ignite key + * @return true, if is transformer present + */ + private boolean isTransformerPresent(Transformer trans, String source, IgniteKey igniteKey) { + if (trans == null) { + logger.error("Ignite Transformer Implementation is missing for source or platform {} for key {}", + source, igniteKey); + return false; + } + logger.info("Transforming kafka record with key {} to IgniteEvent for source or platform {} using {}", + igniteKey, source, trans.getClass().getName()); + return true; + } + + /** + * Checks if is ignite event not null. + * + * @param igniteEvent the ignite event + * @param igniteKey the ignite key + * @return true, if is ignite event not null + */ + private boolean isIgniteEventNotNull(IgniteEvent igniteEvent, IgniteKey igniteKey) { + if (igniteEvent == null) { + logger.error( + "Transformed IgniteEvent in ProtocolTranslatorPreProcessor cannot be null for key {}", + igniteKey); + return false; + } + return true; + } + + /** + * Gets the ignite blob event. + * + * @param value the value + * @param igniteKey the ignite key + * @param blobEvent the blob event + * @return the ignite blob event + */ + private IgniteBlobEvent getIgniteBlobEvent(byte[] value, IgniteKey igniteKey, IgniteBlobEvent blobEvent) { + try { + blobEvent = serializer.deserialize(value); + } catch (Exception e) { + logger.error("Unable to deserialize data into IgniteBlobEvent! Error is: {}", e); + } + if (blobEvent == null) { + logger.error("Deserialized IgniteBlobEvent for key {} cannot be null", igniteKey); + throw new IllegalArgumentException("Deserialized IgniteBlobEvent cannot be null for key " + igniteKey); + } + logger.debug("IgniteBlobEvent blobEvent {} after sniffing for key {}", blobEvent, igniteKey); + return blobEvent; + } + + /** + * Gets the ignite device aware blob event. + * + * @param value the value + * @param igniteKey the ignite key + * @param igniteDeviceAwareBlobEvent the ignite device aware blob event + * @return the ignite device aware blob event + */ + private IgniteDeviceAwareBlobEvent getIgniteDeviceAwareBlobEvent(byte[] value, IgniteKey igniteKey, + IgniteDeviceAwareBlobEvent igniteDeviceAwareBlobEvent) { + try { + igniteDeviceAwareBlobEvent = (IgniteDeviceAwareBlobEvent) serializer.deserialize(value); + } catch (Exception e) { + logger.error("Blob event class type mismatch. Expected IgniteDeviceAwareBlobEvent!"); + } + if (igniteDeviceAwareBlobEvent == null) { + logger.error("Deserialized IgniteDeviceAwareBlobEvent for key {} cannot be null", igniteKey); + throw new IllegalArgumentException("Deserialized IgniteDeviceAwareBlobEvent cannot be " + + "null for key " + igniteKey); + } + logger.debug("IgniteDeviceAwareBlobEvent igniteDeviceAwareBlobEvent {} after sniffing for key {}", + igniteDeviceAwareBlobEvent, igniteKey); + return igniteDeviceAwareBlobEvent; + } + + /** + * Creates the ignite event for platform if present. + * + * @param kafkaHeaders the kafka headers + * @param value the value + * @param igniteKey the ignite key + * @return true, if successful + */ + private boolean createIgniteEventForPlatformIfPresent(Map kafkaHeaders, byte[] value, + IgniteKey igniteKey) { + Transformer trans = null; + String platformId = null; + if (null != kafkaHeaders && !StringUtils.isEmpty(kafkaHeaders.get(Constants.PLATFORM_ID))) { + platformId = kafkaHeaders.get(Constants.PLATFORM_ID); + logger.info("Retrieved platformId from kafka headers as {} for key {}", platformId, igniteKey); + } else { + String sourceTopicName = this.spc.streamName(); + logger.debug("Attempting to fetch platformID from prefix of source topic name : {}", sourceTopicName); + if (StringUtils.isNotEmpty(sourceTopicName)) { + platformId = getPlatformIdFromTopicPrefix(sourceTopicName); + } + } + if (StringUtils.isNotBlank(platformId)) { + trans = transformerMap.get(platformId); + transformerSource = platformId; + if (!isTransformerPresent(trans, platformId, igniteKey)) { + return false; + } + igniteEvent = trans.fromBlob(value, igniteKey); + } else { + trans = transformerMap.get(IgniteEventSource.IGNITE); + transformerSource = IgniteEventSource.IGNITE; + if (!isTransformerPresent(trans, IgniteEventSource.IGNITE, igniteKey)) { + return false; + } + igniteEvent = trans.fromBlob(value, Optional.empty()); + } + logger.debug("IgniteEvent after transformation {} for key {}", igniteEvent, igniteKey); + + if (!isIgniteEventNotNull(igniteEvent, igniteKey)) { + return false; + } + if (StringUtils.isNotBlank(igniteEvent.getPlatformId())) { + return true; + } + if (StringUtils.isNotBlank(platformId)) { + ((AbstractIgniteEvent) igniteEvent).setPlatformId(platformId); + } + return true; + } + + /** + * Gets the platform id from topic prefix. + * + * @param sourceTopicName the source topic name + * @return the platform id from topic prefix + */ + private String getPlatformIdFromTopicPrefix(String sourceTopicName) { + + String platformId = null; + if (null == kafkaTopicNamePlatformPrefixes || kafkaTopicNamePlatformPrefixes.isEmpty()) { + logger.debug("kafkaTopicNamePlatformPrefixes list is either null, or empty. " + + "No platformId found from Kafka topic name"); + return platformId; + } + for (String platformPrefix : kafkaTopicNamePlatformPrefixes) { + if (!StringUtils.isEmpty(platformPrefix)) { + logger.debug("Checking platformId in source topic name against prefix : {}", platformPrefix); + if (sourceTopicName.toLowerCase().startsWith(platformPrefix.toLowerCase())) { + platformId = platformPrefix; + logger.info("Retrieved platformId from kafka topic name prefix as {}", platformId); + break; + } + } + } + return platformId; + } + + /** + * Verify and collect data consumption metric. + * + * @param igniteEvent the ignite event + * @param igniteKey the ignite key + */ + private void verifyAndCollectDataConsumptionMetric(IgniteEvent igniteEvent, IgniteKey igniteKey) { + //This is for capturing metrics only in Prometheus. + if (enablePrometheus) { + long diff = igniteEvent.getTimestamp() - System.currentTimeMillis(); + histogramInboundLatency.labels(serviceName, spc.getTaskID(), nodeName).observe(diff); + } + + if (isEnableDuplicateMessageCheck) { + boolean isDuplicate = duplicateMessgeFilteringAgent.isDuplicate(igniteKey, igniteEvent); + ((AbstractIgniteEvent) igniteEvent).setDuplicateMessage(isDuplicate); + } + //This is for capturing the size metric in Kafka or Prometheus. + if (isKafkaDataConsumptionMetricsEnabled || enablePrometheus) { + collectDataConsumptionMetric(igniteKey, igniteEvent); + } + } + + /** + * Sets the kafka headers if enabled. + * + * @param kafkaHeaders the kafka headers + * @param igniteEvent the ignite event + */ + private void setKafkaHeadersIfEnabled(Map kafkaHeaders, AbstractIgniteEvent igniteEvent) { + if (kafkaHeadersEnabled) { + igniteEvent.setKafkaHeaders(kafkaHeaders); + } + } + + /** + * Populate platform id from service impl. + * + * @param kafkaRecord the kafka record + * @param igniteKey the ignite key + */ + private void populatePlatformIdFromServiceImpl(Record kafkaRecord, IgniteKey igniteKey) { + if (StringUtils.isEmpty(platformIdServiceImpl)) { + return; + } + IgnitePlatform ignitePlatform = null; + try { + ignitePlatform = (IgnitePlatform) platformUtils.getInstanceByClassName(platformIdServiceImpl); + } catch (IllegalArgumentException e) { + logger.error(PropertyNames.IGNITE_PLATFORM_SERVICE_IMPL_CLASS_NAME + " refers to a class that " + + "is not available on the classpath", e); + } + if (null == ignitePlatform) { + return; + } + String platformId = ignitePlatform.getPlatformId(spc, new Record<>(igniteKey, igniteEvent, + kafkaRecord.timestamp())); + logger.info("Platform ID fetched from class : {} is : {}", platformIdServiceImpl, platformId); + ((AbstractIgniteEvent) igniteEvent).setPlatformId(platformId); + } + + /** + * Populate vehicle id from vehicle profile api. + */ + private void populateVehicleIdFromVehicleProfileApi() { + + String sourceDeviceId = igniteEvent.getSourceDeviceId(); + String platformId = igniteEvent.getPlatformId(); + if (vehicleProfilePlatformIds.contains(platformId) + && StringUtils.isNotEmpty(sourceDeviceId) + && StringUtils.isEmpty(igniteEvent.getVehicleId())) { + logger.debug("Fetching VehicleId from vehicle profile for sourceDeviceId {}, and platformID {} ", + sourceDeviceId, platformId); + String vehicleId = vehicleProfileClientApiUtil.callVehicleProfile(sourceDeviceId); + logger.info("VehicleId {} is fetched from vehicle profile for deviceId {}, and platformID {}", + vehicleId, sourceDeviceId, platformId); + ((AbstractIgniteEvent) igniteEvent).setVehicleId(vehicleId); + } + } + + /** + * Gets the kafka headers if enabled. + * + * @param requestId the request id + * @param inputRecord the input record + * @return the kafka headers if enabled + */ + private Map getKafkaHeadersIfEnabled(String requestId, Record inputRecord) { + Map kafkaHeaders = null; + if (kafkaHeadersEnabled) { + Headers headers = inputRecord.headers(); + logger.debug("Retreived headers for current kafka input record with requestID or igniteKey : {}, " + + "headers : {}", requestId, headers); + if (headers != null) { + kafkaHeaders = new HashMap<>(); + Iterator
    iterator = headers.iterator(); + while (iterator.hasNext()) { + Header header = iterator.next(); + kafkaHeaders.put(header.key(), new String(header.value(), StandardCharsets.UTF_8)); + } + } + } + return kafkaHeaders; + } + + /** + * Collect data consumption metric. + * + * @param igniteKey the ignite key + * @param igniteEvent the ignite event + */ + private void collectDataConsumptionMetric(IgniteKey igniteKey, IgniteEvent igniteEvent) { + Transformer trans = transformerMap.get(transformerSource); + // handle composite-event + if (igniteEvent.getNestedEvents() != null && !igniteEvent.getNestedEvents().isEmpty()) { + for (IgniteEvent event : igniteEvent.getNestedEvents()) { + sampleEventSize(igniteKey, event, trans); + } + } else { + // handle standard-ignite-event + sampleEventSize(igniteKey, igniteEvent, trans); + } + } + + /** + * Sample event size. + * + * @param igniteKey the ignite key + * @param igniteEvent the ignite event + * @param trans the trans + */ + private void sampleEventSize(IgniteKey igniteKey, IgniteEvent igniteEvent, Transformer trans) { + byte[] byteArray = null; + if (enableDataConsumptionMetric || isKafkaDataConsumptionMetricsEnabled) { + byteArray = trans.toBlob(igniteEvent); + } + //Capture only in Prometheus + if (enableDataConsumptionMetric) { + // size in KBs + serviceDataConsumtionMetrics.labels(serviceName, igniteEvent.getEventId(), nodeName) + .observe((double) byteArray.length / Constants.BYTE_1024); + } + //Capture only in Kafka. + if (isKafkaDataConsumptionMetricsEnabled + && Stream.of(EventID.MSG_SEQ_BUF_FLUSH_EVENT, EventID.DFF_FEEDBACK_EVENT, + EventID.DEVICEMESSAGEFAILURE, EventID.IGNITE_EXCEPTION_EVENT) + .noneMatch(igniteEvent.getEventId()::equalsIgnoreCase)) { + //stream event size with eventID and vehicleID + IgniteEvent dataUsageEvent = createDataUsageEvent(igniteEvent, + (double) byteArray.length / Constants.BYTE_1024); + spc.forwardDirectly(igniteKey, dataUsageEvent, dataUsageTopic); + } + } + + /** + * Creates the data usage event. + * + * @param igniteEvent the ignite event + * @param eventSize the event size + * @return the ignite event + */ + private IgniteEvent createDataUsageEvent(IgniteEvent igniteEvent, double eventSize) { + IgniteEventImpl analyticsEvent = new IgniteEventImpl(); + analyticsEvent.setEventId(EventID.DATA_USAGE_EVENT); + analyticsEvent.setTimestamp(System.currentTimeMillis()); + analyticsEvent.setVersion(Version.V1_0); + DataUsageEventDataV1_0 analyticsEventData = new DataUsageEventDataV1_0(); + analyticsEventData.setEventName(igniteEvent.getEventId()); + analyticsEventData.setPayLoadSize(eventSize); + analyticsEventData.setEventTimestamp(igniteEvent.getTimestamp()); + if (!StringUtils.isEmpty(igniteEvent.getVehicleId())) { + analyticsEventData.setVehicleId(igniteEvent.getVehicleId()); + } else { + analyticsEventData.setDeviceId(igniteEvent.getSourceDeviceId()); + } + analyticsEvent.setEventData(analyticsEventData); + return analyticsEvent; + } + + + /** + * initConfig is used to initialize the properties and the transformers + * Note IgniteKeyTransformer is mandatory. + * Unlike IgniteEventTransformer and OemProtocolTransformer whose usage is + * determined at runtime, IgniteKeyTransformer isn't hence if not + * initialized error can be thrown and can exit before initializing. + * + * @param props the props + */ + @Override + public void initConfig(Properties props) { + String transformerList = props.getProperty(PropertyNames.EVENT_TRANSFORMER_CLASSES); + String igniteKeyTransformerImpl = props.getProperty(PropertyNames.IGNITE_KEY_TRANSFORMER); + String igniteSerializationImpl = props.getProperty(PropertyNames.INGESTION_SERIALIZER_CLASS); + + IgniteKeyTransformer kt = null; + + if (StringUtils.isBlank(transformerList)) { + logger.error("Event transformer list cannot be blank"); + throw new IllegalArgumentException("Event transformer list cannot be blank"); + } + + if (StringUtils.isBlank(igniteSerializationImpl)) { + logger.error("Ignite event Serializer cannot be blank"); + throw new IllegalArgumentException("Ignite event Serializer cannot be blank"); + } + + if (StringUtils.isBlank(igniteKeyTransformerImpl)) { + logger.error("Ignite key transformer cannot be blank"); + throw new IllegalArgumentException("Ignite key transformer cannot be blank"); + } + + logger.info("Event transformer List from property file : {}", transformerList); + logger.info("Ignite key transformer from property file : {}", igniteKeyTransformerImpl); + logger.info("Ignite event serializer from property file : {}", igniteSerializationImpl); + + serializer = IngestionSerializerFactory.getInstance(igniteSerializationImpl); + // Initialize transformers from List + String[] transformerArr = transformerList.split(","); + boolean transformerInjectPropertyFlg = + Boolean.parseBoolean(props.getProperty(PropertyNames.TRANSFORMER_INJECT_PROPERTY_ENABLE)); + for (String transformer : transformerArr) { + Transformer t = null; + // Flag which will ensure that the instances of transformers + // created via runtime class loader( Non Spring Based Bean + // creation) will have the properties available to it via the + // parameterized constructor. + if (transformerInjectPropertyFlg) { + try { + logger.info("Loading parameterized constructor for transformer"); + t = (Transformer) ctx.getAutowireCapableBeanFactory().getBean(transformer, props); + } catch (Exception ex) { + throw new IllegalArgumentException( + transformer + " parameterized constructor is not available to accept Properties."); + } + } else { + t = (Transformer) ctx.getAutowireCapableBeanFactory().getBean(transformer); + } + logger.info("Adding entry in transformerMap for source : {}, and transformer : {}", + t.getSource(), t.getClass().getName()); + transformerMap.put(t.getSource(), t); + } + + // Initialize IgniteKeyTransformer is present + // If an IgniteKeyTransformer is not present throw an exception and + // exit. + try { + kt = (IgniteKeyTransformer) getClass().getClassLoader().loadClass(igniteKeyTransformerImpl) + .getDeclaredConstructor().newInstance(); + } catch (InstantiationException | IllegalAccessException | ClassNotFoundException e) { + throw new IllegalArgumentException( + PropertyNames.IGNITE_KEY_TRANSFORMER + " refers to a class that is not available on the classpath"); + } catch (InvocationTargetException | NoSuchMethodException e) { + throw new IllegalArgumentException("Failed to invoke class " + PropertyNames.IGNITE_KEY_TRANSFORMER, e); + } + + igniteKeyTransformer = Optional.ofNullable(kt); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + //method + } + + /** + * Close. + */ + @Override + public void close() { + //method + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + // no additional configuration to be applied + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Post initialization of this bean. + */ + @PostConstruct + public void initialize() { + if (StringUtils.isEmpty(serviceName)) { + throw new InvalidServiceNameException("Service Name is unavailable"); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/SchedulerAgentPostProcessor.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/SchedulerAgentPostProcessor.java new file mode 100644 index 0000000..ebb58f4 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/SchedulerAgentPostProcessor.java @@ -0,0 +1,289 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessorFilter; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.domain.DeviceMessageFailureEventDataV1_0; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageErrorCode; +import org.eclipse.ecsp.events.scheduler.CreateScheduleEventData; +import org.eclipse.ecsp.events.scheduler.DeleteScheduleEventData; +import org.eclipse.ecsp.events.scheduler.ScheduleNotificationEventData; +import org.eclipse.ecsp.events.scheduler.ScheduleOpStatusEventData; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntry; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntryDAOMongoImpl; +import org.eclipse.ecsp.stream.dma.handler.DeviceMessageUtils; +import org.eclipse.ecsp.stream.dma.scheduler.DeviceMessagingEventScheduler; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Properties; + +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.SCHEDULER_AGENT_TOPIC_NAME; + +/** + * SchedulerAgentPostProcessor is responsible for accepting create schedule + * and delete schedule events, and forwards them directly to Kafka + * SCHEDULER AGENT topic. It is also responsible for handling scheduler notifications and acknowledgement events. + * Event which has to be sent to Kafka SCHEDULER AGENT topic has to + * be of type {@link CreateScheduleEventData} or {@link DeleteScheduleEventData} , + * otherwise the events are forwarded to next handler in the post processor chain. + * + * @author KJalawadi + */ +@Service +@ConditionalOnProperty(name = PropertyNames.SCHEDULER_ENABLED, havingValue = "true") +public class SchedulerAgentPostProcessor implements IgniteEventStreamProcessor, StreamProcessorFilter { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(SchedulerAgentPostProcessor.class); + + /** The scheduler agent topic. */ + @Value("${" + SCHEDULER_AGENT_TOPIC_NAME + "}") + @NonNull + private String schedulerAgentTopic; + + /** The ttl expiry notification enabled. */ + @Value("${" + PropertyNames.DMA_TTL_EXPIRY_NOTIFICATION_ENABLED + ":true}") + private String ttlExpiryNotificationEnabled; + + /** The dma enabled. */ + @Value("${" + PropertyNames.DMA_ENABLED + ":true}") + private String dmaEnabled; + + /** The remove on ttl expiry enabled. */ + @Value("${" + PropertyNames.DMA_REMOVE_ON_TTL_EXPIRY_ENABLED + ":true}") + private String removeOnTtlExpiryEnabled; + + /** The ctxt. */ + private StreamProcessingContext, IgniteEvent> ctxt; + + /** The offline buffer dao. */ + @Autowired(required = false) + private DMOfflineBufferEntryDAOMongoImpl offlineBufferDao; + + /** The device message utils. */ + @Autowired + private DeviceMessageUtils deviceMessageUtils; + + /** The event scheduler. */ + @Autowired(required = false) + private DeviceMessagingEventScheduler eventScheduler; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.ctxt = spc; + if (Boolean.parseBoolean(ttlExpiryNotificationEnabled) + && Boolean.parseBoolean(dmaEnabled)) { + logger.debug("SchedulerAgentPostProcessor initialized with ttlExpiryNotification and dma enabled."); + removeOfflineBufferEntriesWithExpiredTtl(); + eventScheduler.scheduleEvent(spc); + } + } + + /** + * Inits the config. + * + * @param props the props + */ + @Override + public void initConfig(Properties props) { + // + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "SchedulerAgent"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + IgniteKey key = kafkaRecord.key(); + IgniteEvent value = kafkaRecord.value(); + logger.debug(value, "SchedulerAgentPostProcessor event received is: key={}, value={}", + key.toString(), value.toString()); + if ((EventID.CREATE_SCHEDULE_EVENT.equals(value.getEventId()) + && value.getEventData() instanceof CreateScheduleEventData) + || (EventID.DELETE_SCHEDULE_EVENT.equals(value.getEventId()) + && value.getEventData() instanceof DeleteScheduleEventData)) { + logger.debug(value, "SchedulerAgentPostProcessor forwarding schedule event directly to " + + "ScheduleEventProcessor: " + "key={}, value={}, topic={}", key.toString(), + value.toString(), schedulerAgentTopic); + this.ctxt.forwardDirectly(key, value, schedulerAgentTopic); + } + + // WI-374794 Added handling for ScheduleOpStatusEvent + if (EventID.SCHEDULE_OP_STATUS_EVENT.equals(value.getEventId()) + && value.getEventData() instanceof ScheduleOpStatusEventData) { + logger.debug(value, "SchedulerAgentPostProcessor received " + "acknowledgement for " + + "scheduled event with key={}, " + "value={}", key.toString(), value.toString()); + + ScheduleOpStatusEventData eventData = (ScheduleOpStatusEventData) value.getEventData(); + if (eventData.getStatusErrorCode() != null) { + logger.error(value, "Scheduler service failed to create/delete a scheduler with error code : {}", + eventData.getStatusErrorCode().toString()); + } + } + + // WI-374794 Added handling for ScheduleNotificationEvent + if (EventID.SCHEDULE_NOTIFICATION_EVENT.equals(value.getEventId()) + && value.getEventData() instanceof ScheduleNotificationEventData) { + logger.info(value, "SchedulerAgentPostProcessor received notification for scheduled event with key={}, " + + "value={}", key.toString(), value.toString()); + if (Boolean.parseBoolean(dmaEnabled)) { + removeOfflineBufferEntriesWithExpiredTtl(); + eventScheduler.scheduleEvent(ctxt); + } + } + + logger.debug(value, "SchedulerAgentPostProcessor forwarding event to next post processor in chain: " + + "key={}, value={}", key.toString(), value.toString()); + this.ctxt.forward(kafkaRecord); + } + + /** + * Remove entries from offline buffer collection for which TTL has expired. + * Send failure event to feedback topic for such entries. + */ + private void removeOfflineBufferEntriesWithExpiredTtl() { + List offlineEntries = offlineBufferDao.getOfflineBufferEntriesWithExpiredTtl(); + if (null != offlineEntries && !offlineEntries.isEmpty()) { + + offlineEntries.parallelStream().forEach(offlineEntry -> { + DeviceMessage event = offlineEntry.getEvent(); + DeviceMessageFailureEventDataV1_0 failEventData = new DeviceMessageFailureEventDataV1_0(); + failEventData.setFailedIgniteEvent(event.getEvent()); + failEventData.setErrorCode(DeviceMessageErrorCode.DEVICE_DELIVERY_CUTOFF_EXCEEDED); + failEventData.setDeviceDeliveryCutoffExceeded(true); + deviceMessageUtils.postFailureEvent(failEventData, + offlineEntry.getIgniteKey(), ctxt, event.getFeedBackTopic()); + logger.info("For key {} and value {} cutoff exceeded, " + + "will not send it to device. Failure event posted to {}", + offlineEntry.getIgniteKey(), event, event.getFeedBackTopic()); + if (Boolean.parseBoolean(removeOnTtlExpiryEnabled)) { + offlineBufferDao.removeOfflineBufferEntry(offlineEntry.getId()); + logger.info("Removed offline entry with id {} and key {} with expired TTL", + offlineEntry.getId(), offlineEntry.getIgniteKey()); + } else { + offlineEntry.setTtlNotifProcessed(true); + offlineBufferDao.update(offlineEntry); + } + }); + } + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + // Overridden method + } + + /** + * Close. + */ + @Override + public void close() { + // Overridden method + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + // Overridden method + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * returns if current stream processor is enabled or not. + * + * @param props props + * @return boolean + */ + @Override + public boolean includeInProcessorChain(Properties props) { + return Boolean.parseBoolean(props.getProperty(PropertyNames.SCHEDULER_ENABLED)); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/TaskContextInitializer.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/TaskContextInitializer.java new file mode 100644 index 0000000..e023d0a --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/processors/TaskContextInitializer.java @@ -0,0 +1,169 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.offset.OffsetManager; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.threadlocal.TaskContextHandler; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.Properties; + +/** + * TaskContextInitializer is the first processor in the Processor chaining. The + * + * @author avadakkootko + */ +public class TaskContextInitializer implements StreamProcessor { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(TaskContextInitializer.class); + + /** The spc. */ + private StreamProcessingContext spc; + + /** The handler. */ + private TaskContextHandler handler; + + /** The task id. */ + private String taskId; + + /** The offset manager. */ + @Autowired + private OffsetManager offsetManager; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + handler = TaskContextHandler.getTaskContextHandler(); + taskId = spc.getTaskID(); + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "task-context-initializer"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record kafkaRecord) { + + if (null == kafkaRecord) { + logger.error("Input record to TaskContextInitializer cannot be null"); + return; + } + + byte[] key = kafkaRecord.key(); + byte[] value = kafkaRecord.value(); + long currentOffset = spc.offset(); + String topic = spc.streamName(); + int partition = spc.partition(); + if (!offsetManager.doSkipOffset(topic, partition, currentOffset)) { + if (key == null || value == null) { + logger.error("Input Key {} or value {} cannot be null in TaskContextInitializer", key, value); + return; + } + handler.resetTaskContextValue(taskId); + logger.debug("Resetting handler for taskId {} ", taskId); + spc.forward(kafkaRecord); + offsetManager.updateProcessedOffset(topic, partition, currentOffset); + } + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + //method + } + + /** + * Close. + */ + @Override + public void close() { + logger.debug("Attempting to shutdown offsetmanager executer"); + offsetManager.shutdown(); + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + // no additional configuration to be applied + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @SuppressWarnings("rawtypes") + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheBypass.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheBypass.java new file mode 100644 index 0000000..9cbb246 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheBypass.java @@ -0,0 +1,441 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.concurrent.BasicThreadFactory; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.MutationId; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.ThreadUtils; +import org.eclipse.ecsp.cache.DeleteEntryRequest; +import org.eclipse.ecsp.cache.DeleteMapOfEntitiesRequest; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.cache.PutEntityRequest; +import org.eclipse.ecsp.cache.PutMapOfEntitiesRequest; +import org.eclipse.ecsp.entities.IgniteEntity; +import org.eclipse.ecsp.stream.dma.dao.DMCacheEntityDAOMongoImpl; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.PriorityBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * {@link CacheBypass} class offers a reliable persistence of messages coming + * to DMA to be forwarded to MQTT topic. For eg. DMA stores RetryRecords in redis for its + * Retry feature, and if redis is unavailable, then these records are stored in a + * Queue from where one or more threads will poll records from queue and try to + * complete the redis transaction for each one of them. + * + * @param the key type + * @param the value type + */ +@Component +public class CacheBypass, V extends IgniteEntity> { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(CacheBypass.class); + + /** The queue. */ + private BlockingQueue> queue; + + /** The cache bypass executor service. */ + private ExecutorService cacheBypassExecutorService; + + /** The cache. */ + @Autowired + private IgniteCache cache; + + /** The dm cache entity dao. */ + @Autowired + private DMCacheEntityDAOMongoImpl dmCacheEntityDao; + + /** The queue capacity. */ + @Value("${" + PropertyNames.CACHE_BYPASS_QUEUE_INITIAL_CAPACITY + "}") + private int queueCapacity; + + /** The num cache bypass threads. */ + @Value("${" + PropertyNames.DMA_NUM_CACHE_BYPASS_THREADS + "}") + private int numCacheBypassThreads; + + /** The wait time. */ + @Value("${" + PropertyNames.CACHE_BYPASS_THREADS_SHUTDOWN_WAIT_TIME + "}") + private int waitTime; + + /** The is executor service initialized. */ + private AtomicBoolean isExecutorServiceInitialized = new AtomicBoolean(false); + + /** + * Sets the cache. + * + * @param cache the new cache + */ + public void setCache(IgniteCache cache) { + this.cache = cache; + } + + /** + * Gets the queue. + * + * @return the queue + */ + public Queue> getQueue() { + return this.queue; + } + + /** + * Sets the queue. + * + * @param queue the queue + */ + public void setQueue(BlockingQueue> queue) { + this.queue = queue; + } + + /** + * Validate. + */ + protected void validate() { + if (this.queueCapacity <= 0) { + throw new IllegalArgumentException("Queue capacity must be greater than 0."); + } + if (waitTime <= 0) { + throw new IllegalArgumentException("CacheBypass thread shutdown wait time must be greater than 0."); + } + if (numCacheBypassThreads < Constants.ONE) { + throw new IllegalArgumentException("Number of threads for CacheBypass thread-pool must be greater than 1."); + } + } + + /** + * Sort. + * + * @param list the list + * @return the list + */ + @SuppressWarnings("rawtypes") + List sort(List list) { + Collections.sort(list, (entity1, entity2) -> + entity1.getLastUpdatedTime().compareTo(entity2.getLastUpdatedTime())); + return list; + } + + /** + * Populate queue. + */ + @SuppressWarnings({ "rawtypes", "unchecked" }) + void populateQueue() { + List entitiesList = dmCacheEntityDao.findAll(); + entitiesList = sort(entitiesList); + queue.addAll((Collection) entitiesList); + logger.info("CacheBypass queue populated with {} cache entities.", queue.size()); + dmCacheEntityDao.deleteAll(); + logger.info("dmCacheEntities collection cleared."); + } + + /** + * Sets up and starts the CacheBypass thread pool for this task id. + * The threads from the thread pool continuously poll records from the queue. + * If redis is up and running then the queue will be empty, else, there will be + * data in the queue for the threads to process. + **/ + @PostConstruct + public void setup() { + validate(); + if (cacheBypassExecutorService == null || cacheBypassExecutorService.isShutdown()) { + if (queue == null) { + queue = new PriorityBlockingQueue<>(queueCapacity, + (CacheEntity e1, CacheEntity e2) -> + e1.getLastUpdatedTime().compareTo(e2.getLastUpdatedTime())); + } + populateQueue(); + checkAndProcessQueue(); + } + } + + /** + * Check and process queue. + */ + private void checkAndProcessQueue() { + if (!queue.isEmpty()) { + logger.info("Pulled {} records from dmCacheEntities collection at startup. " + + "Processing them..", queue.size()); + isExecutorServiceInitialized.set(true); + initializeCacheBypassExecutorService(); + } + } + + /** + * Initialize cache bypass executor service. + */ + private void initializeCacheBypassExecutorService() { + BasicThreadFactory threadFactory = new BasicThreadFactory.Builder() + .namingPattern(Constants.CACHE_BYPASS_REDIS_RETRY_THREAD + "-%d") + .build(); + cacheBypassExecutorService = Executors.newFixedThreadPool(numCacheBypassThreads, threadFactory); + for (int i = 0; i < numCacheBypassThreads; i++) { + cacheBypassExecutorService.execute(this::pollAndProcessEvents); + } + logger.debug("Thread pool for CacheBypass created: {}", cacheBypassExecutorService.toString()); + } + + /** + * Poll and process events. + */ + /* + * polls and sends a cache entity to processEvents to get processed. + */ + private void pollAndProcessEvents() { + logger.debug("Executing pollAndProcessEvents(), queue size: {}", queue.size()); + while (!queue.isEmpty()) { + CacheEntity entity = queue.poll(); + if (entity != null) { + logger.info("Entity with Key {} and Value {} polled from the queue. Current size of queue is: {}", + entity.getKey().convertToString(), entity.getValue(), queue.size()); + processEvents(entity); + } + } + } + + /** + * Takes an entity and processes it based on operation set on that entity. + * If the operation for a particular entity fails, then the control will jump to the catch block, + * and the entity will be added to the {@link CacheBypass#queue} and CacheBypass ExecutorService + * will be initialized {@link CacheBypass#cacheBypassExecutorService} and threads from the pool + * will keep polling entries and keep retrying the redis operation set on them until + * the operation is successful. + *Lazy initialization of ExecutorService on discovering that the application is unable to + *perform the Redis operations. Once the {@link CacheBypass#queue} is emptied, + *the ExecutorService will be shut down. + * + * @param entity {@link CacheEntity} + */ + public void processEvents(CacheEntity entity) { + try { + switch (entity.getOperation()) { + case PUT: + putToCache(entity); + break; + case PUT_TO_MAP: + putToMapCache(entity); + break; + case DEL: + deleteFromCache(entity); + break; + case DEL_FROM_MAP: + deleteFromMapCache(entity); + break; + default: + logger.error("Operation not supported."); + } + if (isExecutorServiceInitialized.get() && queue.isEmpty()) { + isExecutorServiceInitialized.set(false); + logger.info("CacheBypass queue is now empty. Shutting down the executor service."); + shutdownExecutorService(); + } + } catch (Exception e) { + logger.error("Operation: {} unsuccessful for key: {} and Value: {}. " + + "Added to CacheBypass queue to be retried.", + entity.getOperation(), entity.getKey().convertToString(), entity.getValue()); + queue.add(entity); + if (!isExecutorServiceInitialized.get()) { + isExecutorServiceInitialized.set(true); + initializeCacheBypassExecutorService(); + } + } + } + + /** + * Puts this entity into Redis. + * + * @param entity the entity + */ + public void putToCache(CacheEntity entity) { + PutEntityRequest putRequest = new PutEntityRequest<>(); + putRequest.withKey(entity.getKey().convertToString()); + putRequest.withValue(entity.getValue()); + putRequest.withNamespaceEnabled(false); + Optional mutationId = entity.getMutationId(); + if (mutationId.isPresent()) { + putRequest.withMutationId(String.valueOf(mutationId.get())); + } + cache.putEntity(putRequest); + logger.debug("PutEntity operation complete for Key {} and Value {} ", + entity.getKey().convertToString(), entity.getValue()); + } + + /** + * Puts the key-value pair in redis under a specific map key(parent key). + * + * @param entity {@link CacheEntity} + */ + public void putToMapCache(CacheEntity entity) { + PutMapOfEntitiesRequest putRequest = new PutMapOfEntitiesRequest<>(); + putRequest.withKey(entity.getMapKey()); + Map map = new HashMap<>(); + map.put(entity.getKey().convertToString(), entity.getValue()); + putRequest.withValue(map); + putRequest.withNamespaceEnabled(false); + Optional mutationId = entity.getMutationId(); + if (mutationId.isPresent()) { + putRequest.withMutationId(String.valueOf(mutationId.get())); + } + cache.putMapOfEntities(putRequest); + logger.debug("PutMapOfEntities operation complete for key {} and value {} ", + entity.getKey().convertToString(), entity.getValue()); + } + + /** + * Deletes the entity from Redis. + * + * @param entity the entity + */ + public void deleteFromCache(CacheEntity entity) { + DeleteEntryRequest deleteRequest = new DeleteEntryRequest(); + deleteRequest.withKey(entity.getKey().convertToString()); + Optional mutationId = entity.getMutationId(); + deleteRequest.withNamespaceEnabled(false); + if (mutationId.isPresent()) { + deleteRequest.withMutationId(String.valueOf(mutationId.get())); + } + cache.delete(deleteRequest); + logger.debug("Delete operation complete for key {} and value {} ", + entity.getKey().convertToString(), entity.getValue()); + } + + /** + * Deletes a key-value pair from redis from under a specific map key(parent key). + * + * @param entity {@link CacheEntity} + */ + public void deleteFromMapCache(CacheEntity entity) { + DeleteMapOfEntitiesRequest deleteMapRequest = new DeleteMapOfEntitiesRequest(); + deleteMapRequest.withKey(entity.getMapKey()); + Set subKeys = new HashSet<>(); + subKeys.add(entity.getKey().convertToString()); + deleteMapRequest.withFields(subKeys); + deleteMapRequest.withNamespaceEnabled(false); + Optional mutationId = entity.getMutationId(); + if (mutationId.isPresent()) { + deleteMapRequest.withMutationId(String.valueOf(mutationId.get())); + } + cache.deleteMapOfEntities(deleteMapRequest); + logger.debug("DeleteMapOfEntities operation complete for key {} and value {} ", + entity.getKey().convertToString(), entity.getValue()); + } + + /** + * saving the data of queue to mongo at the time of shutdown hook. + * + * @param queue the queue + */ + public synchronized void saveToMongo(Queue> queue) { + if (queue != null && !queue.isEmpty()) { + CacheEntity[] entitiesArray = queue.toArray(new CacheEntity[queue.size()]); + dmCacheEntityDao.saveAll(entitiesArray); + queue.removeAll(getQueue()); + logger.info("Flushed {} cache entity entries in to mongo", entitiesArray.length); + } else { + logger.info("CacheBypass queue is empty. Nothing to flush into MongoDB."); + } + } + + /** + * At the time of shutdown hook, this method will be invoked, and saveToMongo will be invoked. + **/ + public void close() { + saveToMongo(queue); + shutdownExecutorService(); + } + + /** + * Shutdown executor service. + */ + private void shutdownExecutorService() { + if (cacheBypassExecutorService != null && !cacheBypassExecutorService.isShutdown()) { + logger.info("Shutting down the CacheBypass Executor threads."); + ThreadUtils.shutdownExecutor(cacheBypassExecutorService, waitTime, false); + } + } + + /** + * Sets the queue capacity. + * + * @param capacity the new queue capacity + */ + // below methods are for testing purposes + void setQueueCapacity(int capacity) { + this.queueCapacity = capacity; + } + + /** + * Sets the wait time. + * + * @param waitTime the new wait time + */ + void setWaitTime(int waitTime) { + this.waitTime = waitTime; + } + + /** + * Sets the dm cache entity dao. + * + * @param dmCacheEntityDao the new dm cache entity dao + */ + void setDmCacheEntityDao(DMCacheEntityDAOMongoImpl dmCacheEntityDao) { + this.dmCacheEntityDao = dmCacheEntityDao; + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheEntity.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheEntity.java new file mode 100644 index 0000000..895dd44 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheEntity.java @@ -0,0 +1,209 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import dev.morphia.annotations.Entity; +import dev.morphia.annotations.Id; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.MutationId; +import org.eclipse.ecsp.entities.AbstractIgniteEntity; +import org.eclipse.ecsp.entities.IgniteEntity; + +import java.util.Optional; +import java.util.UUID; + +/** + * Captures the idea of the events/RetryRecords whether to write to/delete from redis along with other information. + * + * @author hbadshah + * @param the key type + * @param the value type + */ +@Entity +public class CacheEntity, V extends IgniteEntity> extends AbstractIgniteEntity { + + /** The id. */ + // to uniquely identify a CacheEntity if required + @Id + private String id; + + /** The key. */ + private K key; + + /** The value. */ + private V value; + + /** The map key. */ + private String mapKey; + + /** The op. */ + private Operation op; + + /** The mutation id. */ + private MutationId mutationId; + + /** + * Instantiates a new cache entity. + */ + public CacheEntity() { + StringBuilder stringBuilder = new StringBuilder(); + id = stringBuilder.append(UUID.randomUUID().toString()).append(System.currentTimeMillis()).toString(); + } + + // this key corresponds to the key whose type is K in CachedMapStateStore and CacheSortedMapStateStore + // and corresponds to the mapEntryKey in PutMapOfEntitiesRequest as well as in DeleteMapOfEntitiesRequest. + /** + * With key. + * + * @param key the key + * @return the cache entity + */ + // This is the childKey. + public CacheEntity withKey(K key) { + this.key = key; + return this; + } + + /** + * With value. + * + * @param value the value + * @return the cache entity + */ + public CacheEntity withValue(V value) { + this.value = value; + return this; + } + + // this mapKey corresponds to the mapKey of PutMapOfEntitiesRequest and + /** + * With map key. + * + * @param mapKey the map key + * @return the cache entity + */ + // DeleteMapOfEntities. This is the parentKey + public CacheEntity withMapKey(String mapKey) { + this.mapKey = mapKey; + return this; + } + + /** + * method to set mutationId. + * + * @param mutationId Optional{@code <}MutationId{@code >} + * @return org.eclipse.ecsp.analytics.stream.base.stores.CacheEntity + */ + public CacheEntity withMutationId(Optional mutationId) { + if (mutationId.isPresent()) { + this.mutationId = mutationId.get(); + } else { + this.mutationId = null; + } + return this; + } + + /** + * With operation. + * + * @param op the op + * @return the cache entity + */ + public CacheEntity withOperation(Operation op) { + this.op = op; + return this; + } + + /** + * Gets the key. + * + * @return the key + */ + public K getKey() { + return this.key; + } + + /** + * Gets the value. + * + * @return the value + */ + public V getValue() { + return this.value; + } + + /** + * Gets the map key. + * + * @return the map key + */ + public String getMapKey() { + return this.mapKey; + } + + /** + * Gets the mutation id. + * + * @return the mutation id + */ + public Optional getMutationId() { + return Optional.ofNullable(this.mutationId); + } + + /** + * Gets the operation. + * + * @return the operation + */ + public Operation getOperation() { + return this.op; + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return "CacheEntity[ Key: " + key + "," + "Value:" + value + "," + + "Map Key:" + mapKey + "," + "Mutation Id:" + mutationId + "," + "Operation:" + op + "]"; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheKeyConverter.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheKeyConverter.java new file mode 100644 index 0000000..e49a468 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheKeyConverter.java @@ -0,0 +1,67 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import dev.morphia.annotations.Entity; + +/** + * Interface {@link CacheKeyConverter}. + * + * @param the key type + */ +@Entity +public interface CacheKeyConverter { + + /** + * Convert from. + * + * @param key the key + * @return the k + */ + public K convertFrom(String key); + + /** + * Convert to string. + * + * @return the string + */ + public String convertToString(); + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CachedMapStateStore.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CachedMapStateStore.java new file mode 100644 index 0000000..fa1b1f1 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CachedMapStateStore.java @@ -0,0 +1,384 @@ +package org.eclipse.ecsp.analytics.stream.base.stores; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.dao.CacheBackedInMemoryBatchCompleteCallBack; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.MutationId; +import org.eclipse.ecsp.cache.GetMapOfEntitiesRequest; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.entities.IgniteEntity; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.eclipse.ecsp.utils.metrics.InternalCacheGuage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * A Java Map based in-memory state-store. + * + * @param The first type parameter. + * @param The second type parameter. + */ +@Component +@Scope("prototype") +public class CachedMapStateStore, V extends IgniteEntity> + extends GenericMapStateStore + implements MutableKeyValueStore { + + /** The logger. */ + private static IgniteLogger LOGGER = IgniteLoggerFactory.getLogger(CachedMapStateStore.class); + + /** The cache. */ + @Autowired + private IgniteCache cache; + + /** The bypass. */ + @Autowired + private CacheBypass bypass; + + /** The cache guage. */ + @Autowired + private InternalCacheGuage cacheGuage; + + /** The sub services. */ + @Value("${" + PropertyNames.SUB_SERVICES + ":}") + private String subServices; + + /** The svc. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String svc; + + /** The node name. */ + @Value("${NODE_NAME:localhost}") + private String nodeName; + + /** The persist in ignite cache. */ + private boolean persistInIgniteCache = true; + + /** The task id. */ + //This taskId has been added here to pass it down to CacheBypass from syncWithMapCacheMethod + private String taskId; + + /** + * Sets the cache. + * + * @param cache the new cache + */ + public void setCache(IgniteCache cache) { + this.cache = cache; + } + + /** + * Sets the bypass. + * + * @param bypass the bypass + */ + public void setBypass(CacheBypass bypass) { + this.bypass = bypass; + } + + /** + * Sets the taskId. + * + * @param taskId The topic's partition ID. + **/ + public void setTaskId(String taskId) { + //This check has been applied for DMARetryRecordDAO, DMARetryBucketDAO, + //and ShoulderTapDAO who needs CacheBypass. + if (taskId == null) { + LOGGER.error("null taskId received"); + return; + } + this.taskId = taskId; + } + + /** + * Wrapper over Java Map's putIfAbsent. + * + * @param key the key + * @param value the value + * @param mutationId the mutation id + * @param cacheType the cache type + * @return the v + */ + public V putIfAbsent(K key, V value, Optional mutationId, String cacheType) { + LOGGER.debug("Invoking putIfAbsent of CacheBackedGenericInMemoryDAOImpl with key {} and value {}", key, value); + V oldValue = super.putIfAbsent(key, value); + cacheGuage.set(super.approximateNumEntries(), cacheType, svc, nodeName, taskId); + + if (persistInIgniteCache && oldValue == null) { + CacheEntity entity = new CacheEntity<>(); + entity.withKey(key).withValue(value).withMutationId(mutationId).withOperation(Operation.PUT); + entity.setLastUpdatedTime(LocalDateTime.now()); + bypass.processEvents(entity); + } + return oldValue; + } + + /** + * Put. + * + * @param key the key + * @param value the value + * @param mutationId the mutation id + * @param cacheType the cache type + */ + @Override + public void put(K key, V value, Optional mutationId, String cacheType) { + LOGGER.debug("Invoking put of CacheBackedGenericInMemoryDAOImpl with key {} and value {} " + + "and mutationId {}", key, value, mutationId); + if (persistInIgniteCache) { + CacheEntity entity = new CacheEntity<>(); + entity.withKey(key).withValue(value).withMutationId(mutationId).withOperation(Operation.PUT); + entity.setLastUpdatedTime(LocalDateTime.now()); + bypass.processEvents(entity); + } + super.put(key, value); + cacheGuage.set(super.approximateNumEntries(), cacheType, svc, nodeName, taskId); + } + + /** + * this is used to set values to Cache. + * + * @param key the key + * @param value the value + * @param cacheType the cache type + */ + public void put(K key, V value, String cacheType) { + LOGGER.debug("Invoking put of CacheBackedGenericInMemoryDAOImpl with key {} and value {}", key, value); + put(key, value, Optional.empty(), cacheType); + + } + + /** + * this is used to set values to Cache. + * + * @param key the key + * @param cacheType the cache type + * @return the v + */ + public V delete(K key, String cacheType) { + LOGGER.debug("Invoking delete of CacheBackedGenericInMemoryDAOImpl with key {}", key); + delete(key, Optional.empty(), cacheType); + return null; + } + + /** + * Delete. + * + * @param key the key + * @param mutationId the mutation id + * @param cacheType the cache type + */ + @Override + public void delete(K key, Optional mutationId, String cacheType) { + LOGGER.debug("Invoking delete of CacheBackedGenericInMemoryDAOImpl with key {} and " + + "mutationId {}", key, mutationId); + super.delete(key); + cacheGuage.set(super.approximateNumEntries(), cacheType, svc, nodeName, taskId); + + if (persistInIgniteCache) { + CacheEntity entity = new CacheEntity<>(); + entity.withKey(key).withMutationId(mutationId).withOperation(Operation.DEL); + entity.setLastUpdatedTime(LocalDateTime.now()); + bypass.processEvents(entity); + } + } + + /** + * Sets the call back. + * + * @param callBack the new call back + */ + @Override + public void setCallBack(CacheBackedInMemoryBatchCompleteCallBack callBack) { + // Method to set instance of callback + + } + + /** + * Sync withcache. + * + * @param regex the regex + * @param converter the converter + */ + @Override + public void syncWithcache(String regex, K converter) { + Map pairs = cache.getKeyValuePairsForRegex(regex, Optional.of(false)); + pairs.forEach((k, v) -> + super.put(converter.convertFrom(k), v) + ); + } + + /** + * Sets the persist in ignite cache. + * + * @param persistInIgniteCache the new persist in ignite cache + */ + public void setPersistInIgniteCache(boolean persistInIgniteCache) { + this.persistInIgniteCache = persistInIgniteCache; + } + + /** + * Put to map. + * + * @param mapKey the map key + * @param mapEntryKey the map entry key + * @param mapEntryValue the map entry value + * @param mutationId the mutation id + * @param cacheType the cache type + */ + @Override + public void putToMap(String mapKey, K mapEntryKey, V mapEntryValue, + Optional mutationId, String cacheType) { + LOGGER.debug("Invoking put to map of CachedMapStateStore with key {} and value {} " + + "and mutationId {}", mapEntryKey, mapEntryValue, mutationId); + if (persistInIgniteCache) { + CacheEntity entity = new CacheEntity<>(); + entity.withMapKey(mapKey).withKey(mapEntryKey).withValue(mapEntryValue).withMutationId(mutationId) + .withOperation(Operation.PUT_TO_MAP); + entity.setLastUpdatedTime(LocalDateTime.now()); + bypass.processEvents(entity); + } + super.put(mapEntryKey, mapEntryValue); + cacheGuage.set(super.approximateNumEntries(), cacheType, svc, nodeName, taskId); + } + + /** + * Put to map if absent. + * + * @param mapKey the map key + * @param mapEntryKey the map entry key + * @param mapEntryValue the map entry value + * @param mutationId the mutation id + * @param cacheType the cache type + * @return the v + */ + @Override + public V putToMapIfAbsent(String mapKey, K mapEntryKey, V mapEntryValue, + Optional mutationId, String cacheType) { + LOGGER.debug("Invoking putIfAbsent to map of CachedMapStateStore with key {} and value {}", + mapEntryKey, mapEntryValue); + V oldValue = super.putIfAbsent(mapEntryKey, mapEntryValue); + if (persistInIgniteCache && oldValue == null) { + CacheEntity entity = new CacheEntity<>(); + entity.withMapKey(mapKey).withKey(mapEntryKey).withValue(mapEntryValue).withMutationId(mutationId) + .withOperation(Operation.PUT_TO_MAP); + entity.setLastUpdatedTime(LocalDateTime.now()); + bypass.processEvents(entity); + } + super.put(mapEntryKey, mapEntryValue); + cacheGuage.set(super.approximateNumEntries(), cacheType, svc, nodeName, taskId); + return oldValue; + } + + /** + * Delete from map. + * + * @param mapKey the map key + * @param mapEntryKey the map entry key + * @param mutationId the mutation id + * @param cacheType the cache type + */ + @Override + public void deleteFromMap(String mapKey, K mapEntryKey, Optional mutationId, String cacheType) { + LOGGER.debug("Invoking delete from map of CachedMapStateStore with map key {}, " + + "entry key {}, and mutationId {}", mapKey, mapEntryKey.convertToString(), mutationId); + super.delete(mapEntryKey); + cacheGuage.set(super.approximateNumEntries(), cacheType, svc, nodeName, taskId); + + if (persistInIgniteCache) { + CacheEntity entity = new CacheEntity<>(); + entity.withMapKey(mapKey).withKey(mapEntryKey).withMutationId(mutationId) + .withOperation(Operation.DEL_FROM_MAP); + entity.setLastUpdatedTime(LocalDateTime.now()); + bypass.processEvents(entity); + } + } + + /** + * Sync with map cache. + * + * @param mapKey the map key + * @param converter the converter + * @param cacheType the cache type + */ + @Override + public void syncWithMapCache(String mapKey, K converter, String cacheType) { + GetMapOfEntitiesRequest getReq = new GetMapOfEntitiesRequest(); + getReq.withKey(mapKey); + getReq.withNamespaceEnabled(false); + Map pairs = cache.getMapOfEntities(getReq); + /* + * If sub-services for a service exist then there would be multiple VEHICLE_DEVICE_MAPPING parent + * keys in redis as hivemq will be putting device status data at sub-service level. + * Now, unlike redis, in DMA we have only one map for device status data and keys of + * that map must be unique, which they won't, + * because in redis same VIN's data could exist under different VEHICLE_DEVICE_MAPPING keys + * and while syncing in-memory with redis, there can't be 2 VINs as key. + * Hence, in in-memory, for device status map, key will look like below: + * ; + * to maintain its uniqueness and also for DMA to know which VIN is active for which sub-service. + */ + if (mapKey.startsWith(DMAConstants.VEHICLE_DEVICE_MAPPING)) { + if (StringUtils.isNotEmpty(subServices)) { + String[] arr = mapKey.split(":"); + pairs.forEach((k, v) -> + super.put(converter.convertFrom(k + DMAConstants.SEMI_COLON + arr[arr.length - 1]), v) + ); + } else { + pairs.forEach((k, v) -> + super.put(converter.convertFrom(k), v) + ); + } + } else { + pairs.forEach((k, v) -> + super.put(converter.convertFrom(k), v) + ); + } + cacheGuage.set(super.approximateNumEntries(), cacheType, svc, nodeName, taskId); + } + + /** + * Force get. + * + * @param mapKey the map key + * @param mapEntryKey the map entry key + * @return the v + */ + @Override + public V forceGet(String mapKey, K mapEntryKey) { + GetMapOfEntitiesRequest getReq = new GetMapOfEntitiesRequest(); + getReq.withKey(mapKey); + Set subKeys = new HashSet<>(); + String keyAsString = mapEntryKey.convertToString(); + subKeys.add(keyAsString); + getReq.withFields(subKeys); + getReq.withNamespaceEnabled(false); + V result = null; + Map map = cache.getMapOfEntities(getReq); + if (map != null) { + result = map.get(keyAsString); + } + return result; + } + + /** + * Close. + */ + @Override + public void close() { + super.close(); + bypass.close(); + } + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CachedSortedMapStateStore.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CachedSortedMapStateStore.java new file mode 100644 index 0000000..6ebfb32 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/CachedSortedMapStateStore.java @@ -0,0 +1,381 @@ +package org.eclipse.ecsp.analytics.stream.base.stores; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.dao.CacheBackedInMemoryBatchCompleteCallBack; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.MutationId; +import org.eclipse.ecsp.cache.GetMapOfEntitiesRequest; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.entities.IgniteEntity; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.eclipse.ecsp.utils.metrics.InternalCacheGuage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * An ordered Java Map based in-memory state-store. The entries in this map are ordered by timestamp. + * + * @param The key type parameter. + * @param The value type parameter. + */ +@Component +@Scope("prototype") +public class CachedSortedMapStateStore, V extends IgniteEntity> + extends GenericSortedMapStateStore implements MutableKeyValueStore { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(CachedSortedMapStateStore.class); + + /** The cache. */ + @Autowired + private IgniteCache cache; + + /** The bypass. */ + @Autowired + private CacheBypass bypass; + + /** The cache guage. */ + @Autowired + private InternalCacheGuage cacheGuage; + + /** The svc. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String svc; + + /** The node name. */ + @Value("${NODE_NAME:localhost}") + private String nodeName; + + /** The task id. */ + //This taskId has been added here to pass it down to CacheBypass from syncWithMapCacheMethod + private String taskId; + + /** + * Sets the cache. + * + * @param cache the new cache + */ + public void setCache(IgniteCache cache) { + this.cache = cache; + } + + /** + * Sets the bypass. + * + * @param bypass the bypass + */ + public void setBypass(CacheBypass bypass) { + this.bypass = bypass; + } + /** + * Sets the task ID. + * + * @param taskId The topic partition ID. + */ + + public void setTaskId(String taskId) { + //This check has been applied for DMARetryRecordDAO, DMARetryBucketDAO, and + //ShoulderTapDAO who needs CacheBypass. + if (taskId == null) { + logger.error("null taskId received"); + return; + } + this.taskId = taskId; + } + + /** + * Wrapper over Java Map's put functionality. + * + * @param key The key to put in the map. + * @param value The value associated with the key. + * @param cacheType The name of the in-memory cache where this entry will be put. + */ + public void put(K key, V value, String cacheType) { + logger.debug("Invoking put of CacheBackedGenericInMemoryDAOImpl with key {} and value {}", key, value); + put(key, value, Optional.empty(), cacheType); + + } + + /** + * Put. + * + * @param key the key + * @param value the value + * @param mutationId the mutation id + * @param cacheType the cache type + */ + @Override + public void put(K key, V value, Optional mutationId, String cacheType) { + logger.debug("Invoking put of CacheBackedGenericInMemoryDAOImpl with key {} and value {} " + + "and mutationId {}", key, value, mutationId); + putToCache(key, value, mutationId); + super.put(key, value); + cacheGuage.set(super.approximateNumEntries(), cacheType, svc, nodeName, taskId); + } + + /** + * Put if absent. + * + * @param key the key + * @param value the value + * @param mutationId the mutation id + * @param cacheType the cache type + * @return the v + */ + @Override + public V putIfAbsent(K key, V value, Optional mutationId, String cacheType) { + logger.debug("Invoking put of CacheBackedGenericInMemoryDAOImpl with key {} and value {} " + + "and mutationId {}", key, value, mutationId); + V oldValue = super.putIfAbsent(key, value); + cacheGuage.set(super.approximateNumEntries(), cacheType, svc, nodeName, taskId); + + if (oldValue == null) { + putToCache(key, value, mutationId); + } + return oldValue; + } + + /** + * Delete. + * + * @param key the key + * @param mutationId the mutation id + * @param cacheType the cache type + */ + @Override + public void delete(K key, Optional mutationId, String cacheType) { + logger.debug("Invoking delete of CacheBackedGenericInMemoryDAOImpl with key {} and " + + "mutationId", key, mutationId); + super.delete(key); + cacheGuage.set(super.approximateNumEntries(), cacheType, svc, nodeName, taskId); + deleteFromCache(key, mutationId); + } + + /** + * Wrapper over Java Map's delete function. + * + * @param key The key to delete. + * @param cacheType The name of the in-memory cache from where this key will be deleted. + * @return the deleted value. + */ + public V delete(K key, String cacheType) { + logger.debug("Invoking delete of CacheBackedGenericInMemoryDAOImpl with key {}", key); + delete(key, Optional.empty(), cacheType); + return null; + } + + /** + * Delete from cache. + * + * @param key the key + * @param mutationId the mutation id + */ + private void deleteFromCache(K key, Optional mutationId) { + CacheEntity entity = new CacheEntity<>(); + entity.withKey(key).withOperation(Operation.DEL); + if (mutationId.isPresent()) { + entity.withMutationId(mutationId); + } + entity.setLastUpdatedTime(LocalDateTime.now()); + bypass.processEvents(entity); + } + + /** + * Sets the call back. + * + * @param callBack the new call back + */ + @Override + public void setCallBack(CacheBackedInMemoryBatchCompleteCallBack callBack) { + // Implementataion Pending + + } + + /** + * Sync withcache. + * + * @param regex the regex + * @param converter the converter + */ + @Override + public void syncWithcache(String regex, K converter) { + Map pairs = cache.getKeyValuePairsForRegex(regex, Optional.of(false)); + pairs.forEach((k, v) -> + super.put(converter.convertFrom(k), v) + ); + } + + /** + * Put to cache. + * + * @param key the key + * @param value the value + * @param mutationId the mutation id + */ + private void putToCache(K key, V value, Optional mutationId) { + CacheEntity entity = new CacheEntity<>(); + entity.withKey(key).withValue(value).withOperation(Operation.PUT); + if (mutationId.isPresent()) { + entity.withMutationId(mutationId); + } + entity.setLastUpdatedTime(LocalDateTime.now()); + bypass.processEvents(entity); + } + + /** + * Put to map. + * + * @param mapKey the map key + * @param mapEntryKey the map entry key + * @param mapEntryValue the map entry value + * @param mutationId the mutation id + * @param cacheType the cache type + */ + @Override + public void putToMap(String mapKey, K mapEntryKey, V mapEntryValue, + Optional mutationId, String cacheType) { + logger.debug("Invoking put to map of CachedMapStateStore with key {} and value {} " + + "and mutationId {}", mapEntryKey, mapEntryValue, mutationId); + putToMapCache(mapKey, mapEntryKey, mapEntryValue, mutationId); + super.put(mapEntryKey, mapEntryValue); + cacheGuage.set(super.approximateNumEntries(), cacheType, svc, nodeName, taskId); + } + + /** + * Put to map if absent. + * + * @param mapKey the map key + * @param mapEntryKey the map entry key + * @param mapEntryValue the map entry value + * @param mutationId the mutation id + * @param cacheType the cache type + * @return the v + */ + @Override + public V putToMapIfAbsent(String mapKey, K mapEntryKey, V mapEntryValue, + Optional mutationId, String cacheType) { + logger.debug("Invoking putIfAbsent to map of CachedMapStateStore with key {} and value {}", + mapEntryKey.convertToString(), mapEntryValue); + V oldValue = super.putIfAbsent(mapEntryKey, mapEntryValue); + cacheGuage.set(super.approximateNumEntries(), cacheType, svc, nodeName, taskId); + if (oldValue == null) { + putToMapCache(mapKey, mapEntryKey, mapEntryValue, mutationId); + } + return oldValue; + } + + /** + * Delete from map. + * + * @param mapKey the map key + * @param mapEntryKey the map entry key + * @param mutationId the mutation id + * @param cacheType the cache type + */ + @Override + public void deleteFromMap(String mapKey, K mapEntryKey, Optional mutationId, String cacheType) { + logger.debug("Invoking delete from map of CachedMapStateStore with key {} and " + + "mutationId {}", mapEntryKey, mutationId); + super.delete(mapEntryKey); + cacheGuage.set(super.approximateNumEntries(), cacheType, svc, nodeName, taskId); + deleteFromMapCache(mapKey, mapEntryKey, mutationId); + } + + /** + * Sync with map cache. + * + * @param mapKey the map key + * @param converter the converter + * @param cacheType the cache type + */ + @Override + public void syncWithMapCache(String mapKey, K converter, String cacheType) { + GetMapOfEntitiesRequest getReq = new GetMapOfEntitiesRequest(); + getReq.withKey(mapKey); + getReq.withNamespaceEnabled(false); + Map pairs = cache.getMapOfEntities(getReq); + pairs.forEach((k, v) -> + super.put(converter.convertFrom(k), v) + ); + + cacheGuage.set(super.approximateNumEntries(), cacheType, svc, nodeName, taskId); + } + + /** + * Delete from map cache. + * + * @param mapKey the map key + * @param mapEntryKey the map entry key + * @param mutationId the mutation id + */ + private void deleteFromMapCache(String mapKey, K mapEntryKey, Optional mutationId) { + CacheEntity entity = new CacheEntity<>(); + entity.withMapKey(mapKey).withKey(mapEntryKey); + if (mutationId.isPresent()) { + entity.withMutationId(mutationId); + } + entity.withOperation(Operation.DEL_FROM_MAP); + entity.setLastUpdatedTime(LocalDateTime.now()); + bypass.processEvents(entity); + } + + /** + * Put to map cache. + * + * @param mapKey the map key + * @param mapEntryKey the map entry key + * @param mapEntryValue the map entry value + * @param mutationId the mutation id + */ + private void putToMapCache(String mapKey, K mapEntryKey, V mapEntryValue, Optional mutationId) { + CacheEntity entity = new CacheEntity<>(); + entity.withMapKey(mapKey).withKey(mapEntryKey).withValue(mapEntryValue); + if (mutationId.isPresent()) { + entity.withMutationId(mutationId); + } + entity.withOperation(Operation.PUT_TO_MAP); + entity.setLastUpdatedTime(LocalDateTime.now()); + bypass.processEvents(entity); + } + + /** + * Force get. + * + * @param mapKey the map key + * @param mapEntryKey the map entry key + * @return the v + */ + @Override + public V forceGet(String mapKey, K mapEntryKey) { + GetMapOfEntitiesRequest getReq = new GetMapOfEntitiesRequest(); + getReq.withKey(mapKey); + Set subKeys = new HashSet<>(); + String keyAsString = mapEntryKey.convertToString(); + subKeys.add(keyAsString); + getReq.withFields(subKeys); + getReq.withNamespaceEnabled(false); + V result = null; + Map map = cache.getMapOfEntities(getReq); + if (map != null) { + result = map.get(keyAsString); + } + return result; + } + + /** + * Close. + */ + @Override + public void close() { + super.close(); + bypass.close(); + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/GenericMapStateStore.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/GenericMapStateStore.java new file mode 100644 index 0000000..fba53b9 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/GenericMapStateStore.java @@ -0,0 +1,183 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Repository; + +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * GenericMapStateStore uses ConcurrentHashMap for a thread-safe storage of key value pair in memory. + * + * @author avadakkootko + * @param the key type + * @param the value type + */ +@Repository +@Scope("prototype") +public class GenericMapStateStore extends GenericMapStateStoreBase> { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(GenericMapStateStore.class); + + /** The Constant STORE_NAME. */ + private static final String STORE_NAME = "GenericMapStateStoreName"; + + /** The logger. */ + private static IgniteLogger LOGGER = IgniteLoggerFactory.getLogger(GenericMapStateStore.class); + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return STORE_NAME; + } + + /** + * Creates the empty store. + * + * @return the concurrent hash map + */ + @Override + protected ConcurrentHashMap createEmptyStore() { + return new ConcurrentHashMap<>(); + } + + /** + * Creates the iterator. + * + * @param mapStore2 the map store 2 + * @return the key value iterator + */ + @Override + protected KeyValueIterator createIterator(ConcurrentHashMap mapStore2) { + return new ConcurrentHashMapIterator<>(mapStore2); + } + + /** + * The Class ConcurrentHashMapIterator. + * + * @param the generic type + * @param the generic type + */ + private class ConcurrentHashMapIterator implements KeyValueIterator { + + /** The concurrent map. */ + private ConcurrentHashMap concurrentMap; + + /** The key iter. */ + private Iterator keyIter; + + /** + * Instantiates a new concurrent hash map iterator. + * + * @param map the map + */ + public ConcurrentHashMapIterator(Map map) { + /* + * Since we have to iterate over the original map, make a deep copy + * of this map. + */ + logger.trace("Initializing iterator for map state store."); + this.concurrentMap = new ConcurrentHashMap<>(); + this.concurrentMap.putAll(map); + keyIter = map.keySet().iterator(); + + } + + /** + * Checks for next. + * + * @return true, if successful + */ + @Override + public boolean hasNext() { + return keyIter.hasNext(); + } + + /** + * Next. + * + * @return the key value + */ + @Override + public KeyValue next() { + if (hasNext()) { + S key = this.keyIter.next(); + U val = this.concurrentMap.get(key); + return new KeyValue<>(key, val); + } + return null; + } + + /** + * Close. + */ + @Override + public void close() { + if (concurrentMap != null) { + concurrentMap.clear(); + } + + } + + /** + * Peek next key. + * + * @return the s + */ + @Override + public S peekNextKey() { + throw new UnsupportedOperationException("Method peekNextKey not supported in KeyValueMapIterator"); + } + + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/GenericMapStateStoreBase.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/GenericMapStateStoreBase.java new file mode 100644 index 0000000..e8f5c17 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/GenericMapStateStoreBase.java @@ -0,0 +1,240 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.processor.ProcessorContext; +import org.apache.kafka.streams.processor.StateStore; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.apache.kafka.streams.state.KeyValueStore; +import org.eclipse.ecsp.analytics.stream.base.utils.ObjectUtils; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; + +import java.util.List; +import java.util.Map; + +import static org.eclipse.ecsp.analytics.stream.base.utils.Constants.RECEIVED_NULL_KEY; + +/** + * GenericMapStateStore uses ConcurrentHashMap for a thread-safe storage of key value pair in memory. + * + * @author avadakkootko + * @param the key type + * @param the value type + * @param the generic type + */ +public abstract class GenericMapStateStoreBase> implements KeyValueStore { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(GenericMapStateStoreBase.class); + + /** The map store. */ + protected M mapStore = createEmptyStore(); + + /** + * Creates the empty store. + * + * @return the m + */ + protected abstract M createEmptyStore(); + + /** + * Inits the. + * + * @param context the context + * @param root the root + */ + @Override + public void init(ProcessorContext context, StateStore root) { + logger.info("GenericMapStateStore already initialized."); + } + + /** + * Flush. + */ + @Override + public void flush() { + } + + /** + * Close. + */ + @Override + public void close() { + mapStore.clear(); + logger.info("GenericMapStateStore Closed."); + } + + /** + * Persistent. + * + * @return true, if successful + */ + @Override + public boolean persistent() { + return false; + } + + /** + * Checks if is open. + * + * @return true, if is open + */ + @Override + public boolean isOpen() { + return true; + } + + /** + * Gets the. + * + * @param key the key + * @return the v + */ + @Override + public V get(K key) { + ObjectUtils.requireNonNull(key, RECEIVED_NULL_KEY); + logger.trace("Retrieving the value for the key:{} from generic map state store", key); + return mapStore.get(key); + } + + /** + * Range. + * + * @param from the from + * @param to the to + * @return the key value iterator + */ + @Override + public KeyValueIterator range(K from, K to) { + logger.error("Method range is not implemented by GenericMapStateStore class."); + throw new UnsupportedOperationException("Method range is not implemented by GenericMapStateStore class."); + } + + /** + * All. + * + * @return the key value iterator + */ + @Override + public KeyValueIterator all() { + return createIterator(mapStore); + } + + /** + * Creates the iterator. + * + * @param mapStore2 the map store 2 + * @return the key value iterator + */ + protected abstract KeyValueIterator createIterator(M mapStore2); + + /** + * Approximate num entries. + * + * @return the long + */ + @Override + public long approximateNumEntries() { + return mapStore.size(); + } + + /** + * Put. + * + * @param key the key + * @param value the value + */ + @Override + public void put(K key, V value) { + ObjectUtils.requireNonNull(key, RECEIVED_NULL_KEY); + ObjectUtils.requireNonNull(value, "Received null value."); + logger.debug("Putting into generic map state store. key:{} and value:{}", key, value); + mapStore.put(key, value); + } + + /** + * Put if absent. + * + * @param key the key + * @param value the value + * @return the v + */ + @Override + public V putIfAbsent(K key, V value) { + ObjectUtils.requireNonNull(key, RECEIVED_NULL_KEY); + ObjectUtils.requireNonNull(value, "Received null value."); + // check if the key exist + return mapStore.putIfAbsent(key, value); + } + + /** + * Put all. + * + * @param entries the entries + */ + @Override + public void putAll(List> entries) { + ObjectUtils.requireNonNull(entries, "Received null values."); + for (KeyValue keyValue : entries) { + K key = keyValue.key; + V val = keyValue.value; + // Null check will be done inside put. + put(key, val); + } + } + + /** + * Delete. + * + * @param key the key + * @return the v + */ + @Override + public V delete(K key) { + V obj = this.mapStore.get(key); + if (null != obj) { + logger.debug("Deleting key:{} from map state store.", key); + this.mapStore.remove(key); + } + return obj; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/GenericSortedMapStateStore.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/GenericSortedMapStateStore.java new file mode 100644 index 0000000..76a3dd8 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/GenericSortedMapStateStore.java @@ -0,0 +1,242 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.eclipse.ecsp.analytics.stream.base.utils.ObjectUtils; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Repository; + +import java.util.Comparator; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentSkipListMap; + +/** + * This state store stores keys in sorted order and provides range operations for keys in an efficient manner. + * + * @author avadakkootko + * @param the key type + * @param the value type + */ +@Repository +@Scope("prototype") +public class GenericSortedMapStateStore extends GenericMapStateStoreBase> + implements SortedKeyValueStore { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(GenericSortedMapStateStore.class); + + /** The Constant STORE_NAME. */ + private static final String STORE_NAME = "GenericSortedMapStateStoreName"; + + /** The logger. */ + private static IgniteLogger LOGGER = IgniteLoggerFactory.getLogger(GenericSortedMapStateStore.class); + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return STORE_NAME; + } + + /** + * Creates the store with comparator. + * + * @param comparator the comparator + */ + public void createStoreWithComparator(Comparator comparator) { + mapStore = new ConcurrentSkipListMap(comparator); + } + + /** + * Creates the empty store. + * + * @return the concurrent skip list map + */ + @Override + protected ConcurrentSkipListMap createEmptyStore() { + return new ConcurrentSkipListMap<>(); + } + + /** + * Creates the iterator. + * + * @param mapStore2 the map store 2 + * @return the key value iterator + */ + @Override + protected KeyValueIterator createIterator(ConcurrentSkipListMap mapStore2) { + return new ConcurrentSkipListMapIterator(mapStore2); + } + + /** + * Range. + * + * @param fromKey the from key + * @param toKey the to key + * @return the key value iterator + */ + @Override + public KeyValueIterator range(K fromKey, K toKey) { + ObjectUtils.requireNonNull(fromKey, "Received null fromKey."); + ObjectUtils.requireNonNull(toKey, "Received null toKey."); + return new ConcurrentSkipListMapIterator( + mapStore.subMap(fromKey, true, toKey, + true)); + } + + /** + * All. + * + * @return the key value iterator + */ + @Override + public KeyValueIterator all() { + return new ConcurrentSkipListMapIterator(mapStore); + } + + /** + * Returns a view of the portion of this map whose keys are less than (or equal to) toKey. + * + * @param toKey toKey + * @return KeyValueIterator + */ + @Override + public KeyValueIterator getHead(K toKey) { + ObjectUtils.requireNonNull(toKey, "Received null key."); + return new ConcurrentSkipListMapIterator(mapStore.headMap(toKey, true)); + } + + /** + * Returns a view of the portion of this map whose keys are greater than (or equal to) fromKey. + * + * @param fromKey fromKey + * @return KeyValueIterator + */ + @Override + public KeyValueIterator getTail(K fromKey) { + ObjectUtils.requireNonNull(fromKey, "Received null key."); + return new ConcurrentSkipListMapIterator(mapStore.tailMap(fromKey, true)); + } + + /** + * The Class ConcurrentSkipListMapIterator. + */ + private class ConcurrentSkipListMapIterator implements KeyValueIterator { + + /** The sorted map. */ + private ConcurrentSkipListMap sortedMap; + + /** The key iter. */ + private Iterator keyIter; + + /** + * Since we have to iterate over the original map, make a deep copy + * of this map. + * + * @param map the map + */ + public ConcurrentSkipListMapIterator(Map map) { + /* + * Since we have to iterate over the original map, make a deep copy + * of this map. + */ + logger.trace("Initializing iterator for map state store."); + this.sortedMap = new ConcurrentSkipListMap<>(); + this.sortedMap.putAll(map); + keyIter = map.keySet().iterator(); + + } + + /** + * Checks for next. + * + * @return true, if successful + */ + @Override + public boolean hasNext() { + return keyIter.hasNext(); + } + + /** + * Next. + * + * @return the key value + */ + @Override + public KeyValue next() { + if (hasNext()) { + K key = this.keyIter.next(); + V val = this.sortedMap.get(key); + return new KeyValue<>(key, val); + } + return null; + } + + /** + * Close. + */ + @Override + public void close() { + if (sortedMap != null) { + sortedMap.clear(); + } + + } + + /** + * Peek next key. + * + * @return the k + */ + @Override + public K peekNextKey() { + throw new UnsupportedOperationException("Method peekNextKey not supported in KeyValueMapIterator"); + } + + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanPersistentKVStore.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanPersistentKVStore.java new file mode 100644 index 0000000..14a6af0 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanPersistentKVStore.java @@ -0,0 +1,307 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import org.apache.kafka.common.config.TopicConfig; +import org.apache.kafka.common.serialization.Serde; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.common.utils.Bytes; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.processor.ProcessorContext; +import org.apache.kafka.streams.processor.StateStore; +import org.apache.kafka.streams.processor.StateStoreContext; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.apache.kafka.streams.state.KeyValueStore; +import org.apache.kafka.streams.state.StateSerdes; +import org.apache.kafka.streams.state.StoreBuilder; +import org.apache.kafka.streams.state.Stores; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +/** + * class HarmanPersistentKVStore implements KeyValueStore. + * + * @param the key type + * @param the value type + */ +public abstract class HarmanPersistentKVStore implements KeyValueStore { + + /** The log. */ + private static IgniteLogger log = IgniteLoggerFactory.getLogger(HarmanPersistentKVStore.class); + + /** The proxied. */ + private final KeyValueStore proxied; + + /** The key serde. */ + private final Serde keySerde; + + /** The value serde. */ + private final Serde valueSerde; + + /** The serdes. */ + private StateSerdes serdes; + + /** + * HarmanPersistentKVStore. + * + * @param name name + * @param changeLoggingEnabled changeLoggingEnabled + * @param keySerde keySerde + * @param valueSerde valueSerde + * @param props props + */ + protected HarmanPersistentKVStore(String name, boolean changeLoggingEnabled, final Serde keySerde, + final Serde valueSerde, Properties props) { + if (changeLoggingEnabled) { + Map changelogConfig = new HashMap<>(); + changelogConfig.put(TopicConfig.MIN_IN_SYNC_REPLICAS_CONFIG, "1"); + + StoreBuilder> changeLogBuilder = Stores.keyValueStoreBuilder( + new HarmanRocksDBStoreSupplier(name, props), + Serdes.Bytes(), + Serdes.ByteArray()) + .withLoggingEnabled(changelogConfig); + proxied = changeLogBuilder.build(); + log.info("Created instance of HarmanRocksDBStore with change log enabled"); + } else { + proxied = new HarmanRocksDBStore<>(name, + Serdes.Bytes(), + Serdes.ByteArray(), props); + log.warn("Created instance of HarmanRocksDBStore with " + + "change log disabled. State store is not fault-tolerant!"); + } + this.keySerde = keySerde; + this.valueSerde = valueSerde; + } + + /** + * Put. + * + * @param key the key + * @param value the value + */ + @Override + public void put(final K key, final V value) { + final Bytes bytesKey = Bytes.wrap(serdes.rawKey(key)); + final byte[] bytesValue = serdes.rawValue(value); + proxied.put(bytesKey, bytesValue); + } + + /** + * Put if absent. + * + * @param key the key + * @param value the value + * @return the v + */ + @Override + public V putIfAbsent(final K key, final V value) { + final V v = get(key); + if (v == null) { + put(key, value); + } + return v; + } + + /** + * Put all. + * + * @param entries the entries + */ + @Override + public void putAll(final List> entries) { + final List> keyValues = new ArrayList<>(); + for (final KeyValue entry : entries) { + keyValues.add(KeyValue.pair(Bytes.wrap(serdes.rawKey(entry.key)), serdes.rawValue(entry.value))); + } + proxied.putAll(keyValues); + } + + /** + * Delete. + * + * @param key the key + * @return the v + */ + @Override + public V delete(final K key) { + final byte[] oldValue = proxied.delete(Bytes.wrap(serdes.rawKey(key))); + if (oldValue == null) { + return null; + } + return serdes.valueFrom(oldValue); + } + + /** + * Gets the. + * + * @param key the key + * @return the v + */ + @Override + public V get(final K key) { + final byte[] rawValue = proxied.get(Bytes.wrap(serdes.rawKey(key))); + if (rawValue == null) { + return null; + } + return serdes.valueFrom(rawValue); + } + + /** + * Range. + * + * @param from the from + * @param to the to + * @return the key value iterator + */ + @Override + public KeyValueIterator range(final K from, final K to) { + return new SerializedKVIterator<>(proxied.range(Bytes.wrap(serdes.rawKey(from)), + Bytes.wrap(serdes.rawKey(to))), + serdes); + } + + /** + * All. + * + * @return the key value iterator + */ + @Override + public KeyValueIterator all() { + return new SerializedKVIterator<>(proxied.all(), serdes); + } + + /** + * Approximate num entries. + * + * @return the long + */ + @Override + public long approximateNumEntries() { + return proxied.approximateNumEntries(); + } + + /** + * Initialize the HarmanPersistentKVStore. + + * @param context ProcessorContext instance + * @param root StateStore instance + */ + @Deprecated(since = "2.41.0-1") + @Override + public void init(ProcessorContext context, StateStore root) { + if (context instanceof StateStoreContext stateStoreContext) { + init(stateStoreContext, root); + } else { + throw new UnsupportedOperationException( + "Use RocksDBStore#init(StateStoreContext, StateStore) instead." + ); + } + } + + + /** + * Inits the. + * + * @param context the context + * @param root the root + */ + @SuppressWarnings("unchecked") + @Override + public void init(final StateStoreContext context, final StateStore root) { + + proxied.init(context, root); + this.serdes = new StateSerdes<>(proxied.name(), + keySerde == null ? (Serde) context.keySerde() : keySerde, + valueSerde == null ? (Serde) context.valueSerde() : valueSerde); + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return proxied.name(); + } + + /** + * Flush. + */ + @Override + public void flush() { + proxied.flush(); + } + + /** + * Close. + */ + @Override + public void close() { + proxied.close(); + } + + /** + * Persistent. + * + * @return true, if successful + */ + @Override + public boolean persistent() { + return proxied.persistent(); + } + + /** + * Checks if is open. + * + * @return true, if is open + */ + @Override + public boolean isOpen() { + return proxied.isOpen(); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanPersistentPrimitiveMapValueStore.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanPersistentPrimitiveMapValueStore.java new file mode 100644 index 0000000..b15dba3 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanPersistentPrimitiveMapValueStore.java @@ -0,0 +1,177 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.common.errors.SerializationException; +import org.apache.kafka.common.serialization.Deserializer; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.common.serialization.Serializer; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; +import java.util.Properties; + +/** + * class {@link HarmanPersistentPrimitiveMapValueStore} + * extends {@link HarmanPersistentKVStore}. + */ +public class HarmanPersistentPrimitiveMapValueStore extends HarmanPersistentKVStore> { + + /** The Constant OBJECT_MAPPER. */ + public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * {@link HarmanPersistentPrimitiveMapValueStore}. + * + * @param name name + * @param changeLoggingEnabled changeLoggingEnabled + * @param properties properties + */ + + public HarmanPersistentPrimitiveMapValueStore(String name, boolean changeLoggingEnabled, Properties properties) { + + super(name, changeLoggingEnabled, Serdes.serdeFrom(String.class), Serdes.serdeFrom(new Serializer>() { + @Override + public void configure(Map configs, boolean isKey) { + //method + } + + @Override + public byte[] serialize(String topic, Map data) { + if (data == null) { + return new byte[0]; + } + try { + return OBJECT_MAPPER.writeValueAsBytes(data); + } catch (JsonProcessingException e) { + throw new SerializationException(e); + } + } + + @Override + public void close() { + //method + } + + }, new Deserializer>() { + @Override + public void configure(Map configs, boolean isKey) { + //method + } + + @Override + public Map deserialize(String topic, byte[] data) { + if (data == null) { + return Collections.emptyMap(); + } + try { + return OBJECT_MAPPER.readValue(data, Map.class); + } catch (IOException e) { + throw new SerializationException(e); + } + } + + @Override + public void close() { + //method + } + + }), properties); + } + + /** + * HarmanPersistentPrimitiveMapValueStore. + * + * @param name name + * @param changeLoggingEnabled changeLoggingEnabled + */ + public HarmanPersistentPrimitiveMapValueStore(String name, boolean changeLoggingEnabled) { + super(name, changeLoggingEnabled, Serdes.serdeFrom(String.class), Serdes.serdeFrom(new Serializer>() { + @Override + public void configure(Map configs, boolean isKey) { + //method + } + + @Override + public byte[] serialize(String topic, Map data) { + if (data == null) { + return new byte[0]; + } + try { + return OBJECT_MAPPER.writeValueAsBytes(data); + } catch (JsonProcessingException e) { + throw new SerializationException(e); + } + } + + @Override + public void close() { + //method + } + + }, new Deserializer>() { + @Override + public void configure(Map configs, boolean isKey) { + //method + } + + @Override + public Map deserialize(String topic, byte[] data) { + if (data == null) { + return Collections.emptyMap(); + } + try { + return OBJECT_MAPPER.readValue(data, Map.class); + } catch (IOException e) { + throw new SerializationException(e); + } + } + + @Override + public void close() { + // no additional operation to be performed at close + } + + }), new Properties()); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanRocksDBStore.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanRocksDBStore.java new file mode 100644 index 0000000..a714712 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanRocksDBStore.java @@ -0,0 +1,1052 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.metrics.Sensor.RecordingLevel; +import org.apache.kafka.common.serialization.Serde; +import org.apache.kafka.common.utils.Bytes; +import org.apache.kafka.common.utils.Utils; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.StreamsConfig; +import org.apache.kafka.streams.errors.InvalidStateStoreException; +import org.apache.kafka.streams.errors.ProcessorStateException; +import org.apache.kafka.streams.processor.ProcessorContext; +import org.apache.kafka.streams.processor.StateStore; +import org.apache.kafka.streams.processor.StateStoreContext; +import org.apache.kafka.streams.processor.internals.ChangelogRecordDeserializationHelper; +import org.apache.kafka.streams.processor.internals.RecordBatchingStateRestoreCallback; +import org.apache.kafka.streams.query.Position; +import org.apache.kafka.streams.query.PositionBound; +import org.apache.kafka.streams.query.Query; +import org.apache.kafka.streams.query.QueryConfig; +import org.apache.kafka.streams.query.QueryResult; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.apache.kafka.streams.state.KeyValueStore; +import org.apache.kafka.streams.state.RocksDBConfigSetter; +import org.apache.kafka.streams.state.StateSerdes; +import org.apache.kafka.streams.state.internals.BatchWritingStore; +import org.apache.kafka.streams.state.internals.BlockBasedTableConfigWithAccessibleCache; +import org.apache.kafka.streams.state.internals.OffsetCheckpoint; +import org.apache.kafka.streams.state.internals.StoreQueryUtils; +import org.apache.kafka.streams.state.internals.metrics.RocksDBMetricsRecorder; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.context.StreamBaseSpringContext; +import org.eclipse.ecsp.analytics.stream.base.metrics.reporter.HarmanRocksDBMetricsExporter; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.rocksdb.BlockBasedTableConfig; +import org.rocksdb.Cache; +import org.rocksdb.CompactionStyle; +import org.rocksdb.CompressionType; +import org.rocksdb.FlushOptions; +import org.rocksdb.LRUCache; +import org.rocksdb.Options; +import org.rocksdb.RocksDB; +import org.rocksdb.RocksDBException; +import org.rocksdb.RocksIterator; +import org.rocksdb.Statistics; +import org.rocksdb.TableFormatConfig; +import org.rocksdb.WriteBatch; +import org.rocksdb.WriteOptions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Properties; +import java.util.Set; + +import static org.apache.kafka.streams.StreamsConfig.InternalConfig.IQ_CONSISTENCY_OFFSET_VECTOR_ENABLED; +import static org.apache.kafka.streams.StreamsConfig.METRICS_RECORDING_LEVEL_CONFIG; +import static org.apache.kafka.streams.processor.internals.ProcessorContextUtils.getMetricsImpl; +import static org.eclipse.ecsp.analytics.stream.base.KafkaStreamsProcessorContext.StoreType.ROCKSDB; + +/** + * A persistent key-value store based on RocksDB. + * Note that the use of array-typed keys is discouraged because they result + * in incorrect caching behavior. If you intend to work on byte + * arrays as key, for example, you may want to wrap them with the {@code Bytes} class, + * i.e. use {@code RocksDBStore} rather than + * {@code RocksDBStore}. + * + * @param + * The key type + * @param + * The value type + * @see org.apache.kafka.streams.state.Stores#create(String) + */ +public class HarmanRocksDBStore implements KeyValueStore, BatchWritingStore { + + /** The Constant LOG. */ + private static final Logger LOG = LoggerFactory.getLogger(HarmanRocksDBStore.class); + + /** The Constant COMPRESSION_TYPE. */ + private static final CompressionType COMPRESSION_TYPE = CompressionType.NO_COMPRESSION; + + /** The Constant COMPACTION_STYLE. */ + private static final CompactionStyle COMPACTION_STYLE = CompactionStyle.UNIVERSAL; + + /** The Constant WRITE_BUFFER_SIZE. */ + private static final long WRITE_BUFFER_SIZE = 32 * 1024 * 1024L; + + /** The Constant BLOCK_CACHE_SIZE. */ + private static final long BLOCK_CACHE_SIZE = 100 * 1024 * 1024L; + + /** The Constant BLOCK_SIZE. */ + private static final long BLOCK_SIZE = 4096L; + + /** The Constant MAX_WRITE_BUFFERS. */ + private static final int MAX_WRITE_BUFFERS = 3; + + /** The Constant DB_FILE_DIR. */ + private static final String DB_FILE_DIR = "rocksdb"; + + /** The Constant LOG_FILE_TIME_TO_ROLL. */ + private static final long LOG_FILE_TIME_TO_ROLL = 30 * 60 * 60 * 24L; //30-days + + /** The Constant KEEP_LOG_FILE_NUM. */ + private static final long KEEP_LOG_FILE_NUM = 5; //5-files max + + /** The Constant MAX_LOG_FILE_SIZE. */ + private static final long MAX_LOG_FILE_SIZE = 200 * 1024 * 1024L; //200Mb each file + + /** The Constant RECYCLE_LOG_FILE_NUM. */ + private static final long RECYCLE_LOG_FILE_NUM = 0L; + + /** The Constant POSITION_FILE_SUFFIX. */ + private static final String POSITION_FILE_SUFFIX = ".position"; + + /** The Constant FROM_STORE. */ + private static final String FROM_STORE = " from store "; + + /** The prop. */ + private static Properties prop; + + /** The name. */ + private final String name; + + /** The parent dir. */ + private final String parentDir; + + /** The open iterators. */ + private final Set openIterators = new HashSet<>(); + + /** The key serde. */ + private final Serde keySerde; + + /** The value serde. */ + private final Serde valueSerde; + + /** The metrics recorder. */ + private final RocksDBMetricsRecorder metricsRecorder; + + /** The open. */ + protected volatile boolean open = false; + + /** The context. */ + protected StateStoreContext context; + + /** The position. */ + protected Position position; + + /** The db dir. */ + File dbDir; + + /** The cache. */ + private Cache cache; + + /** The serdes. */ + private StateSerdes serdes; + + /** The db. */ + private RocksDB db; + // the following option objects will be created in the constructor and + /** The options. */ + // closed in the close() method + private Options options; + + /** The write options. */ + private WriteOptions writeOptions; + + /** The flush options. */ + private FlushOptions flushOptions; + + /** The state store config. */ + private Properties stateStoreConfig; + + /** The consistency enabled. */ + private boolean consistencyEnabled = false; + + /** The user specified statistics. */ + private boolean userSpecifiedStatistics = false; + + /** The rocks DB enabled. */ + private boolean rocksDBEnabled; + + /** + * Instantiates a new harman rocks DB store. + * + * @param name the name + * @param keySerde the key serde + * @param valueSerde the value serde + * @param stateStoreConfig the state store config + */ + public HarmanRocksDBStore(String name, Serde keySerde, Serde valueSerde, Properties stateStoreConfig) { + this(name, DB_FILE_DIR, keySerde, valueSerde, stateStoreConfig, + new RocksDBMetricsRecorder(ROCKSDB.toString(), name)); + } + + /** + * HarmanRocksDBStore. + * + * @param name name + * @param parentDir parentDir + * @param keySerde keySerde + * @param valueSerde valueSerde + * @param stateStoreConfig stateStoreConfig + * @param metricsRecorder metricsRecorder + */ + public HarmanRocksDBStore(String name, String parentDir, Serde keySerde, + Serde valueSerde, Properties stateStoreConfig, + final RocksDBMetricsRecorder metricsRecorder) { + this.name = name; + this.parentDir = parentDir; + this.keySerde = keySerde; + this.valueSerde = valueSerde; + this.stateStoreConfig = stateStoreConfig; + this.metricsRecorder = metricsRecorder; + } + + /** + * Sets the properties. + * + * @param prop the new properties + */ + public static void setProperties(Properties prop) { + LOG.info("Setting env properties in HarmanRocksDBStore..."); + HarmanRocksDBStore.prop = prop; + } + + /** + * openDB connection. + * + * @param context StateStoreContext + */ + @SuppressWarnings("unchecked") + public void openDB(StateStoreContext context) { + + // initialize the default rocksdb options + final BlockBasedTableConfigWithAccessibleCache tableConfig = new BlockBasedTableConfigWithAccessibleCache(); + cache = new LRUCache(BLOCK_CACHE_SIZE); + tableConfig.setBlockCache(cache); + tableConfig.setBlockSize(BLOCK_SIZE); + + options = new Options(); + options.setTableFormatConfig(tableConfig); + options.setWriteBufferSize(WRITE_BUFFER_SIZE); + options.setCompressionType(COMPRESSION_TYPE); + options.setCompactionStyle(COMPACTION_STYLE); + options.setMaxWriteBufferNumber(MAX_WRITE_BUFFERS); + options.setCreateIfMissing(true); + options.setErrorIfExists(false); + + // if values not provided via properties, it will be set to rocksdb provided default values. + if (!stateStoreConfig.isEmpty()) { + options.setLogFileTimeToRoll(stateStoreConfig.containsKey("rocksdb.log.file.roll.time") + ? Long.parseLong(stateStoreConfig.get("rocksdb.log.file.roll.time").toString()) + : LOG_FILE_TIME_TO_ROLL); + + options.setKeepLogFileNum(stateStoreConfig.containsKey("rocksdb.log.files.to.keep") + ? Long.parseLong(stateStoreConfig.get("rocksdb.log.files.to.keep").toString()) + : KEEP_LOG_FILE_NUM); + + options.setMaxLogFileSize(stateStoreConfig.containsKey("rocksdb.log.file.max.size") + ? Long.parseLong(stateStoreConfig.get("rocksdb.log.file.max.size").toString()) + : MAX_LOG_FILE_SIZE); + + options.setRecycleLogFileNum(stateStoreConfig.containsKey("rocksdb.log.file.recycle.count") + ? Long.parseLong(stateStoreConfig.get("rocksdb.log.file.recycle.count").toString()) + : RECYCLE_LOG_FILE_NUM); + + LOG.info( + "Rocks db properties finally set as: logFilesToKeep: {}, " + + "maxLogFileSize: {}, recycleLogFileNum: {}, logFileTimeToRoll: {}", + options.keepLogFileNum(), options.maxLogFileSize(), + options.recycleLogFileNum(), options.logFileTimeToRoll()); + } + + writeOptions = new WriteOptions(); + writeOptions.setDisableWAL(Boolean.valueOf((String) (!stateStoreConfig.isEmpty() + && stateStoreConfig.containsKey("rocksdb.disable.wal") + ? stateStoreConfig.get("rocksdb.disable.wal") + : "true"))); + + flushOptions = new FlushOptions(); + flushOptions.setWaitForFlush(true); + + final Map configs = context.appConfigs(); + final Class configSetterClass = (Class) configs + .get(StreamsConfig.ROCKSDB_CONFIG_SETTER_CLASS_CONFIG); + if (configSetterClass != null) { + final RocksDBConfigSetter configSetter = Utils.newInstance(configSetterClass); + configSetter.setConfig(name, options, configs); + } + // we need to construct the serde while opening DB since + // it is also triggered by windowed DB segments without initialization + this.serdes = new StateSerdes<>(name, + keySerde == null ? (Serde) context.keySerde() : keySerde, + valueSerde == null ? (Serde) context.valueSerde() : valueSerde); + + this.dbDir = new File(new File(context.stateDir(), parentDir), this.name); + this.db = openRocksDb(this.dbDir, this.options); + + if (this.rocksDBEnabled) { + maybeSetUpStatistics(configs); + addValueProvidersToMetricsRecorder(); + LOG.info("Setting rocksDB instance in rocks db metrics exporter"); + setRocksDbObject(); + } + open = true; + } + + /** + * Sets the rocks db object. + */ + private void setRocksDbObject() { + HarmanRocksDBMetricsExporter exporter; + exporter = StreamBaseSpringContext.getBean(HarmanRocksDBMetricsExporter.class); + LOG.info("Fetched bean: {} from spring context", exporter.getClass().getName()); + exporter.setRocksDb(this.db); + } + + /** + * Open rocks db. + * + * @param dir the dir + * @param options the options + * @return the rocks DB + */ + private RocksDB openRocksDb(File dir, Options options) { + try { + dir.getParentFile().mkdirs(); + return RocksDB.open(options, dir.getAbsolutePath()); + } catch (RocksDBException e) { + throw new ProcessorStateException("Error opening store " + this.name + " at location " + dir.toString(), e); + } + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return this.name; + } + + /** + * Persistent. + * + * @return true, if successful + */ + @Override + public boolean persistent() { + return true; + } + + /** + * Checks if is open. + * + * @return true, if is open + */ + @Override + public boolean isOpen() { + return open; + } + + /** + * Gets the position. + * + * @return the position + */ + @Override + public Position getPosition() { + return position; + } + + /** + * Gets the. + * + * @param key the key + * @return the v + */ + @Override + public synchronized V get(K key) { + validateStoreOpen(); + byte[] byteValue = getInternal(serdes.rawKey(key)); + if (byteValue == null) { + return null; + } else { + return serdes.valueFrom(byteValue); + } + } + + /** + * Validate store open. + */ + private void validateStoreOpen() { + if (!open) { + throw new InvalidStateStoreException("Store " + this.name + " is currently closed"); + } + } + + /** + * Gets the internal. + * + * @param rawKey the raw key + * @return the internal + */ + private byte[] getInternal(byte[] rawKey) { + try { + return this.db.get(rawKey); + } catch (RocksDBException e) { + throw new ProcessorStateException("Error while getting value for key " + serdes.keyFrom(rawKey) + + FROM_STORE + this.name, e); + } + } + + /** + * Put. + * + * @param key the key + * @param value the value + */ + @SuppressWarnings("unchecked") + @Override + public synchronized void put(K key, V value) { + validateStoreOpen(); + byte[] rawKey = serdes.rawKey(key); + byte[] rawValue = serdes.rawValue(value); + putInternal(rawKey, rawValue); + StoreQueryUtils.updatePosition(position, context); + } + + /** + * Put if absent. + * + * @param key the key + * @param value the value + * @return the v + */ + @Override + public synchronized V putIfAbsent(K key, V value) { + V originalValue = get(key); + if (originalValue == null) { + put(key, value); + } + return originalValue; + } + + /** + * Put internal. + * + * @param rawKey the raw key + * @param rawValue the raw value + */ + private void putInternal(byte[] rawKey, byte[] rawValue) { + if (rawValue == null) { + try { + db.delete(writeOptions, rawKey); + } catch (RocksDBException e) { + LOG.error("Error while removing key " + + serdes.keyFrom(rawKey) + " from store " + this.name, e); + throw new ProcessorStateException("Error while removing key " + serdes.keyFrom(rawKey) + + FROM_STORE + this.name, e); + } + } else { + try { + db.put(writeOptions, rawKey, rawValue); + } catch (RocksDBException e) { + LOG.error("Error while executing put key " + serdes.keyFrom(rawKey) + + " and value " + serdes.keyFrom(rawValue) + " from store " + this.name, e); + throw new ProcessorStateException("Error while executing put key " + serdes.keyFrom(rawKey) + + " and value " + serdes.keyFrom(rawValue) + FROM_STORE + this.name, e); + } + } + } + + /** + * Put all. + * + * @param entries the entries + */ + @Override + public void putAll(List> entries) { + try (WriteBatch batch = new WriteBatch()) { + for (KeyValue entry : entries) { + final byte[] rawKey = serdes.rawKey(entry.key); + if (entry.value == null) { + db.delete(rawKey); + } else { + final byte[] value = serdes.rawValue(entry.value); + batch.put(rawKey, value); + } + } + db.write(writeOptions, batch); + StoreQueryUtils.updatePosition(position, context); + } catch (RocksDBException e) { + throw new ProcessorStateException("Error while batch writing to store " + this.name, e); + } + } + + /** + * Query. + * + * @param the generic type + * @param query the query + * @param positionBound the position bound + * @param config the config + * @return the query result + */ + @Override + public QueryResult query( + final Query query, + final PositionBound positionBound, + final QueryConfig config) { + + return StoreQueryUtils.handleBasicQueries( + query, + positionBound, + config, + this, + position, + context + ); + } + + /** + * Delete. + * + * @param key the key + * @return the v + */ + @Override + public synchronized V delete(K key) { + V value = get(key); + put(key, null); + return value; + } + + /** + * Range. + * + * @param from the from + * @param to the to + * @return the key value iterator + */ + @Override + public synchronized KeyValueIterator range(K from, K to) { + validateStoreOpen(); + // query rocksdb + final RocksDBRangeIterator rocksDBRangeIterator = new RocksDBRangeIterator(db.newIterator(), serdes, from, to); + openIterators.add(rocksDBRangeIterator); + return rocksDBRangeIterator; + } + + /** + * All. + * + * @return the key value iterator + */ + @Override + public synchronized KeyValueIterator all() { + validateStoreOpen(); + // query rocksdb + RocksIterator innerIter = db.newIterator(); + innerIter.seekToFirst(); + final RocksDbIterator rocksDbIterator = new RocksDbIterator(innerIter, serdes); + openIterators.add(rocksDbIterator); + return rocksDbIterator; + } + + /** + * Write. + * + * @param batch the batch + * @throws RocksDBException the rocks DB exception + */ + @Override + public void write(final WriteBatch batch) throws RocksDBException { + db.write(writeOptions, batch); + } + + /** + * Return an approximate count of key-value mappings in this store. + * + * RocksDB cannot return an exact entry count without doing a + * full scan, so this method relies on the + * rocksdb.estimate-num-keys property to get an approximate + * count. The returned size also includes a count of dirty keys in the + * store's in-memory cache, which may lead to some double-counting + * of entries and inflate the estimate. + * + * @return an approximate count of key-value mappings in the store. + */ + @Override + public long approximateNumEntries() { + long value; + try { + value = this.db.getLongProperty("rocksdb.estimate-num-keys"); + } catch (RocksDBException e) { + throw new ProcessorStateException("Error fetching property from store " + this.name, e); + } + if (isOverflowing(value)) { + return Long.MAX_VALUE; + } + return value; + } + + /** + * Checks if is overflowing. + * + * @param value the value + * @return true, if is overflowing + */ + private boolean isOverflowing(long value) { + // RocksDB returns an unsigned 8-byte integer, which could overflow long + // and manifest as a negative value. + return value < 0; + } + + /** + * Flush. + */ + @Override + public synchronized void flush() { + if (db == null) { + return; + } + // flush RocksDB + flushInternal(); + } + + /** + * if flushing failed because of any internal store exceptions. + * + * @throws ProcessorStateException ProcessorStateException + */ + private void flushInternal() { + try { + db.flush(flushOptions); + } catch (RocksDBException e) { + throw new ProcessorStateException("Error while executing flush from store " + this.name, e); + } + } + + /** + * Close. + */ + @Override + public synchronized void close() { + if (!open) { + return; + } + open = false; + closeOpenIterators(); + + if (this.rocksDBEnabled) { + metricsRecorder.removeValueProviders(name); + } + + // Important: do not rearrange the order in which the below objects are closed! + db.close(); + options.close(); + writeOptions.close(); + flushOptions.close(); + cache.close(); + + options = null; + writeOptions = null; + flushOptions = null; + db = null; + cache = null; + } + + /** + * Close open iterators. + */ + private void closeOpenIterators() { + for (KeyValueIterator iterator : new HashSet<>(openIterators)) { + iterator.close(); + } + openIterators.clear(); + } + + /** + * Maybe set up statistics. + * + * @param configs the configs + */ + private void maybeSetUpStatistics(final Map configs) { + if (options.statistics() != null) { + userSpecifiedStatistics = true; + } + if (!userSpecifiedStatistics && RecordingLevel.forName( + (String) configs.get(METRICS_RECORDING_LEVEL_CONFIG)) == RecordingLevel.DEBUG) { + + // metrics recorder will clean up statistics object + final Statistics statistics = new Statistics(); + options.setStatistics(statistics); + } + } + + /** + * prepareBatchForRestore(). + * + * @param records records + * @param batch batch + * @throws RocksDBException RocksDBException + */ + public void prepareBatchForRestore(final Collection> records, + final WriteBatch batch) throws RocksDBException { + for (final KeyValue record : records) { + addToBatch(record.key, record.value, batch); + } + } + + /** + * addToBatch(). + * + * @param key key + * @param value value + * @param batch batch + * @throws RocksDBException RocksDBException + */ + public void addToBatch(final byte[] key, + final byte[] value, + final WriteBatch batch) throws RocksDBException { + if (value == null) { + batch.delete(key); + } else { + batch.put(key, value); + } + } + + /** + * Adds the to batch. + * + * @param record the record + * @param batch the batch + * @throws RocksDBException the rocks DB exception + */ + @Override + public void addToBatch(KeyValue record, WriteBatch batch) throws RocksDBException { + addToBatch(record.key, record.value, batch); + } + + /** + * Initialize HarmanRocksDBStore. + * + * @param context the context + * @param root the root + * @deprecated (Use StateStoreContext instead) + */ + @Deprecated(since = "2.41.0-1") + @Override + public void init(final ProcessorContext context, final StateStore root) { + if (context instanceof StateStoreContext storeContext) { + init(storeContext, root); + } else { + throw new UnsupportedOperationException( + "Use RocksDBStore#init(StateStoreContext, StateStore) instead." + ); + } + } + + /** + * Inits the. + * + * @param context the context + * @param root the root + */ + @Override + public void init(final StateStoreContext context, final StateStore root) { + String rocksDBMetricsEnabled = HarmanRocksDBStore.prop.getProperty(PropertyNames.ROCKSDB_METRICS_ENABLED); + LOG.info("RocksDB metrics enabled set to : {}", rocksDBMetricsEnabled); + this.rocksDBEnabled = !StringUtils.isEmpty(rocksDBMetricsEnabled) + && rocksDBMetricsEnabled.equalsIgnoreCase(Constants.TRUE); + if (this.rocksDBEnabled) { + metricsRecorder.init(getMetricsImpl(context), context.taskId()); + } + + openDB(context); + + final File positionCheckpointFile = new File(context.stateDir(), FilenameUtils.getName(name() + + POSITION_FILE_SUFFIX)); + try { + LOG.debug("Attempting to create position checkpoint file : {}", positionCheckpointFile.getCanonicalPath()); + if (!positionCheckpointFile.getCanonicalPath().startsWith(context.stateDir().getCanonicalPath())) { + throw new IOException("Could not open file : " + name() + POSITION_FILE_SUFFIX + + " Expected base path does not match the input base path. Possible file traversal attack!"); + } + } catch (IOException e) { + String errMsg = "Error creating position checkpoint file " + name() + + POSITION_FILE_SUFFIX + " at location " + positionCheckpointFile.getAbsolutePath(); + throw new ProcessorStateException(errMsg, e); + } + OffsetCheckpoint positionCheckpoint; + positionCheckpoint = new OffsetCheckpoint(positionCheckpointFile); + this.position = StoreQueryUtils.readPositionFromCheckpoint(positionCheckpoint); + + context.register( + root, + (RecordBatchingStateRestoreCallback) this::restoreBatch, + () -> StoreQueryUtils.checkpointPosition(positionCheckpoint, position) + ); + + consistencyEnabled = StreamsConfig.InternalConfig.getBoolean( + context.appConfigs(), + IQ_CONSISTENCY_OFFSET_VECTOR_ENABLED, + false); + + this.context = context; + } + + /** + * The Class RocksDbIterator. + */ + class RocksDbIterator implements KeyValueIterator { + + /** The iter. */ + private final RocksIterator iter; + + /** The serdes. */ + private final StateSerdes serdes; + + /** The open. */ + private boolean open = true; + + /** + * Instantiates a new rocks db iterator. + * + * @param iter the iter + * @param serdes the serdes + */ + RocksDbIterator(RocksIterator iter, StateSerdes serdes) { + this.iter = iter; + this.serdes = serdes; + } + + /** + * Peek raw key. + * + * @return the byte[] + */ + byte[] peekRawKey() { + return iter.key(); + } + + /** + * Gets the key value. + * + * @return the key value + */ + private KeyValue getKeyValue() { + return new KeyValue<>(serdes.keyFrom(iter.key()), serdes.valueFrom(iter.value())); + } + + /** + * Checks for next. + * + * @return true, if successful + */ + @Override + public synchronized boolean hasNext() { + if (!open) { + throw new InvalidStateStoreException("store %s has closed"); + } + return iter.isValid(); + } + + /** + * KeyValue(). + * + * @return the key value + * @throws NoSuchElementException if no next element exist + */ + @Override + public synchronized KeyValue next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + + KeyValue entry = this.getKeyValue(); + iter.next(); + return entry; + } + + /** + * remove(). + * + * @throws UnsupportedOperationException UnsupportedOperationException + */ + @Override + public void remove() { + throw new UnsupportedOperationException("RocksDB iterator does not support remove"); + } + + /** + * Close. + */ + @Override + public synchronized void close() { + open = false; + openIterators.remove(this); + iter.close(); + } + + /** + * Peek next key. + * + * @return the k + */ + @Override + public K peekNextKey() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return serdes.keyFrom(iter.key()); + + } + + } + + /** + * The Class RocksDBRangeIterator. + */ + private class RocksDBRangeIterator extends RocksDbIterator { + // RocksDB's JNI interface does not expose getters/setters that allow + // the + // comparator to be pluggable, and the default is lexicographic, so it's + /** The comparator. */ + // safe to just force lexicographic comparator here for now. + private final Comparator comparator = Bytes.BYTES_LEXICO_COMPARATOR; + + /** The raw to key. */ + private byte[] rawToKey; + + /** + * Instantiates a new rocks DB range iterator. + * + * @param iter the iter + * @param serdes the serdes + * @param from the from + * @param to the to + */ + RocksDBRangeIterator(RocksIterator iter, StateSerdes serdes, K from, K to) { + super(iter, serdes); + iter.seek(serdes.rawKey(from)); + this.rawToKey = serdes.rawKey(to); + } + + /** + * Checks for next. + * + * @return true, if successful + */ + @Override + public synchronized boolean hasNext() { + return super.hasNext() && comparator.compare(super.peekRawKey(), this.rawToKey) <= 0; + } + } + + /** + * Adds the value providers to metrics recorder. + */ + private void addValueProvidersToMetricsRecorder() { + final TableFormatConfig tableFormatConfig = options.tableFormatConfig(); + final Statistics statistics = userSpecifiedStatistics ? null : options.statistics(); + if (tableFormatConfig instanceof BlockBasedTableConfigWithAccessibleCache accessibleCache) { + final Cache blockCache = accessibleCache.blockCache(); + metricsRecorder.addValueProviders(name, db, blockCache, statistics); + } else if (tableFormatConfig instanceof BlockBasedTableConfig) { + throw new ProcessorStateException("The used block-based table format configuration does not expose the " + + "block cache. Use the BlockBasedTableConfig instance provided by Options#tableFormatConfig() to configure " + + "the block-based table format of RocksDB. Do not provide a new instance " + + "of BlockBasedTableConfig to " + "the RocksDB options."); + } else { + metricsRecorder.addValueProviders(name, db, null, statistics); + } + } + + /** + * Restore batch. + * + * @param records the records + */ + void restoreBatch(final Collection> records) { + try (final WriteBatch batch = new WriteBatch()) { + final List> keyValues = new ArrayList<>(); + for (final ConsumerRecord consumerRecord : records) { + ChangelogRecordDeserializationHelper.applyChecksAndUpdatePosition( + consumerRecord, + consistencyEnabled, + position + ); + // If version headers are not present or version is V0 + keyValues.add(new KeyValue<>(consumerRecord.key(), consumerRecord.value())); + } + prepareBatchForRestore(keyValues, batch); + write(batch); + } catch (final RocksDBException e) { + throw new ProcessorStateException("Error restoring batch to store " + name, e); + } + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanRocksDBStoreSupplier.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanRocksDBStoreSupplier.java new file mode 100644 index 0000000..d767f3c --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanRocksDBStoreSupplier.java @@ -0,0 +1,112 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.common.utils.Bytes; +import org.apache.kafka.streams.state.KeyValueBytesStoreSupplier; +import org.apache.kafka.streams.state.KeyValueStore; + +import java.util.Properties; + +/** + * class HarmanRocksDBStoreSupplier implements KeyValueBytesStoreSupplier. + */ +public class HarmanRocksDBStoreSupplier implements KeyValueBytesStoreSupplier { + + /** The name. */ + private final String name; + + /** The state store config. */ + private final Properties stateStoreConfig; + + /** + * Instantiates a new harman rocks DB store supplier. + * + * @param name the name + */ + public HarmanRocksDBStoreSupplier(final String name) { + this(name, new Properties()); + } + + /** + * Instantiates a new harman rocks DB store supplier. + * + * @param name the name + * @param stateStoreConfig the state store config + */ + public HarmanRocksDBStoreSupplier(final String name, final Properties stateStoreConfig) { + this.name = name; + this.stateStoreConfig = stateStoreConfig; + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return name; + } + + /** + * Gets the. + * + * @return the key value store + */ + @Override + public KeyValueStore get() { + + return new HarmanRocksDBStore<>(name, + Serdes.Bytes(), + Serdes.ByteArray(), stateStoreConfig); + } + + /** + * Metrics scope. + * + * @return the string + */ + @Override + public String metricsScope() { + return "rocksdb-state"; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/JsonStateStore.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/JsonStateStore.java new file mode 100644 index 0000000..c1296eb --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/JsonStateStore.java @@ -0,0 +1,154 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.common.errors.SerializationException; +import org.apache.kafka.common.serialization.Deserializer; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.common.serialization.Serializer; + +import java.io.IOException; +import java.util.Map; +import java.util.Properties; + +/** + * class JsonStateStore extends HarmanPersistentKVStore. + */ +public class JsonStateStore extends HarmanPersistentKVStore { + + /** The Constant OBJECT_MAPPER. */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * JsonStateStore Constructor. + * + * @param name name + * @param changeLoggingEnabled changeLoggingEnabled + * @param properties properties + */ + public JsonStateStore(String name, boolean changeLoggingEnabled, Properties properties) { + super(name, changeLoggingEnabled, Serdes.serdeFrom(String.class), Serdes.serdeFrom(new Serializer() { + @Override + public void configure(Map configs, boolean isKey) { + //Overridden method + } + + @Override + public byte[] serialize(String topic, JsonNode data) { + if (data == null) { + return new byte[0]; + } + try { + return OBJECT_MAPPER.writeValueAsBytes(data); + } catch (JsonProcessingException e) { + throw new SerializationException(e); + } + } + + }, new Deserializer() { + + @Override + public void configure(Map configs, boolean isKey) { + //Overridden method + } + + @Override + public JsonNode deserialize(String topic, byte[] data) { + if (data == null) { + return null; + } + try { + return OBJECT_MAPPER.readTree(data); + } catch (IOException e) { + throw new SerializationException(e); + } + } + }), properties); + } + + /** + * JsonStateStore. + * + * @param name name + * @param changeLoggingEnabled changeLoggingEnabled + */ + public JsonStateStore(String name, boolean changeLoggingEnabled) { + super(name, changeLoggingEnabled, Serdes.serdeFrom(String.class), Serdes.serdeFrom(new Serializer() { + @Override + public void configure(Map configs, boolean isKey) { + //Overridden method + } + + @Override + public byte[] serialize(String topic, JsonNode data) { + if (data == null) { + return new byte[0]; + } + try { + return OBJECT_MAPPER.writeValueAsBytes(data); + } catch (JsonProcessingException e) { + throw new SerializationException(e); + } + } + + }, new Deserializer() { + + @Override + public void configure(Map configs, boolean isKey) { + //Overridden method + } + + @Override + public JsonNode deserialize(String topic, byte[] data) { + if (data == null) { + return null; + } + try { + return OBJECT_MAPPER.readTree(data); + } catch (IOException e) { + throw new SerializationException(e); + } + } + }), new Properties()); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/MapObjectStateStore.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/MapObjectStateStore.java new file mode 100644 index 0000000..fe23724 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/MapObjectStateStore.java @@ -0,0 +1,334 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.processor.ProcessorContext; +import org.apache.kafka.streams.processor.StateStore; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.apache.kafka.streams.state.KeyValueStore; +import org.eclipse.ecsp.analytics.stream.base.utils.ObjectUtils; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static org.eclipse.ecsp.analytics.stream.base.utils.Constants.RECEIVED_NULL_KEY; + +/** + * InMemory hash map state store. This is for supporting CFMS code base as part of Ignite2.o + */ +@Component +@Scope("prototype") +public class MapObjectStateStore implements KeyValueStore { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(MapObjectStateStore.class); + + /** The Constant STORE_NAME. */ + private static final String STORE_NAME = "MapStateStoreName"; + + /** The map store. */ + private Map mapStore = new ConcurrentHashMap<>(); + + /** + * A constant dummy name, as this map store will never be called by + * its name, as it is maintained by the application not kafka. + * + * @return name + */ + @Override + public String name() { + return STORE_NAME; + } + + /** + * Inits the. + * + * @param context the context + * @param root the root + */ + @Override + public void init(ProcessorContext context, StateStore root) { + // This method will not be called + } + + /** + * Flush. + */ + @Override + public void flush() { + logger.info("Clearing the hash map state store"); + mapStore.clear(); + } + + /** + * Close. + */ + @Override + public void close() { + logger.error("Method close is not implemented by MapObjectStateStore class."); + } + + /** + * Persistent. + * + * @return true, if successful + */ + @Override + public boolean persistent() { + return true; + } + + /** + * Checks if is open. + * + * @return true, if is open + */ + @Override + public boolean isOpen() { + return true; + } + + /** + * Gets the. + * + * @param key the key + * @return the object + */ + @Override + public Object get(String key) { + ObjectUtils.requireNonNull(key, RECEIVED_NULL_KEY); + logger.debug("Retrieving the value for the key:{}", key); + return mapStore.get(key); + } + + /** + * Range. + * + * @param from the from + * @param to the to + * @return the key value iterator + */ + @Override + public KeyValueIterator range(String from, String to) { + return null; + } + + /** + * All. + * + * @return the key value iterator + */ + @Override + public KeyValueIterator all() { + return new MapIterator(this.mapStore); + } + + /** + * Approximate num entries. + * + * @return the long + */ + @Override + public long approximateNumEntries() { + return mapStore.size(); + } + + /** + * Put. + * + * @param key the key + * @param value the value + */ + @Override + public void put(String key, Object value) { + ObjectUtils.requireNonNull(key, RECEIVED_NULL_KEY); + ObjectUtils.requireNonNull(value, "Received null value."); + logger.debug("Putting into map state store. key:{} and value:{}", key, value); + mapStore.put(key, value); + } + + /** + * Put if absent. + * + * @param key the key + * @param value the value + * @return the object + */ + @Override + public Object putIfAbsent(String key, Object value) { + ObjectUtils.requireNonNull(key, RECEIVED_NULL_KEY); + ObjectUtils.requireNonNull(value, "Received null value."); + // check if the key exist + if (!this.mapStore.containsKey(key)) { + logger.debug("Adding key:{} to the map state store as it is not present.", key); + put(key, value); + // return the incoming value as return value. + return value; + } else { + logger.debug("key:{} will not be added to the map state store as it is already present.", key); + return null; + } + } + + /** + * Put all. + * + * @param entries the entries + */ + @Override + public void putAll(List> entries) { + ObjectUtils.requireNonNull(entries, "Received null values."); + for (KeyValue keyValue : entries) { + String key = keyValue.key; + Object val = keyValue.value; + logger.debug("Putting key:{} and value:{} in the map state store."); + put(key, val); + } + + } + + /** + * Delete. + * + * @param key the key + * @return the object + */ + @Override + public Object delete(String key) { + ObjectUtils.requireNonNull(mapStore, "Uninitialized state store."); + Object obj = this.mapStore.get(key); + if (null != obj) { + logger.debug("Deleting key:{} from map state store.", key); + this.mapStore.remove(key); + } + return obj; + } + + /** + * Iterator is not required to iterate over the map elements, but + * in order to be compatible with other state stores, we are implementing + * this as an iterator. + */ + private class MapIterator implements KeyValueIterator { + + /** The key iter. */ + // map keys iterator + Iterator keyIter; + + /** The map. */ + private Map map; + + /** + * Since we have to iterate over the original map, make a deep copy + * of this map. + * + * @param map the map + */ + public MapIterator(Map map) { + /* + * Since we have to iterate over the original map, make a deep copy + * of this map. + */ + logger.trace("Initializing iterator for map state store."); + this.map = new HashMap<>(); + this.map.putAll(map); + keyIter = map.keySet().iterator(); + + } + + /** + * Checks for next. + * + * @return true, if successful + */ + @Override + public boolean hasNext() { + return keyIter.hasNext(); + } + + /** + * Next. + * + * @return the key value + */ + @Override + public KeyValue next() { + + if (hasNext()) { + // Retrieve the key from the list + String key = this.keyIter.next(); + // now retrieve the key from the map + Object val = this.map.get(key); + logger.debug("Sending key:{} and value:{}", key, val); + // return the key and value + return new KeyValue<>(key, val); + } + return null; + } + + /** + * Close. + */ + @Override + public void close() { + logger.error("Close operation not supported for map iterator."); + } + + /** + * Peek next key. + * + * @return the string + */ + @Override + public String peekNextKey() { + throw new UnsupportedOperationException("close operation not supported for map iterator."); + } + + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/MutableKeyValueStore.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/MutableKeyValueStore.java new file mode 100644 index 0000000..81a5cf9 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/MutableKeyValueStore.java @@ -0,0 +1,160 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import org.eclipse.ecsp.analytics.stream.base.dao.CacheBackedInMemoryBatchCompleteCallBack; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.MutationId; +import org.eclipse.ecsp.entities.IgniteEntity; + +import java.util.Optional; + +/** + * interface MutableKeyValueStore extends IgniteEntity. + * + * @param the key type + * @param the value type + */ +public interface MutableKeyValueStore, V extends IgniteEntity> { + + /** + * Stores key value pair. The key value may be persisted to cache backend asynchronously. + * The given mutation id will be provided to the + * callback when this operation has been committed to the cache backend. + * + * @param key key + * @param value value + * @param mutationId mutationId + * @param cacheType the cache type + */ + public void put(K key, V value, Optional mutationId, String cacheType); + + /** + * Put if absent. + * + * @param key the key + * @param value the value + * @param mutationId the mutation id + * @param cacheType the cache type + * @return the v + */ + public V putIfAbsent(K key, V value, Optional mutationId, String cacheType); + + /** + * Deletes key value pair. The key may be deleted from backend asynchronously. + * The given mutation id will be provided to the callback + * when this operation has been committed to the cache backend. + * + * @param key key + * @param mutationId mutationId + * @param cacheType the cache type + */ + public void delete(K key, Optional mutationId, String cacheType); + + /** + * Sets the call back. + * + * @param callBack the new call back + */ + public void setCallBack(CacheBackedInMemoryBatchCompleteCallBack callBack); + + /** + * Sync withcache. + * + * @param regex the regex + * @param converter the converter + */ + public void syncWithcache(String regex, K converter); + + /** + * put to in-memory hashmap is backed by RMap in cache. + * + * @param mapKey mapKey + * @param mapEntryKey mapEntryKey + * @param mapEntryValue mapEntryValue + * @param mutationId mutationId + * @param cacheType the cache type + */ + public void putToMap(String mapKey, K mapEntryKey, V mapEntryValue, + Optional mutationId, String cacheType); + + /** + * Put to map if absent. + * + * @param mapKey the map key + * @param mapEntryKey the map entry key + * @param mapEntryValue the map entry value + * @param mutationId the mutation id + * @param cacheType the cache type + * @return the v + */ + public V putToMapIfAbsent(String mapKey, K mapEntryKey, V mapEntryValue, + Optional mutationId, String cacheType); + + /** + * delete from in-memory hashmap is backed by RMap in cache. + * + * @param mapKey mapKey + * @param mapEntryKey the map entry key + * @param mutationId mutationId + * @param cacheType the cache type + */ + public void deleteFromMap(String mapKey, K mapEntryKey, Optional mutationId, String cacheType); + + /** + * Sync will read all key value pairs from cache for the parent key and populate the in-memory map. + * + * @param mapKey mapKey + * @param converter converter + * @param cacheType the cache type + */ + public void syncWithMapCache(String mapKey, K converter, String cacheType); + + /** + * As per the implementatation of cache read should happen only from in memory key store. + * Force get bypasses the in-memory key store and + * reads from redis. + * + * @param mapKey mapKey + * @param mapEntryKey mapEntryKey + * @return the v + */ + public V forceGet(String mapKey, K mapEntryKey); + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/ObjectStateStore.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/ObjectStateStore.java new file mode 100644 index 0000000..81efbac --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/ObjectStateStore.java @@ -0,0 +1,193 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; +import com.esotericsoftware.kryo.serializers.CompatibleFieldSerializer; +import de.javakaffee.kryoserializers.jodatime.JodaDateTimeSerializer; +import org.apache.kafka.common.serialization.Deserializer; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.common.serialization.Serializer; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.joda.time.DateTime; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.Map; +import java.util.Properties; + +/** + * A generic object state store that uses Kryo to serialize and + * deserialize arbitrary entities. The default serializer is + * CompatibleFieldSerializer thereby allowing changes to java classes. + * + * @author ssasidharan + */ +public class ObjectStateStore extends HarmanPersistentKVStore { + + /** The Constant KRYO_REPO. */ + private static final ThreadLocal KRYO_REPO = ThreadLocal.withInitial(() -> { + Kryo k = new Kryo(); + k.setDefaultSerializer(CompatibleFieldSerializer.class); + k.register(DateTime.class, new JodaDateTimeSerializer()); + k.setCopyReferences(false); + return k; + }); + + /** + * ObjectStateStore. + * + * @param name name + * @param changeLoggingEnabled changeLoggingEnabled + * @param props props + */ + public ObjectStateStore(String name, boolean changeLoggingEnabled, Properties props) { + super(name, changeLoggingEnabled, Serdes.String(), Serdes.serdeFrom(new Serializer() { + + @Override + public void configure(Map configs, boolean isKey) { + // overridden method + } + + @Override + public byte[] serialize(String topic, Object data) { + Kryo kryo = KRYO_REPO.get(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(1 * Constants.BYTE_1024 * Constants.BYTE_1024); + Output output = new Output(baos); + kryo.writeClassAndObject(output, data); + output.close(); + byte[] bytes = baos.toByteArray(); + return bytes.length == 1 ? null : bytes; + } + + @Override + public void close() { + // overridden method + } + + }, new Deserializer() { + @Override + public void configure(Map configs, boolean isKey) { + // overridden method + } + + @Override + public Object deserialize(String topic, byte[] data) { + if (data != null) { + Input input = new Input(new ByteArrayInputStream(data)); + Object o = KRYO_REPO.get().readClassAndObject(input); + input.close(); + return o; + } + return null; + } + + @Override + public void close() { + // overridden method + } + }), props); + } + + /** + * ObjectStateStore. + * + * @param name name + * @param changeLoggingEnabled changeLoggingEnabled + */ + public ObjectStateStore(String name, boolean changeLoggingEnabled) { + super(name, changeLoggingEnabled, Serdes.String(), Serdes.serdeFrom(new Serializer() { + + @Override + public void configure(Map configs, boolean isKey) { + // overridden method + } + + @Override + public byte[] serialize(String topic, Object data) { + Kryo kryo = KRYO_REPO.get(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(1 * Constants.BYTE_1024 * Constants.BYTE_1024); + Output output = new Output(baos); + kryo.writeClassAndObject(output, data); + output.close(); + byte[] bytes = baos.toByteArray(); + return bytes.length == 1 ? null : bytes; + } + + @Override + public void close() { + // overridden method + } + + }, new Deserializer() { + @Override + public void configure(Map configs, boolean isKey) { + // overridden method + } + + @Override + public Object deserialize(String topic, byte[] data) { + if (data != null) { + Input input = new Input(new ByteArrayInputStream(data)); + Object o = KRYO_REPO.get().readClassAndObject(input); + input.close(); + return o; + } + return null; + } + + @Override + public void close() { + // overridden method + } + }), new Properties()); + } + + /** + * Close. + */ + @Override + public void close() { + KRYO_REPO.remove(); + super.close(); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/Operation.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/Operation.java new file mode 100644 index 0000000..9a6f5e6 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/Operation.java @@ -0,0 +1,58 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +/** + * enum {@link Operation}. + */ +public enum Operation { + + /** The put. */ + PUT, + + /** The put to map. */ + PUT_TO_MAP, + + /** The del. */ + DEL, + + /** The del from map. */ + DEL_FROM_MAP +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/SerializedKVIterator.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/SerializedKVIterator.java new file mode 100644 index 0000000..704e7d2 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/SerializedKVIterator.java @@ -0,0 +1,129 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import org.apache.kafka.common.utils.Bytes; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.apache.kafka.streams.state.StateSerdes; + +import java.util.NoSuchElementException; + +/** + * The Class SerializedKVIterator. + * + * @param the key type + * @param the value type + */ +class SerializedKVIterator implements KeyValueIterator { + + /** The bytes iterator. */ + private final KeyValueIterator bytesIterator; + + /** The serdes. */ + private final StateSerdes serdes; + + /** + * Instantiates a new serialized KV iterator. + * + * @param bytesIterator the bytes iterator + * @param serdes the serdes + */ + SerializedKVIterator(final KeyValueIterator bytesIterator, + final StateSerdes serdes) { + + this.bytesIterator = bytesIterator; + this.serdes = serdes; + } + + /** + * Close. + */ + @Override + public void close() { + bytesIterator.close(); + } + + /** + * Peek next key. + * + * @return the k + */ + @Override + public K peekNextKey() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + final Bytes bytes = bytesIterator.peekNextKey(); + return serdes.keyFrom(bytes.get()); + } + + /** + * Checks for next. + * + * @return true, if successful + */ + @Override + public boolean hasNext() { + return bytesIterator.hasNext(); + } + + /** + * Next. + * + * @return the key value + */ + @Override + public KeyValue next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + final KeyValue next = bytesIterator.next(); + return KeyValue.pair(serdes.keyFrom(next.key.get()), serdes.valueFrom(next.value)); + } + + /** + * Removes the. + */ + @Override + public void remove() { + throw new UnsupportedOperationException("remove not supported by SerializedKeyValueIterator"); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/SortedKeyValueStore.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/SortedKeyValueStore.java new file mode 100644 index 0000000..02226fd --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/stores/SortedKeyValueStore.java @@ -0,0 +1,80 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import org.apache.kafka.streams.state.KeyValueIterator; +import org.apache.kafka.streams.state.KeyValueStore; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; + +/** + * interface {@link SortedKeyValueStore} extends {@link KeyValueStore}. + * + * @param the key type + * @param the value type + */ +public interface SortedKeyValueStore extends KeyValueStore { + + /** The logger. */ + public static IgniteLogger LOGGER = IgniteLoggerFactory.getLogger(SortedKeyValueStore.class); + + /** + * Returns a view of the portion of statestore whose keys are less than (or equal to) toKey. + * + * @param toKey toKey + * @return KeyValueIterator + */ + public default KeyValueIterator getHead(K toKey) { + LOGGER.error("Implementation of Method getHeadMap unavailable"); + return null; + } + + /** + * Returns a view of the portion of statestore whose keys are greater than (or equal to) fromKey. + * + * @param fromKey fromKey + * @return KeyValueIterator + */ + public default KeyValueIterator getTail(K fromKey) { + LOGGER.error("Implementation of Method getTailMap unavailable"); + return null; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/CompressionJack.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/CompressionJack.java new file mode 100644 index 0000000..cbf2f5c --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/CompressionJack.java @@ -0,0 +1,356 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.exception.InputStreamMaxSizeExceededException; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.GZIPInputStream; +import java.util.zip.InflaterInputStream; +import java.util.zip.ZipInputStream; + +/** + * {@link CompressionJack}. + */ + +@Component +public class CompressionJack { + + /** The threshold size. */ + @Value("${" + PropertyNames.MAX_DECOMPRESS_INPUT_STREAM_SIZE_IN_BYTES + ":1000000000}") + private static long thresholdSize; + + /** + * sniff method. + * + * @param bytes bytes + * @return CompressionType + * @throws IOException {@link IOException} + */ + public CompressionType sniff(byte[] bytes) throws IOException { + try (DataInputStream dis = new DataInputStream(new ByteArrayInputStream(bytes))) { + short coupleBytes = dis.readShort(); + switch (coupleBytes) { + case Constants.SHORT_0_X_1_F_8_B: + return CompressionType.GZIP; + case Constants.SHORT_0_X_1_F_9_D: + return CompressionType.Z; + case Constants.SHORT_0_X_425_A: + return CompressionType.BZIP2; + case Constants.SHORT_0_X_7801: + case Constants.SHORT_0_X_789_C: + case Constants.SHORT_0_X_78_DA: + return CompressionType.ZLIB; + case Constants.SHORT_0_X_504_B: + return CompressionType.ZIP; + default: + return CompressionType.PLAIN; + } + } + } + + /** + * Decompress. + * + * @param bytes the bytes + * @return the byte[] + * @throws IOException Signals that an I/O exception has occurred. + */ + public byte[] decompress(byte[] bytes) throws IOException { + CompressionType codec = sniff(bytes); + return codec.decompress(bytes); + } + + /** + * Sets the threshold size. + * + * @param thresholdSizeParam the new threshold size + */ + // setter for unit test cases + public static void setThresholdSize(long thresholdSizeParam) { + thresholdSize = thresholdSizeParam; + } + + /** + * {@link CompressionType}. + */ + public enum CompressionType { + + /** The gzip. */ + GZIP(new GzipDecompressor()), + + /** The zlib. */ + ZLIB(new ZlibDecompressor()), + + /** The zip. */ + ZIP(new ZipDecompressor()), + + /** The bzip2. */ + BZIP2(new UnsupportedDecompressor()), + + /** The z. */ + Z(new UnsupportedDecompressor()), + + /** The plain. */ + PLAIN(new NoOpDecompressor()); + + /** The inflater. */ + private Decompressor inflater; + + /** + * Instantiates a new compression type. + * + * @param inflater the inflater + */ + private CompressionType(Decompressor inflater) { + this.inflater = inflater; + } + + /** + * Decompress. + * + * @param bytes the bytes + * @return the byte[] + * @throws IOException Signals that an I/O exception has occurred. + */ + public byte[] decompress(byte[] bytes) throws IOException { + return inflater.decompress(bytes); + } + } + + /** + * The Interface Decompressor. + */ + private interface Decompressor { + + /** + * Decompress. + * + * @param in the in + * @return the byte[] + * @throws IOException Signals that an I/O exception has occurred. + */ + public byte[] decompress(byte[] in) throws IOException; + } + + /** + * The Class BaseDecompressor. + */ + private abstract static class BaseDecompressor implements Decompressor { + + /** + * Decompress. + * + * @param in the in + * @return the byte[] + * @throws IOException Signals that an I/O exception has occurred. + */ + @Override + public byte[] decompress(byte[] in) throws IOException { + try (InputStream gin = createDecompressionInputStream(in)) { + ByteArrayOutputStream out = new ByteArrayOutputStream(Constants.INT_6144); + byte[] buffer = new byte[Constants.INT_6144]; + int nr = 0; + while ((nr = gin.read(buffer, 0, Constants.INT_6144)) != Constants.NEGATIVE_ONE) { + out.write(buffer, 0, nr); + } + return out.toByteArray(); + } + } + + /** + * Creates the decompression input stream. + * + * @param in the in + * @return the input stream + * @throws IOException Signals that an I/O exception has occurred. + */ + public abstract InputStream createDecompressionInputStream(byte[] in) throws IOException; + + } + + /** + * The Class GzipDecompressor. + */ + private static final class GzipDecompressor extends BaseDecompressor { + + /** + * Creates the decompression input stream. + * + * @param in the in + * @return the input stream + * @throws IOException Signals that an I/O exception has occurred. + */ + @Override + public InputStream createDecompressionInputStream(byte[] in) throws IOException { + return new GZIPInputStream(new BufferedInputStream(new ByteArrayInputStream(in))); + } + } + + /** + * The Class ZlibDecompressor. + */ + private static final class ZlibDecompressor extends BaseDecompressor { + + /** + * Creates the decompression input stream. + * + * @param in the in + * @return the input stream + * @throws IOException Signals that an I/O exception has occurred. + */ + @Override + public InputStream createDecompressionInputStream(byte[] in) throws IOException { + return new InflaterInputStream(new BufferedInputStream(new ByteArrayInputStream(in))); + } + + } + + /** + * The Class ZipDecompressor. + */ + private static final class ZipDecompressor extends BaseDecompressor { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(ZipDecompressor.class); + + /** The Constant THRESHOLD_SIZE. */ + private static final long THRESHOLD_SIZE = CompressionJack.thresholdSize; + + /** The total size archive. */ + int totalSizeArchive = 0; + + /** + * Creates the decompression input stream. + * + * @param in the in + * @return the input stream + * @throws IOException Signals that an I/O exception has occurred. + */ + @Override + public InputStream createDecompressionInputStream(byte[] in) throws IOException { + ByteArrayInputStream inputStream = new ByteArrayInputStream(in); + if (isValidSizeOfStream(inputStream)) { + ZipInputStream zis = new ZipInputStream(new BufferedInputStream(new ByteArrayInputStream(in))); + zis.getNextEntry(); + return zis; + } else { + throw new InputStreamMaxSizeExceededException("Threshold for size of input stream is : " + + THRESHOLD_SIZE + "The current size of input stream is : " + totalSizeArchive + + " and has exceeded the security threshold"); + } + } + + /** + * Checks if is valid size of stream. + * + * @param byteInputStream the byte input stream + * @return true, if is valid size of stream + * @throws IOException Signals that an I/O exception has occurred. + */ + private boolean isValidSizeOfStream(ByteArrayInputStream byteInputStream) throws IOException { + logger.debug("Validating size of input stream for ZipDecompressor against threshold size : {}", + THRESHOLD_SIZE); + if (THRESHOLD_SIZE < 1) { + return true; + } + int numBytes = Constants.NEGATIVE_ONE; + byte[] buffer = new byte[Constants.BYTE_1024]; + int currTotalSizeArchive = 0; + while ((numBytes = byteInputStream.read(buffer)) > 0) { + currTotalSizeArchive += numBytes; + if (currTotalSizeArchive > THRESHOLD_SIZE) { + logger.error("Current size : {} has exceeded the security threshold for size " + + "during ZipDecompressor", currTotalSizeArchive); + totalSizeArchive = currTotalSizeArchive; + return false; + } + } + logger.debug("Size of input stream for ZipDecompressor is : {}", currTotalSizeArchive); + return true; + } + } + + /** + * The Class NoOpDecompressor. + */ + private static final class NoOpDecompressor implements Decompressor { + + /** + * Decompress. + * + * @param in the in + * @return the byte[] + * @throws IOException Signals that an I/O exception has occurred. + */ + @Override + public byte[] decompress(byte[] in) throws IOException { + return in; + } + } + + /** + * The Class UnsupportedDecompressor. + */ + private static final class UnsupportedDecompressor implements Decompressor { + + /** + * Decompress. + * + * @param in the in + * @return the byte[] + * @throws IOException Signals that an I/O exception has occurred. + */ + @Override + public byte[] decompress(byte[] in) throws IOException { + throw new UnsupportedOperationException("Unsupported compression format"); + } + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ConnectionStatusRetriever.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ConnectionStatusRetriever.java new file mode 100644 index 0000000..27e0392 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ConnectionStatusRetriever.java @@ -0,0 +1,24 @@ +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdStatus; + +/** + * CR-4570 DMA should expose an interface for services to retrieve connection + * status from an API. + * Services can implement this interface and plug-in its implementation configuration to + * provide their own logic to call the API & fetch device connection status. + * + * @author HBadshah + */ +public interface ConnectionStatusRetriever { + + /** An interface to fetch the connection status of devices from an API. + * + * @param requestId The ID of the request. + * @param vehicleId The ID of the vehicle. + * @param deviceId the ID of the device. + * + * @return {@link VehicleIdDeviceIdStatus} + */ + public VehicleIdDeviceIdStatus getConnectionStatusData(String requestId, String vehicleId, String deviceId); +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Constants.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Constants.java new file mode 100644 index 0000000..1beb1e3 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Constants.java @@ -0,0 +1,375 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +/** + * Constants file. + */ +public class Constants { + + /** + * Instantiates a new constants. + */ + private Constants() { + + } + + /** The Constant ONE. */ + public static final int ONE = 1; + + /** The Constant NEGATIVE_ONE. */ + public static final int NEGATIVE_ONE = -1; + + /** The Constant COMMA. */ + public static final String COMMA = ","; + + /** The Constant FORWARD_SLASH. */ + public static final String FORWARD_SLASH = "/"; + + /** The Constant HYPHEN. */ + public static final String HYPHEN = "-"; + + /** The Constant UNDERSCORE. */ + public static final char UNDERSCORE = '_'; + + /** The Constant DOT. */ + public static final char DOT = '.'; + + /** The Constant CONSUMER_GROUP. */ + public static final String CONSUMER_GROUP = "consumer-group"; + + /** The Constant DLQ_NULL_KEY. */ + public static final String DLQ_NULL_KEY = "DLQ-NULL-KEY-"; + + /** The Constant TO_DEVICE. */ + public static final String TO_DEVICE = FORWARD_SLASH + "2d"; + + /** The Constant KAFKA_TOPICS_HEALTH_MONITOR. */ + public static final String KAFKA_TOPICS_HEALTH_MONITOR = "KAFKA_TOPICS_HEALTH_MONITOR"; + + /** The Constant KAFKA_TOPICS_METRIC_NAME. */ + public static final String KAFKA_TOPICS_METRIC_NAME = "KAFKA_TOPICS_HEALTH_GAUGE"; + + /** The Constant DELIMITER. */ + public static final String DELIMITER = "\\|"; + + /** The Constant FORCED_HEALTH_CHECK_DEVICE_ID. */ + public static final String FORCED_HEALTH_CHECK_DEVICE_ID = "testDevice123"; + + /** The Constant FORCED_HEALTH_DEFAULT_TEST_TOPIC_NAME. */ + public static final String FORCED_HEALTH_DEFAULT_TEST_TOPIC_NAME = "test"; + + /** The Constant ECU_TYPE_BROKER_TOPIC_DELIMETER. */ + public static final String ECU_TYPE_BROKER_TOPIC_DELIMETER = "#"; + + /** The Constant ROCKSDB_PREFIX. */ + public static final String ROCKSDB_PREFIX = "rocksdb."; + + /** The Constant CACHE_BYPASS_REDIS_RETRY_THREAD. */ + public static final String CACHE_BYPASS_REDIS_RETRY_THREAD = "CacheBypassRedisRetryThread"; + + /** The Constant PLATFORM_ID. */ + public static final String PLATFORM_ID = "platformId"; + + /** The Constant PLAIN. */ + // 3rd party DFF Kafka endpoint constants + public static final String PLAIN = "PLAIN"; + + /** The Constant TRUE. */ + public static final String TRUE = "true"; + + /** The Constant FALSE. */ + public static final String FALSE = "false"; + + /** The Constant PAHO. */ + public static final String PAHO = "paho"; + + /** The Constant FIFTY. */ + public static final int FIFTY = 50; + + /** The Constant THREAD_SLEEP_TIME_5000. */ + public static final int THREAD_SLEEP_TIME_5000 = 5000; + + /** The Constant THREAD_SLEEP_TIME_15000. */ + public static final int THREAD_SLEEP_TIME_15000 = 15000; + + /** The Constant THREAD_SLEEP_TIME_500. */ + public static final int THREAD_SLEEP_TIME_500 = 500; + + /** The Constant THREAD_SLEEP_TIME_4000. */ + public static final int THREAD_SLEEP_TIME_4000 = 4000; + + /** The Constant THREAD_SLEEP_TIME_400. */ + public static final int THREAD_SLEEP_TIME_400 = 400; + + /** The Constant THREAD_SLEEP_TIME_5900. */ + public static final int THREAD_SLEEP_TIME_5900 = 5900; + + /** The Constant THREAD_SLEEP_TIME_30000. */ + public static final int THREAD_SLEEP_TIME_30000 = 30000; + + /** The Constant THREAD_SLEEP_TIME_9000. */ + public static final int THREAD_SLEEP_TIME_9000 = 9000; + + /** The Constant THREAD_SLEEP_TIME_120000. */ + public static final int THREAD_SLEEP_TIME_120000 = 120000; + + /** The Constant THREAD_SLEEP_TIME_3000. */ + public static final int THREAD_SLEEP_TIME_3000 = 3000; + + /** The Constant THREAD_SLEEP_TIME_300. */ + public static final int THREAD_SLEEP_TIME_300 = 300; + + /** The Constant INT_30. */ + public static final int INT_30 = 30; + + /** The Constant THREAD_SLEEP_TIME_2500. */ + public static final int THREAD_SLEEP_TIME_2500 = 2500; + + /** The Constant THREAD_SLEEP_TIME_250. */ + public static final int THREAD_SLEEP_TIME_250 = 250; + + /** The Constant THREAD_SLEEP_TIME_2000. */ + public static final int THREAD_SLEEP_TIME_2000 = 2000; + + /** The Constant THREAD_SLEEP_TIME_1500. */ + public static final int THREAD_SLEEP_TIME_1500 = 1500; + + /** The Constant THREAD_SLEEP_TIME_1000. */ + public static final int THREAD_SLEEP_TIME_1000 = 1000; + + /** The Constant INT_1883. */ + public static final int INT_1883 = 1883; + + /** The Constant INT_18. */ + public static final int INT_18 = 18; + + /** The Constant INT_1000000. */ + public static final int INT_1000000 = 1000000; + + /** The Constant THREAD_SLEEP_TIME_1000000. */ + public static final long THREAD_SLEEP_TIME_1000000 = 1000000; + + /** The Constant THREAD_SLEEP_TIME_200. */ + public static final int THREAD_SLEEP_TIME_200 = 200; + + /** The Constant THREAD_SLEEP_TIME_100. */ + public static final int THREAD_SLEEP_TIME_100 = 100; + + /** The Constant THREAD_SLEEP_TIME_10000. */ + public static final int THREAD_SLEEP_TIME_10000 = 10000; + + /** The Constant INT_16. */ + public static final int INT_16 = 16; + + /** The Constant TWENTY_THOUSAND. */ + public static final long TWENTY_THOUSAND = 20000L; + + /** The Constant THIRTY_THOUSAND. */ + public static final long THIRTY_THOUSAND = 30000L; + + /** The Constant SIXTEEN. */ + public static final long SIXTEEN = 16; + + /** The Constant SIXTY. */ + public static final long SIXTY = 60; + + /** The Constant INT_45. */ + public static final long INT_45 = 45; + + /** The Constant THREAD_SLEEP_TIME_10. */ + public static final int THREAD_SLEEP_TIME_10 = 10; + + /** The Constant TWENTY. */ + public static final int TWENTY = 20; + + /** The Constant TEN. */ + public static final int TEN = 10; + + /** The Constant SEVEN. */ + public static final short SEVEN = 7; + + /** The Constant SIX. */ + public static final int SIX = 6; + + /** The Constant FIVE. */ + public static final int FIVE = 5; + + /** The Constant FOUR. */ + public static final int FOUR = 4; + + /** The Constant THREE. */ + public static final int THREE = 3; + + /** The Constant TWO. */ + public static final int TWO = 2; + + /** The Constant THREAD_SLEEP_TIME_6000. */ + public static final int THREAD_SLEEP_TIME_6000 = 6000; + + /** The Constant THREAD_SLEEP_TIME_60000. */ + public static final int THREAD_SLEEP_TIME_60000 = 60000; + + /** The Constant LONG_60000. */ + public static final long LONG_60000 = 60000L; + + /** The Constant HOST. */ + public static final int HOST = 1234; + + /** The Constant OFFSET_VALUE. */ + public static final short OFFSET_VALUE = 32; + + /** The Constant BYTE_1024. */ + public static final int BYTE_1024 = 1024; + + /** The Constant INT_4736565. */ + public static final long INT_4736565 = 4736565; + + /** The Constant INT_4792987. */ + public static final long INT_4792987 = 4792987; + + /** The Constant LONG_MINUS_ONE. */ + public static final long LONG_MINUS_ONE = -1; + + /** The Constant INT_MINUS_ONE. */ + public static final int INT_MINUS_ONE = -1; + + /** The Constant INT_MINUS_TWO. */ + public static final long INT_MINUS_TWO = -2; + + /** The Constant INT_202. */ + public static final int INT_202 = 202; + + /** The Constant INT_45000. */ + public static final int INT_45000 = 45000; + + /** The Constant INT_80000. */ + public static final int INT_80000 = 80000; + + /** The Constant INT_120000. */ + public static final int INT_120000 = 120000; + + /** The Constant INT_20000. */ + public static final int INT_20000 = 20000; + + /** The Constant INT_6144. */ + public static final int INT_6144 = 6144; + + /** The Constant INT_26379. */ + public static final int INT_26379 = 26379; + + /** The Constant INT_6379. */ + public static final int INT_6379 = 6379; + + /** The Constant FLOAT_DECIMAL75. */ + public static final float FLOAT_DECIMAL75 = 0.75f; + + /** The Constant LONG_1443717903851. */ + public static final long LONG_1443717903851 = 1443717903851L; + + /** The Constant LONG_1475151880125. */ + public static final long LONG_1475151880125 = 1475151880125L; + + /** The Constant LONG_111. */ + public static final long LONG_111 = 111L; + + /** The Constant INT_9092. */ + public static final int INT_9092 = 9092; + + /** The Constant SHORT_0_X_1_F_8_B. */ + public static final short SHORT_0_X_1_F_8_B = 0x1f8b; + + /** The Constant SHORT_0_X_1_F_9_D. */ + public static final short SHORT_0_X_1_F_9_D = 0x1f9d; + + /** The Constant SHORT_0_X_425_A. */ + public static final short SHORT_0_X_425_A = 0x425a; + + /** The Constant SHORT_0_X_7801. */ + public static final short SHORT_0_X_7801 = 0x7801; + + /** The Constant SHORT_0_X_789_C. */ + public static final short SHORT_0_X_789_C = 0x789c; + + /** The Constant SHORT_0_X_78_DA. */ + public static final short SHORT_0_X_78_DA = 0x78da; + + /** The Constant SHORT_0_X_504_B. */ + public static final short SHORT_0_X_504_B = 0x504b; + + /** The Constant LONG_1603946935. */ + public static final long LONG_1603946935 = 1603946935L; + + /** The Constant THOUSAND. */ + public static final int THOUSAND = 1000; + + /** The Constant TWO_THOUSAND. */ + public static final int TWO_THOUSAND = 2000; + + /** The Constant HIVEMQ. */ + public static final String HIVEMQ = "hivemq"; + + /** The Constant CANNOT_BE_NULL_ERROR_MSG. */ + public static final String CANNOT_BE_NULL_ERROR_MSG = " shouldn't be null or empty."; + + /** The Constant MQTT_CLIENT_AS_ONE_WAY_TLS. */ + public static final String MQTT_CLIENT_AS_ONE_WAY_TLS = " For mqtt client auth mechanism as one way tls"; + + /** The Constant CANNOT_BE_EMPTY. */ + public static final String CANNOT_BE_EMPTY = " cannot be empty"; + + /** The Constant FOR_PARTITION. */ + public static final String FOR_PARTITION = "For partition "; + + /** The Constant VALUE_END. */ + public static final String VALUE_END = "}"; + + /** The Constant VALUE_START. */ + public static final String VALUE_START = "${"; + + /** The Constant COLON_9100. */ + public static final String COLON_9100 = ":9100"; + + /** The Constant RECEIVED_NULL_KEY. */ + public static final String RECEIVED_NULL_KEY = "Received null key."; + + /** The Constant DEFAULT_PLATFORMID. */ + public static final String DEFAULT_PLATFORMID = "default"; +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQHandler.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQHandler.java new file mode 100644 index 0000000..d1c37cc --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQHandler.java @@ -0,0 +1,401 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; +import jakarta.annotation.PostConstruct; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamBaseConstant; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.IgniteBaseException; +import org.eclipse.ecsp.domain.IgniteExceptionDataV1_0; +import org.eclipse.ecsp.domain.IgniteExceptionDataV1_1; +import org.eclipse.ecsp.domain.NestedDLQExceptionData; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.CompositeIgniteEvent; +import org.eclipse.ecsp.entities.EventData; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.transform.GenericIgniteEventTransformer; +import org.eclipse.ecsp.transform.IgniteKeyTransformer; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.lang.reflect.InvocationTargetException; + +/** + * This class checks whether failed event needs to be re-processed + * else forwards it to the DLQ (Dead lettered queue) + * topic.IgniteBaseException exception class is contract between + * stream processor and service based processors. In order to proceed with DLQ + * re-processing , this exception should be thrown with retryable + * flag set to true else exception will be sent directly to the DLQ topic + * without re-processing. At the time DLQ re-processing , if the + * exception is not required to be processed further due to whatsoever reason + * (business logic etc) ,its mandatory to throw the exception either + * with IgniteBaseException with retryable set to false or any other + * exception in order to ensure that this event failure is ultimately + * stored into DLQ topic for future analysis. + */ +@Component +public class DLQHandler { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DLQHandler.class); + + /** The key transformer. */ + private IgniteKeyTransformer keyTransformer; + + /** The mapper. */ + private ObjectMapper mapper = new ObjectMapper(); + + /** The topic dlq. */ + private String topicDlq; + + /** The key transformer impl. */ + @Value("${ignite.key.transformer.class}") + private String keyTransformerImpl; + + /** The service name. */ + @Value("${service.name}") + private String serviceName; + + /** The convert topic to lower case. */ + @Value("${" + PropertyNames.CONVERT_BACKDOOR_KAFKA_TOPIC_TO_LOWERCASE + ":true}") + private boolean convertTopicToLowerCase; + + /** The max retry count. */ + @Value("${" + PropertyNames.DLQ_MAX_RETRY_COUNT + ":5}") + private int maxRetryCount; + + /** The reprocessing enabled. */ + @Value("${" + PropertyNames.DLQ_REPROCESSING_ENABLED + ":false}") + private boolean reprocessingEnabled; + + /** The transformer. */ + @Autowired + private GenericIgniteEventTransformer transformer; + + /** + * Gets the max retry count. + * + * @return the max retry count + */ + public int getMaxRetryCount() { + return maxRetryCount; + } + + /** + * Sets the max retry count. + * + * @param maxRetryCount the new max retry count + */ + public void setMaxRetryCount(int maxRetryCount) { + this.maxRetryCount = maxRetryCount; + } + + /** + * Checks if is reprocessing enabled. + * + * @return true, if is reprocessing enabled + */ + public boolean isReprocessingEnabled() { + return reprocessingEnabled; + } + + /** + * Sets the reprocessing enabled. + * + * @param reprocessingEnabled the new reprocessing enabled + */ + public void setReprocessingEnabled(boolean reprocessingEnabled) { + this.reprocessingEnabled = reprocessingEnabled; + } + + /** + * init(). + */ + @PostConstruct + public void init() { + try { + keyTransformer = (IgniteKeyTransformer) getClass().getClassLoader().loadClass(keyTransformerImpl) + .getDeclaredConstructor().newInstance(); + } catch (InstantiationException | IllegalAccessException | ClassNotFoundException + | InvocationTargetException | NoSuchMethodException e) { + throw new IllegalArgumentException(PropertyNames.IGNITE_KEY_TRANSFORMER + + " refers to a class that is not available on the classpath", e); + } + this.mapper.setFilterProvider(new SimpleFilterProvider().setFailOnUnknownId(false)); + this.topicDlq = serviceName + StreamBaseConstant.DLQ_TOPIC_POSFIX; + if (convertTopicToLowerCase) { + this.topicDlq = this.topicDlq.toLowerCase(); + } + } + + /** + * It forwards the failed event to DLQ topic. + * + * @param the key type + * @param the value type + * @param context the context + * @param key key + * @param value value + * @param ex ex + */ + public void forwardToDlq(StreamProcessingContext context, K key, V value, Exception ex) { + try { + @SuppressWarnings("rawtypes") + IgniteKey igniteKey = null; + IgniteExceptionDataV1_0 exceptiondata = new IgniteExceptionDataV1_0(); + if (key == null) { + igniteKey = keyTransformer.fromBlob((Constants.DLQ_NULL_KEY + System.currentTimeMillis()).getBytes()); + } + if (key instanceof byte[] bytes) { + igniteKey = keyTransformer.fromBlob(bytes); + exceptiondata.setRawData(value); + } else if (key instanceof IgniteKey igniteKey1) { + igniteKey = igniteKey1; + if (value instanceof IgniteEventImpl igniteEventImpl) { + exceptiondata.setIgniteEvent(igniteEventImpl); + } else if (value instanceof CompositeIgniteEvent compositeIgniteEvent) { + exceptiondata.setCompositeIgniteEvent(compositeIgniteEvent); + } + } + IgniteEventImpl eventImpl = new IgniteEventImpl(); + eventImpl.setEventId(EventID.IGNITE_EXCEPTION_EVENT); + exceptiondata.setErrorTimeInMilis(System.currentTimeMillis()); + exceptiondata.setException(ex); + eventImpl.setEventData(exceptiondata); + eventImpl.setVersion(Version.V1_0); + logger.debug("Forwarding to DLQ {} key {} value {}", topicDlq, igniteKey, eventImpl); + context.forwardDirectly(igniteKey, eventImpl, topicDlq); + } catch (Exception exception) { + logger.error("Unexpected error for key {} value {}", key, value, exception); + } + } + + /** + * This method decides whether the event exception can sent for + * DLQ re-processing or should be forwarded to DLQ topic directly. + * + * @param the key type + * @param the value type + * @param context context + * @param key key + * @param value value + * @param ex ex + * @param processorName processorName + */ + public void forwardToDlq(StreamProcessingContext context, K key, V value, + Exception ex, String processorName) { + logger.error( + "Failed to process key {} with value {} by worker:{} " + + "with exception. Checking DLQ reprocessing criteria before forwarding to DLQ !", + key, value, processorName, ex); + if (checkIfDLQReprocessingRequired(key, value, ex)) { + IgniteEvent igniteEvent = (IgniteEvent) value; + logger.debug(igniteEvent, "Initiating the DLQ re-processing for key {}", key); + performDlqReprocessing(context, (IgniteKey) key, igniteEvent, (IgniteBaseException) ex, processorName); + } else { + logger.info("Forwarding to DLQ topic for key {}", key); + forwardToDlq(context, key, value, ex); + } + } + + /** + * Below conditions are checked: 1> dlq.reprocessing.enabled + * instance of byte[] i.e. previously event didn't failed at the + * TaskContextInitializer or ProtocolTranslatorPreProcessor. 4> + * Exception should be retractable IgniteBaseException. + * + * @param the key type + * @param the value type + * @param key key + * @param value value + * @param ex The exception + * @return boolean Returns true in case DLQ Reprocessing needs to be performed. + */ + public boolean checkIfDLQReprocessingRequired(K key, V value, Exception ex) { + + if (reprocessingEnabled && key != null && value != null + && !(key instanceof byte[]) && ex instanceof IgniteBaseException + && ((IgniteBaseException) ex).isRetryable()) { + int retryCount = 0; + IgniteEvent igniteEvent = (IgniteEvent) value; + if (igniteEvent.getEventData() instanceof IgniteExceptionDataV1_1 igniteExceptionDataV1) { + retryCount = igniteExceptionDataV1.getRetryCount(); + } + if (retryCount < maxRetryCount) { + logger.info("DLQ Reprocessing criteria are satisfied. Forwarding the key: " + + "{} retryCount {} maxRetryCount {} for DLQ reprocessing", + key, retryCount, maxRetryCount); + return true; + } + } + return false; + } + + /** + * This method performs DLQ re-processing and forwards the event along + * with the Exception data so that informed decision can be made + * during re-processing by service. + * + * @param context context + * @param key key + * @param value value + * @param ex ex + * @param processorName processorName + */ + public void performDlqReprocessing(StreamProcessingContext context, IgniteKey key, + IgniteEvent value, IgniteBaseException ex, String processorName) { + IgniteEventImpl clonedIgniteEventImpl = null; + IgniteExceptionDataV1_1 igniteWrapperExceptionData = new IgniteExceptionDataV1_1(); + IgniteExceptionDataV1_1 igniteExceptionData = null; + EventData data = value.getEventData(); + byte[] igniteEventBlob; + + if (data instanceof IgniteExceptionDataV1_1 exceptionData) { + /* + * Exception is retried atleast once. + */ + if (value instanceof IgniteEventImpl igniteEvent) { + clonedIgniteEventImpl = igniteEvent; + igniteExceptionData = (IgniteExceptionDataV1_1) clonedIgniteEventImpl.getEventData(); + igniteEventBlob = getBlobForIgniteEventImpl(ex, igniteWrapperExceptionData, igniteExceptionData); + } else { + igniteExceptionData = new IgniteExceptionDataV1_1(); + if (ex.getIgniteEvent() != null) { + igniteWrapperExceptionData.setCompositeIgniteEvent((CompositeIgniteEvent) ex.getIgniteEvent()); + igniteExceptionData = (IgniteExceptionDataV1_1) ex.getIgniteEvent().getEventData(); + } else { + if (igniteExceptionData.getCompositeIgniteEvent() != null) { + igniteWrapperExceptionData + .setCompositeIgniteEvent(igniteExceptionData.getCompositeIgniteEvent()); + } + } + igniteEventBlob = transformer.toBlob(igniteWrapperExceptionData.getCompositeIgniteEvent()); + } + /* + * Overwriting the nestedEvent, we are maintaining the previous + * event exception info. + */ + NestedDLQExceptionData nestedDlqExceptionData = new NestedDLQExceptionData(igniteEventBlob, + igniteExceptionData.getRetryCount(), igniteExceptionData.getProcessorName(), + igniteExceptionData.getException(), igniteExceptionData.getContext()); + + igniteWrapperExceptionData.setNestedDLQExceptionData(nestedDlqExceptionData); + igniteWrapperExceptionData.setRetryCount(exceptionData.getRetryCount() + 1); + + } else { + /* + * Exception is not re-processed yet. + */ + setIgniteWrapperExceptionData(value, igniteWrapperExceptionData); + } + + igniteWrapperExceptionData.setErrorTimeInMilis(System.currentTimeMillis()); + igniteWrapperExceptionData.setException((Exception) ex.getCause()); + igniteWrapperExceptionData.setProcessorName(processorName); + igniteWrapperExceptionData.setContext((ex).getServiceContext()); + IgniteEventImpl igniteExceptionEvent = new IgniteEventImpl(); + igniteExceptionEvent.setVersion(Version.V1_1); + igniteExceptionEvent.setEventId(EventID.IGNITE_EXCEPTION_EVENT); + igniteExceptionEvent.setEventData(igniteWrapperExceptionData); + IgniteEvent igniteEvent = igniteWrapperExceptionData.getIgniteEvent(); + igniteExceptionEvent.setVehicleId(igniteEvent.getVehicleId()); + igniteExceptionEvent.setRequestId(igniteEvent.getRequestId()); + igniteExceptionEvent.setBizTransactionId(igniteEvent.getBizTransactionId()); + igniteExceptionEvent.setMessageId(igniteEvent.getMessageId()); + igniteExceptionEvent.setCorrelationId(igniteEvent.getCorrelationId()); + igniteExceptionEvent.setTimezone(igniteEvent.getTimezone()); + igniteExceptionEvent.setTimestamp(igniteEvent.getTimestamp()); + logger.debug("Forwarding for DLQ reprocessing {} key {} value {}" + + " retryCount {} maxRetryCount {}", context.streamName(), + key, igniteExceptionEvent, + igniteWrapperExceptionData.getRetryCount(), maxRetryCount); + context.forwardDirectly(key, igniteExceptionEvent, context.streamName()); + + } + + /** + * Sets the ignite wrapper exception data. + * + * @param value the value + * @param igniteWrapperExceptionData the ignite wrapper exception data + */ + private static void setIgniteWrapperExceptionData(IgniteEvent value, + IgniteExceptionDataV1_1 igniteWrapperExceptionData) { + CompositeIgniteEvent clonedCompositeIgniteEvent; + if (value instanceof IgniteEventImpl clonedIgniteEventImpl) { + igniteWrapperExceptionData.setIgniteEvent(clonedIgniteEventImpl); + } else { + clonedCompositeIgniteEvent = (CompositeIgniteEvent) value; + igniteWrapperExceptionData.setCompositeIgniteEvent(clonedCompositeIgniteEvent); + } + igniteWrapperExceptionData.setRetryCount(1); + } + + /** + * Gets the blob for ignite event impl. + * + * @param ex the ex + * @param igniteWrapperExceptionData the ignite wrapper exception data + * @param igniteExceptionData the ignite exception data + * @return the blob for ignite event impl + */ + private byte[] getBlobForIgniteEventImpl(IgniteBaseException ex, IgniteExceptionDataV1_1 igniteWrapperExceptionData, + IgniteExceptionDataV1_1 igniteExceptionData) { + byte[] igniteEventBlob; + if (ex.getIgniteEvent() != null) { + igniteWrapperExceptionData.setIgniteEvent((IgniteEventImpl) ex.getIgniteEvent()); + } else { + igniteWrapperExceptionData.setIgniteEvent(igniteExceptionData.getIgniteEvent()); + } + igniteEventBlob = transformer.toBlob(igniteWrapperExceptionData.getIgniteEvent()); + return igniteEventBlob; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DMATLSFactory.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DMATLSFactory.java new file mode 100644 index 0000000..3bf71c1 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DMATLSFactory.java @@ -0,0 +1,118 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.analytics.stream.base.exception.DeviceMessagingMqttClientTrustStoreException; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import javax.net.ssl.TrustManagerFactory; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.concurrent.ConcurrentHashMap; + +/** + * This class is responsible for instantiating KeyStore(s). A KeyStore instance + * can contain either information about just the key-store or information about + * both, the key-store as well as the trust-store, depending upon whether the + * authentication mechanism is one-way-tls or two-way-tls. + * + * @author NeKhan + */ + +public class DMATLSFactory { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DMATLSFactory.class); + + /** The platform trust manager map. */ + private static ConcurrentHashMap platformTrustManagerMap = new ConcurrentHashMap<>(); + + /** + * Gets the trust manager factory. + * + * @param platformId the platform id + * @param mqttConfig the mqtt config + * @return the trust manager factory + */ + public static TrustManagerFactory getTrustManagerFactory(String platformId, MqttConfig mqttConfig) { + platformTrustManagerMap.putIfAbsent(platformId, createTrustManagerFactory(mqttConfig)); + return platformTrustManagerMap.get(platformId); + } + + /** + * Instantiates a new DMATLS factory. + */ + private DMATLSFactory() { + //Default private constructor to avoid instantiating it using new operator. + } + + /** + * Initializes {@link TrustManagerFactory}. + * + * @param mqttConfig {@link MqttConfig} + * @return TrustManagerFactory instance. + */ + public static TrustManagerFactory createTrustManagerFactory(MqttConfig mqttConfig) { + try { + KeyStore trustStore = KeyStore.getInstance(mqttConfig.getMqttServiceTrustStoreType()); + InputStream inputStream = new FileInputStream(mqttConfig.getMqttServiceTrustStorePath()); + trustStore.load(inputStream, mqttConfig.getMqttServiceTrustStorePassword().toCharArray()); + logger.info("TrustStore: {} loaded successfully.", mqttConfig.getMqttServiceTrustStorePath()); + + TrustManagerFactory trustManagerFactory = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(trustStore); + logger.info("TrustManagerFactory successfully instantiated using algorithm: {}", + TrustManagerFactory.getDefaultAlgorithm()); + return trustManagerFactory; + } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException | IOException exception) { + throw new DeviceMessagingMqttClientTrustStoreException( + "Error encountered either while loading the TrustStore: " + mqttConfig.getMqttServiceTrustStorePath() + + " with truststore type: " + mqttConfig.getMqttServiceTrustStoreType() + " or in " + + "initializing the TrustManagerFactory. Exception is: " + exception, + exception.getCause()); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DefaultDeviceConnectionStatusRetriever.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DefaultDeviceConnectionStatusRetriever.java new file mode 100644 index 0000000..49f6522 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DefaultDeviceConnectionStatusRetriever.java @@ -0,0 +1,234 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.eclipse.ecsp.analytics.stream.base.http.HttpClient; +import org.eclipse.ecsp.analytics.stream.base.parser.DeviceConnectionStatusParser; +import org.eclipse.ecsp.domain.DeviceConnStatusV1_0.ConnectionStatus; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdStatus; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusAPIInMemoryService; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.DMA_CONNECTION_STATUS_API_MAX_RETRY_COUNT; +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.DMA_CONNECTION_STATUS_API_RETRY_INTERVAL_MS; +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.DMA_CONNECTION_STATUS_PARSER_IMPL; +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.DMA_CONNECTION_STATUS_RETRIEVER_API_URL; + +/** + * RDNG: 170506, RTC: 433347 DMA should have the capability of + * retrieving the connection status of VIN/Device through a third party + * API. This class will hit one API which will return the connection status + * of a device as response to DMA, if and only if configured so. + * + * @author hbadshah + */ +@Service +@Scope("prototype") +public class DefaultDeviceConnectionStatusRetriever implements ConnectionStatusRetriever { + + /** The http client. */ + @Autowired + private HttpClient httpClient; + + /** The ctx. */ + @Autowired + private ApplicationContext ctx; + + /** The device service in memory. */ + @Autowired + private DeviceStatusAPIInMemoryService deviceServiceInMemory; + + /** The api url. */ + @Value("${" + DMA_CONNECTION_STATUS_RETRIEVER_API_URL + ":}") + private String apiUrl; + + /** The api max retry count. */ + @Value("${" + DMA_CONNECTION_STATUS_API_MAX_RETRY_COUNT + ":3}") + private int apiMaxRetryCount; + + /** The api retry interval ms. */ + @Value("${" + DMA_CONNECTION_STATUS_API_RETRY_INTERVAL_MS + ":5000}") + private long apiRetryIntervalMs; + + /** The conn status parser impl. */ + @Value("${" + DMA_CONNECTION_STATUS_PARSER_IMPL + ":}") + private String connStatusParserImpl; + + /** The parser. */ + private DeviceConnectionStatusParser parser; + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DefaultDeviceConnectionStatusRetriever.class); + + /** + * getConnectionStatusData(). + * + * @param requestId requestId + * @param vehicleId vehicleId + * @param deviceId deviceId + * @return VehicleIdDeviceIdStatus + */ + public VehicleIdDeviceIdStatus getConnectionStatusData(String requestId, String vehicleId, String deviceId) { + if (StringUtils.isEmpty(apiUrl)) { + throw new IllegalArgumentException("No API URL is configured. Will not be " + + "able to request connection status."); + } + + long startTime = System.currentTimeMillis(); + + String url = appendToUrl(vehicleId); + logger.info("Invoking the connection status API with URL: {} for vehicleId: {}", apiUrl, vehicleId); + // Invoke the API, with no headers and params for now. + Map responseData = httpClient.invokeJsonResource(HttpClient.HttpReqMethod.GET, url, null, + null, apiMaxRetryCount, apiRetryIntervalMs); + long timeTaken = (System.currentTimeMillis() - startTime) / Constants.THOUSAND; + logger.debug("Time taken to fetch the connection status for vehicleId: {} and deviceId: {} is: {} second(s)", + vehicleId, deviceId, timeTaken); + logger.debug("Received connection status data: {} from the API {} for vehicleId: {}, " + + "deviceId: {} and requestId: {}", responseData, apiUrl, vehicleId, deviceId, requestId); + String connectionStatus = parser.getConnectionStatus(responseData); + logger.info("Connection status from the API for vehicleId {} and deviceId {} is {}", + vehicleId, deviceId, connectionStatus); + return getStatusData(vehicleId, deviceId, connectionStatus); + } + + /** + * Append to url. + * + * @param vehicleId the vehicle id + * @return the string + */ + private String appendToUrl(String vehicleId) { + /* + * If '/' is already a part of apiUrl configured then just append the + * vehicleId, else append '/' to the apiUrl. + */ + String url = apiUrl; + if (this.apiUrl.endsWith(String.valueOf(Constants.FORWARD_SLASH))) { + url += vehicleId; + } else { + url += Constants.FORWARD_SLASH + vehicleId; + } + logger.debug("Connection status API URL formed is: {}", url); + return url; + } + + /** + * Gets the status data. + * + * @param vehicleId the vehicle id + * @param deviceId the device id + * @param connectionStatus the connection status + * @return the status data + */ + private VehicleIdDeviceIdStatus getStatusData(String vehicleId, String deviceId, String connectionStatus) { + if (StringUtils.isEmpty(connectionStatus)) { + return null; + } + VehicleIdDeviceIdStatus mapping = deviceServiceInMemory.get(vehicleId); + if (mapping == null) { + ConcurrentHashMap statusMappings = new ConcurrentHashMap<>(); + statusMappings.put(deviceId, ConnectionStatus.valueOf(connectionStatus)); + mapping = new VehicleIdDeviceIdStatus(Version.V1_0, statusMappings); + } + return mapping; + } + + /** + * Setup. + */ + @PostConstruct + private void setup() { + if (StringUtils.isNotEmpty(apiUrl)) { + validate(); + loadConnectionStatusParser(); + } + } + + /** + * Load connection status parser. + */ + private void loadConnectionStatusParser() { + Class classObject = null; + try { + classObject = getClass().getClassLoader().loadClass(connStatusParserImpl); + this.parser = (DeviceConnectionStatusParser) ctx.getBean(classObject); + logger.info("Class {} loaded as DeviceConnectionStatusParser", parser.getClass().getName()); + } catch (Exception e) { + try { + if (classObject == null) { + throw new IllegalArgumentException("Could not load the class " + connStatusParserImpl); + } + this.parser = (DeviceConnectionStatusParser) classObject.getDeclaredConstructor().newInstance(); + logger.info("Class {} loaded as DeviceConnectionStatusParser", parser.getClass().getName()); + } catch (Exception exception) { + String msg = String.format("Class %s could not be loaded. Not found on classpath.", + connStatusParserImpl); + logger.error(msg + ExceptionUtils.getStackTrace(exception)); + throw new IllegalArgumentException(msg); + } + } + } + + /** + * Validate. + */ + private void validate() { + if (apiMaxRetryCount < 0) { + throw new IllegalArgumentException("DMA_CONNECTION_STATUS_API_MAX_RETRY_COUNT cannot be less than 0"); + } + if (apiRetryIntervalMs < 0) { + throw new IllegalArgumentException("DMA_CONNECTION_STATUS_API_RETRY_INTERVAL_MS cannot be less than 0"); + } + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DefaultMqttTopicNameGeneratorImpl.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DefaultMqttTopicNameGeneratorImpl.java new file mode 100644 index 0000000..62669c4 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/DefaultMqttTopicNameGeneratorImpl.java @@ -0,0 +1,236 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.exception.InvalidTargetIDException; +import org.eclipse.ecsp.analytics.stream.base.exception.MqttTopicException; +import org.eclipse.ecsp.analytics.stream.base.platform.MqttTopicNameGenerator; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * The default logic of creating the MQTT Topic name is in this class. + */ +@Component +@Scope("prototype") +@ConditionalOnExpression("${" + PropertyNames.DMA_ENABLED + ":true} " + + "and '${" + PropertyNames.MQTT_TOPIC_GENERATOR_SERVICE_IMPL_CLASS_NAME + "}'" + + ".equalsIgnoreCase('" + PropertyNames.DEFAULT_TOPIC_NAME_GENERATOR_IMPL + "')" +) +public class DefaultMqttTopicNameGeneratorImpl implements MqttTopicNameGenerator { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DefaultMqttTopicNameGeneratorImpl.class); + + /** The topic separator. */ + // mqtt topic separator + @Value("${" + PropertyNames.MQTT_TOPIC_SEPARATOR + ":" + Constants.FORWARD_SLASH + "}") + protected String topicSeparator; + + /** The to device. */ + // mqtt topic separator + @Value("${" + PropertyNames.MQTT_TOPIC_TO_DEVICE_INFIX + ":" + Constants.TO_DEVICE + "}") + protected String toDevice; + + /** The topic name prefix. */ + @Value("${" + PropertyNames.MQTT_SERVICE_TOPIC_NAME_PREFIX + ":}") + protected String topicNamePrefix; + + /** The service topic name. */ + @Value("${" + PropertyNames.MQTT_SERVICE_TOPIC_NAME + "}") + protected String serviceTopicName; + + /** The sub services. */ + /* + * RTC 355420. DMA should have the functionality to track device connection status at sub-service level, + * if configured any. + */ + @Value("${" + PropertyNames.SUB_SERVICES + ":}") + protected String subServices; + + /** + * Gets the topic name prefix. + * + * @return the topic name prefix + */ + public String getTopicNamePrefix() { + return topicNamePrefix; + } + + /** + * Sets the topic name prefix. + * + * @param topicNamePrefix the new topic name prefix + */ + public void setTopicNamePrefix(String topicNamePrefix) { + this.topicNamePrefix = topicNamePrefix; + } + + /** + * Verify and dump mqtt properties. + */ + @PostConstruct + private void verifyAndDumpMqttProperties() { + ObjectUtils.requireNonEmpty(serviceTopicName, "Service topic name shouldn't be null or empty."); + } + + /** + * Gets the mqtt topic name. + * + * @param key the key + * @param header the header + * @param eventId the event id + * @return the mqtt topic name + */ + @Override + public Optional getMqttTopicName(IgniteKey key, DeviceMessageHeader header, String eventId) { + + /* + * DataPlatform 101-HCP-12088, All the events going to Mqtt are + * white listed and Mqtt Topic is vehicleId/serviceName/eventId + * + * Later Mqtt topic will be targetDeviceId+eventID. Targeted deviceID + * could be either TELEMATICS or HU. Both TELEMATICS and HU can be present in the + * vehicle, and thats why we would like to know through targetDeviceID + * who is the recipient of this event. + * + * s + */ + + String topicName = null; + if (header.isGlobalTopicNameProvided()) { + topicName = header.getDevMsgGlobalTopic(); + logger.debug("Global mqtt topic name {} is set for for key:{} ", topicName, key.toString()); + } else { + StringBuilder builder = new StringBuilder(); + String deviceId = header.getTargetDeviceId(); + if (StringUtils.isEmpty(deviceId)) { + throw new InvalidTargetIDException("Target device not present. Cannot dispatch to MQTT"); + } + + String actualTopicPrefix = header.getDevMsgTopicPrefix(); + if (!StringUtils.isEmpty(actualTopicPrefix)) { + buildActualTopicPrefix(builder, actualTopicPrefix); + } else { + builder.append(topicNamePrefix); + logger.debug("Mqtt topic prefix from property file is {}", topicNamePrefix); + } + + // topicNamePrefix will already have forward slash at the + // end.mqtt.service.topic.name.prefix=haa/harman/dev/ + // IN case topicNamePrefix is empty then the topic name will be + // vehicleId/serviceName/eventId (Note the absense of forward slash + // at the begining + // toDevice should have prefix with topic separator in + // mqtt.topic.to.device.infix property, for example /2d + + String actualTopicSuffix = header.getDevMsgTopicSuffix(); + + if (StringUtils.isNotEmpty(subServices)) { + builder.append(deviceId).append(topicSeparator); + actualTopicSuffix = actualTopicSuffix.toUpperCase(); + } else { + builder.append(deviceId).append(toDevice).append(topicSeparator); + } + + if (!StringUtils.isEmpty(actualTopicSuffix)) { + topicName = buildActualTopicSuffix(builder, actualTopicSuffix); + } else { + topicName = builder.append(serviceTopicName).toString(); + } + } + + logger.info("Mqtt Topic Name for key : {} , eventID : {} with device message headers : {} is : {}", + key.toString(), eventId, header.toString(), topicName); + + return Optional.ofNullable(topicName); + + } + + /** + * Builds the actual topic suffix. + * + * @param builder the builder + * @param actualTopicSuffix the actual topic suffix + * @return the string + */ + @NotNull + protected static String buildActualTopicSuffix(StringBuilder builder, String actualTopicSuffix) { + String topicName; + if (actualTopicSuffix.startsWith("/")) { + actualTopicSuffix = actualTopicSuffix.replaceFirst("/", ""); + } + if (StringUtils.isEmpty(actualTopicSuffix)) { + throw new MqttTopicException("Mqtt topic suffix set is empty"); + } + topicName = builder.append(actualTopicSuffix).toString(); + return topicName; + } + + /** + * Builds the actual topic prefix. + * + * @param builder the builder + * @param actualTopicPrefix the actual topic prefix + */ + protected static void buildActualTopicPrefix(StringBuilder builder, String actualTopicPrefix) { + if (actualTopicPrefix.startsWith("/")) { + actualTopicPrefix = actualTopicPrefix.replaceFirst("/", ""); + } + if (StringUtils.isEmpty(actualTopicPrefix)) { + throw new MqttTopicException("Mqtt topic prefix set is empty"); + } + builder.append(actualTopicPrefix); + logger.debug("Mqtt topic prefix from event is {}", actualTopicPrefix); + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Dispatcher.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Dispatcher.java new file mode 100644 index 0000000..3297a1e --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Dispatcher.java @@ -0,0 +1,58 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +/** + * Interface that dictates how events will be dispatched. It could be mqtt, kafka etc + * + * @param the key type + * @param the value type + */ +public interface Dispatcher { + + /** + * Method to dispatch the key value to appropriate end point lets say Mqtt. + * + * @param key key + * @param value value + */ + public void dispatch(K key, V value); + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ForcedHealthCheckEvent.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ForcedHealthCheckEvent.java new file mode 100644 index 0000000..0ba774e --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ForcedHealthCheckEvent.java @@ -0,0 +1,80 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.entities.IgniteEventImpl; + +import java.util.Optional; + +/** + * class {@link ForcedHealthCheckEvent} extends {@link IgniteEventImpl}. + */ +public class ForcedHealthCheckEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new forced health check event. + */ + public ForcedHealthCheckEvent() { + //overridden method + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "testHealthMonitorService"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of(Constants.FORCED_HEALTH_CHECK_DEVICE_ID); + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMqMqttDispatcher.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMqMqttDispatcher.java new file mode 100644 index 0000000..658b49e --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMqMqttDispatcher.java @@ -0,0 +1,336 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import com.hivemq.client.mqtt.MqttClient; +import com.hivemq.client.mqtt.MqttClientSslConfig; +import com.hivemq.client.mqtt.datatypes.MqttClientIdentifier; +import com.hivemq.client.mqtt.datatypes.MqttQos; +import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient; +import com.hivemq.client.mqtt.mqtt3.Mqtt3ClientBuilder; +import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3Publish; +import jakarta.annotation.PostConstruct; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamBaseConstant; +import org.eclipse.ecsp.serializer.IngestionSerializerFactory; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.context.annotation.DependsOn; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * class HiveMqMqttDispatcher extends MqttDispatcher. + */ +@ConditionalOnExpression( + "${" + PropertyNames.DMA_ENABLED + ":true} and '${" + + PropertyNames.MQTT_CLIENT + "}'.equalsIgnoreCase('hivemq')") +@Component +@Scope("prototype") +public class HiveMqMqttDispatcher extends MqttDispatcher { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(HiveMqMqttDispatcher.class); + + /** The message pay load. */ + byte[] messagePayLoad; + + /** The mqtt qos. */ + private MqttQos mqttQos; + + /** The mqtt client map. */ + private Map mqttClientMap; + + /** + * Inits the. + */ + @PostConstruct + private void init() { + mqttPlatformConfigMap = new ConcurrentHashMap<>(); + mqttClientMap = new ConcurrentHashMap<>(); + logger.info("Property loaded for mqtt broker to platformId mapping {}", mqttBrokerPlatformIdMapping); + mqttQos = MqttQos.fromCode(mqttQosValue); + if (mqttQos == null) { + logger.info("No QOS value defined against property mqtt.config.qos. Setting to default value"); + mqttQos = Mqtt3Publish.DEFAULT_QOS; + } + createMqttConfigForDefaultPlatform(); + createMqttConfigForPlatforms(); + initializeForcedHealthCheckEvent(); + healthMonitor.register(this); + transformer = IngestionSerializerFactory.getInstance(igniteSerializationImpl); + logger.info("Initialized HiveMqMqttDispatcher with QOS value for default platform as : {}", mqttQos); + } + + /** + * Creates the mqtt client. + * + * @param platform the platform + */ + @Override + protected void createMqttClient(String platform) { + createMqtt3AsyncClient(platform); + } + + /** + * Creates the mqtt 3 async client. + * + * @param platform the platform + */ + private void createMqtt3AsyncClient(String platform) { + logger.debug("Fetching HiveMq MqttClient for platformID : {}", platform); + Mqtt3AsyncClient client = mqttClientMap.get(platform); + if (client == null || !client.getState().isConnected()) { + logger.warn("Initializing HiveMQ MqttClient because it is null or not connected for platformID : {}", + platform); + Optional cl = getConnection(platform); + if (!cl.isPresent()) { + logger.error("Unable to establish connection to Mqtt broker with HiveMQ client for platformID : {}", + platform); + healthy = false; + errorCounter.incErrorCounter(Optional.ofNullable(taskId), Exception.class); + } else { + client = cl.get(); + mqttClientMap.put(platform, client); + healthy = true; + } + } else { + logger.debug("HiveMQ Client with ID : {} is already connected. Need not reconnect for platformID : {}", + client.getConfig().getClientIdentifier(), platform); + } + } + + /** + * Gets the connection. + * + * @param platform the platform + * @return the connection + */ + private Optional getConnection(String platform) { + try { + for (int i = 0; i < retryCount; i++) { + Optional client = getMqttClient(platform); + if (client.isPresent()) { + return client; + } + logger.debug("Retrying connecting to MQTT broker with HiveMQ client for platformID : {}. " + + "Current retry count : {}", platform, i); + Thread.sleep(retryInterval); + } + } catch (InterruptedException e) { + logger.error("Error occurred when sleeping during retry for creating connection to MQTT broker", e); + Thread.currentThread().interrupt(); + } + return Optional.empty(); + } + + /** + * Get the Mqtt3ClientAsyncClient instance for the given platformId. + + * @param platform The platform identifier. + * @return The instance of Mqtt3AsyncClient + */ + public synchronized Optional getMqttClient(String platform) { + Mqtt3AsyncClient client = null; + logger.info("Creating HiveMQ MQTT client for platformID : {}", platform); + Optional mqttConfigOpt = getMqttConfig(platform); + if (!mqttConfigOpt.isPresent()) { + logger.error("No MQTT config found for platformID : {}. HiveMQ MQTT client cannot be created.", platform); + return Optional.ofNullable(client); + } + + MqttConfig mqttConfig = mqttConfigOpt.get(); + try { + String brokerUrl = mqttConfig.getBrokerUrl(); + int brokerPort = mqttConfig.getMqttBrokerPort(); + int mqttTimeoutInMillis = mqttConfig.getMqttTimeoutInMillis(); + String userName = mqttConfig.getMqttUserName(); + String password = mqttConfig.getMqttUserPassword(); + String mqttClientId = podName + Constants.HYPHEN + UUID.randomUUID(); + Mqtt3ClientBuilder mqtt3ClientBuilder = MqttClient.builder().identifier(mqttClientId) + .serverHost(brokerUrl).serverPort(brokerPort) + .useMqttVersion3(); + mqtt3ClientBuilder = checkAndApplySslConfig(mqtt3ClientBuilder, mqttConfig, platform); + client = mqtt3ClientBuilder.transportConfig().mqttConnectTimeout(mqttTimeoutInMillis, TimeUnit.MILLISECONDS) + .applyTransportConfig() + .addConnectedListener(context -> logger.debug("HiveMq client with id {} is connected " + + "for platformID {}", mqttClientId, platform)) + .addDisconnectedListener(context -> logger.debug("HiveMq client with id {} is disconnected " + + "for platformID {}", mqttClientId, platform)) + .buildAsync(); + client.connectWith().keepAlive(keepAliveInterval).cleanSession(cleanSession) + .simpleAuth().username(userName).password(password.getBytes(StandardCharsets.UTF_8)) + .applySimpleAuth() + .send() + .whenComplete((connAck, throwable) -> { + if (throwable != null) { + logger.error("Failure in HiveMQ client creation for platformID : {}. Exception is {}", + platform, throwable); + } else { + logger.info("HiveMQ Mqtt Client with id {} is created successfully for platformID : {}", + mqttClientId, platform); + } + }); + } catch (Exception e) { + logger.error("HiveMQ MQTT client could not connect for platformID : {}. Exception while creating " + + "HiveMQ Mqtt client " + "and the error msg is : {}", platform, e); + } + return Optional.ofNullable(client); + } + + /** + * Check and apply ssl config. + * + * @param mqtt3ClientBuilder the mqtt 3 client builder + * @param mqttConfig the mqtt config + * @param platform the platform + * @return the mqtt 3 client builder + */ + Mqtt3ClientBuilder checkAndApplySslConfig(Mqtt3ClientBuilder mqtt3ClientBuilder, MqttConfig mqttConfig, + String platform) { + if (StreamBaseConstant.DMA_ONE_WAY_TLS_AUTH_MECHANISM + .equalsIgnoreCase(mqttConfig.getMqttClientAuthMechanism())) { + return mqtt3ClientBuilder + .sslConfig(MqttClientSslConfig.builder() + .trustManagerFactory(DMATLSFactory.getTrustManagerFactory(platform, mqttConfig)) + .build()); + } + return mqtt3ClientBuilder; + } + + /** + * Publish message to mqtt topic. + * + * @param mqttTopicName the mqtt topic name + * @param isRetainedMessage the is retained message + * @param platform the platform + */ + @Override + protected void publishMessageToMqttTopic(String mqttTopicName, boolean isRetainedMessage, String platform) { + Mqtt3AsyncClient client = mqttClientMap.get(platform); + if (null == client) { + throw new NoMqttClientFoundException("Unable to publish message to topic : " + mqttTopicName + ". " + + "No MQTT client found against platformID : " + platform); + } + + logger.debug("Publishing the event via HiveMQ client to the mqtt topic : {} ,with retained flag as : {} ," + + "for platformID : {}", mqttTopicName, isRetainedMessage, platform); + Optional mqttConfigOpt = getMqttConfig(platform); + MqttQos qos = (mqttConfigOpt.isPresent() ? MqttQos.fromCode(mqttConfigOpt.get().getMqttQosValue()) : mqttQos); + client.publishWith().topic(mqttTopicName) + .payload(messagePayLoad) + .qos(qos) + .retain(isRetainedMessage) + .send(); + } + + /** + * Sets the mqtt message payload. + * + * @param payload the new mqtt message payload + */ + @Override + protected void setMqttMessagePayload(byte[] payload) { + messagePayLoad = payload; + } + + /** + * Close mqtt connections. + */ + protected void closeMqttConnections() { + for (Map.Entry entry : mqttClientMap.entrySet()) { + closeMqttConnection(entry.getKey()); + } + } + + /** + * Close mqtt connection. + * + * @param platform the platform + */ + @Override + protected void closeMqttConnection(String platform) { + Mqtt3AsyncClient client; + MqttClientIdentifier mqttClientId = null; + if (mqttClientMap.containsKey(platform)) { + client = mqttClientMap.get(platform); + } else { + logger.info("No MQTT client found to be closed against platformID : {} in mqttClientMap", platform); + return; + } + try { + if (client != null && client.getState().isConnected()) { + mqttClientId = client.getConfig().getClientIdentifier().orElse(null); + client.disconnect(); + client = null; + } + } catch (Exception e1) { + logger.error("Unable to disconnect the HiveMQ client for platformID : {} with clientID {} and " + + "error msg is {}.", platform, mqttClientId, e1); + } finally { + if (client == null || !client.getState().isConnected()) { + logger.info("HiveMQ Client with ID: {} , for platformID : {} disconnected successfully", + mqttClientId, platform); + } else { + logger.info("HiveMQ Client is still connected with client id {} , resetting reference to null.", + client.getConfig().getClientIdentifier()); + client = null; + } + } + } + + /** + * Sets the mqtt client map. + * + * @param mqttClientMap the mqtt client map + */ + // setter for unit test cases + void setMqttClientMap(Map mqttClientMap) { + this.mqttClientMap = mqttClientMap; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/InternalCacheConstants.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/InternalCacheConstants.java new file mode 100644 index 0000000..46736b9 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/InternalCacheConstants.java @@ -0,0 +1,68 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +/** + * class {@link InternalCacheConstants}: constants file. + */ +public class InternalCacheConstants { + + /** + * Instantiates a new internal cache constants. + */ + private InternalCacheConstants() { + + } + + /** The Constant CACHE_TYPE_RETRY_RECORD. */ + public static final String CACHE_TYPE_RETRY_RECORD = "retry_record_cache"; + + /** The Constant CACHE_TYPE_RETRY_BUCKET. */ + public static final String CACHE_TYPE_RETRY_BUCKET = "retry_bucket_cache"; + + /** The Constant CACHE_TYPE_SHOULDER_TAP_RETRY_RECORD. */ + public static final String CACHE_TYPE_SHOULDER_TAP_RETRY_RECORD = "shoulder_tap_retry_record_cache"; + + /** The Constant CACHE_TYPE_SHOULDER_TAP_RETRY_BUCKET. */ + public static final String CACHE_TYPE_SHOULDER_TAP_RETRY_BUCKET = "shoulder_tap_retry_bucket_cache"; + + /** The Constant CACHE_TYPE_DEVICE_CONN_STATUS_CACHE. */ + public static final String CACHE_TYPE_DEVICE_CONN_STATUS_CACHE = "device_conn_status_cache"; +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/JsonUtils.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/JsonUtils.java new file mode 100644 index 0000000..c65f305 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/JsonUtils.java @@ -0,0 +1,321 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.joda.time.DateTime; +import org.joda.time.format.DateTimeFormatter; +import org.joda.time.format.ISODateTimeFormat; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + + +/** + * Util class: {@link JsonUtils}. + */ +@SuppressWarnings("checkstyle:HideUtilityClassConstructor") +public class JsonUtils { + + /** + * Instantiates a new json utils. + */ + protected JsonUtils() {} + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(JsonUtils.class); + + /** The Constant JSON_MAPPER. */ + private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + + /** The Constant ISO_DT_FORMATTER. */ + private static final DateTimeFormatter ISO_DT_FORMATTER = ISODateTimeFormat.dateTime().withZoneUTC(); + + static { + JSON_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + JSON_MAPPER.setSerializationInclusion(Include.NON_NULL); + JSON_MAPPER.setFilterProvider(new SimpleFilterProvider().setFailOnUnknownId(false)); + } + + /** + * getValueAsString(). + * + * @param key key + * @param data data + * @return String + */ + public static String getValueAsString(String key, String data) { + + JsonNode json = null; + try { + json = JSON_MAPPER.readValue(data, JsonNode.class); + } catch (IOException e) { + logger.info("Unable to parse the event data: {} ", data); + return null; + } + return safeGetStringFromJsonNode(key, json); + + } + + /** + * safeGetStringFromJsonNode(). + * + * @param key key + * @param json json + * @return String + */ + public static String safeGetStringFromJsonNode(String key, JsonNode json) { + + if (json == null) { + return null; + } + + JsonNode node = json.get(key); + + if (node != null) { + return node.asText(); + } else { + Iterator it = json.fieldNames(); + while (it.hasNext()) { + String str = (String) it.next(); + if (str.equalsIgnoreCase(key)) { + return json.get(str).asText(); + } + } + } + + return null; + } + + /** + * safeGetBooleanFromJsonNode(). + * + * @param key key + * @param json json + * @return boolean + */ + public static boolean safeGetBooleanFromJsonNode(String key, JsonNode json) { + if (json == null) { + return false; + } + + JsonNode node = json.get(key); + if (node != null) { + return node.asBoolean(); + } + return false; + } + + /** + * getObjectValueAsString(). + * + * @param obj obj + * @return String + */ + public static String getObjectValueAsString(Object obj) { + try { + return JSON_MAPPER.writeValueAsString(obj); + } catch (JsonProcessingException e) { + logger.info("Unable to create the class for the object {}", obj.toString()); + return null; + } + } + + /** + * getObjectValueAsBytes(). + * + * @param obj obj + * @return byte{@code [}{@code ]} + */ + public static byte[] getObjectValueAsBytes(Object obj) { + try { + return JSON_MAPPER.writeValueAsBytes(obj); + } catch (JsonProcessingException e) { + logger.error("Unable to create the class for the object {} error {}", obj.toString(), e); + return new byte[0]; + } + } + + /** + * getJsonNode(). + * + * @param key key + * @param data data + * @return JsonNode + */ + public static JsonNode getJsonNode(String key, String data) { + JsonNode json = null; + try { + json = JSON_MAPPER.readValue(data, JsonNode.class); + } catch (IOException e) { + logger.error("Unable to parse the event data: {}, error {}", data, e); + return null; + } + + if (json == null) { + return null; + } + + return json.get(key); + + } + + /** + * getJsonAsMap(). + * + * @param eventData eventData + * @return Map + */ + public static Map getJsonAsMap(String eventData) { + try { + return JSON_MAPPER.readValue(eventData, new TypeReference>() { + }); + } catch (Exception e) { + logger.error("Unable to convert the eventData to object and error is {}", e); + return Collections.emptyMap(); + } + + } + + /** + * getObjectAsMap(). + * + * @param object object + * @return Map + */ + public static Map getObjectAsMap(Object object) { + try { + return JSON_MAPPER.convertValue(object, Map.class); + } catch (Exception e) { + logger.error("Unable to convert the eventData {} to object and error is {}", object, e); + return Collections.emptyMap(); + } + + } + + /** + * From the given JsonNode, fetches the value for a key and put the value in a list. + * + * @param node node + * @param key key + * @return List + */ + public static List getValuesAsList(JsonNode node, String key) { + List list = new ArrayList<>(); + JsonNode val = node.get(key); + if (null != val) { + if (val.isArray()) { + Iterator iter = val.iterator(); + while (iter.hasNext()) { + String value = iter.next().asText(); + list.add(value); + } + + } else if (val.isTextual()) { + // should be single value + list.add(val.asText()); + } else { + logger.error("Only single string value or arrays of string values are supported"); + } + } + return list; + } + + /** + * Method data takes the json data and binds to the POJO. + * + * @param the generic type + * @param eventData eventData + * @param clazz the clazz + * @return the t + * @throws IOException IOException + */ + public static T bindData(String eventData, Class clazz) throws IOException { + return JSON_MAPPER.readValue(eventData, clazz); + } + + /** + * Gets the list objects. + * + * @param the generic type + * @param data the data + * @param cl the cl + * @return the list objects + * @throws IOException Signals that an I/O exception has occurred. + */ + public static List getListObjects(String data, Class cl) throws IOException { + return JSON_MAPPER.readValue(data, JSON_MAPPER.getTypeFactory().constructCollectionType(List.class, cl)); + } + + /** + * For converting the joda time to ISO date which is used by MongoDB. + */ + public static class IsoDateSerializer extends JsonSerializer { + + /** + * Serialize. + * + * @param value the value + * @param jgen the jgen + * @param provider the provider + * @throws IOException Signals that an I/O exception has occurred. + */ + @Override + public void serialize(DateTime value, JsonGenerator jgen, SerializerProvider provider) throws IOException { + String isoDate = ISO_DT_FORMATTER.print(value); + jgen.writeString(isoDate); + } + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaDispatcher.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaDispatcher.java new file mode 100644 index 0000000..51b0bb3 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaDispatcher.java @@ -0,0 +1,269 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.apache.commons.lang3.StringUtils; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.eclipse.ecsp.analytics.stream.base.KafkaProducerInstance; +import org.eclipse.ecsp.analytics.stream.base.KafkaSslConfig; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.domain.DeviceMessageFailureEventDataV1_0; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageDispatchers; +import org.eclipse.ecsp.entities.dma.DeviceMessageErrorCode; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.handler.DeviceMessageHandler; +import org.eclipse.ecsp.stream.dma.handler.DeviceMessageUtils; +import org.eclipse.ecsp.transform.IgniteKeyTransformer; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Properties; + + +/** + * RDNG: 170507, RTC: 433337. DMA should have the capability to dispatch data to Kafka. + * This dispatcher class will dispatch the DeviceMessage to the provided kafka topic for an ecuType. + * + */ + +@Component +@Scope("prototype") +@ConditionalOnProperty(name = PropertyNames.DMA_ENABLED, havingValue = "true") +public class KafkaDispatcher implements Dispatcher, DeviceMessage> { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(KafkaDispatcher.class); + + /** The dma post dispatch handler. */ + private DeviceMessageHandler dmaPostDispatchHandler; + + /** The broker to ecu types mapping. */ + private Map> brokerToEcuTypesMapping = null; + + /** The spc. */ + private StreamProcessingContext spc; + + /** The kafka producer. */ + //Kafka producer used to publish to kafka. + private KafkaProducer kafkaProducer = null; + + /** The kafka bootstrap servers. */ + @Value("${" + PropertyNames.BOOTSTRAP_SERVERS + ":}") + private String kafkaBootstrapServers; + + /** The max request size. */ + @Value("${" + PropertyNames.KAFKA_MAX_REQUEST_SIZE + ":}") + private String maxRequestSize; + + /** The acks config. */ + @Value("${" + PropertyNames.KAFKA_ACKS_CONFIG + ":}") + private String acksConfig; + + /** The retries config. */ + @Value("${" + PropertyNames.KAFKA_RETRIES_CONFIG + ":}") + private String retriesConfig; + + /** The batch size config. */ + @Value("${" + PropertyNames.KAFKA_BATCH_SIZE_CONFIG + ":}") + private String batchSizeConfig; + + /** The linger ms config. */ + @Value("${" + PropertyNames.KAFKA_LINGER_MS_CONFIG + ":}") + private String lingerMsConfig; + + /** The buffer memory config. */ + @Value("${" + PropertyNames.KAFKA_BUFFER_MEMORY_CONFIG + ":}") + private String bufferMemoryConfig; + + /** The request timeout ms config. */ + @Value("${" + PropertyNames.KAFKA_REQUEST_TIMEOUT_MS_CONFIG + ":}") + private String requestTimeoutMsConfig; + + /** The delivery timeout ms config. */ + @Value("${" + PropertyNames.KAFKA_DELIVERY_TIMEOUT_MS_CONFIG + ":}") + private String deliveryTimeoutMsConfig; + + /** The compression type config. */ + @Value("${" + PropertyNames.KAFKA_COMPRESSION_TYPE_CONFIG + ":}") + private String compressionTypeConfig; + + /** The client id. */ + @Value("${" + PropertyNames.CLIENT_ID + ":}") + private String clientId; + + /** The key transformer. */ + @Autowired + private IgniteKeyTransformer keyTransformer; + + /** The device message utils. */ + @Autowired + private DeviceMessageUtils deviceMessageUtils; + + /** The kafka ssl config. */ + @Autowired + private KafkaSslConfig kafkaSslConfig; + + /** + * Inits the. + */ + //If the broker name is present in our brokerToEcuTypesMapping, only then initialize the KafkaProducer. + private void init() { + if (brokerToEcuTypesMapping != null && brokerToEcuTypesMapping.containsKey(DeviceMessageDispatchers.KAFKA)) { + logger.info("Initializing KafkaProducer for DMA KafkaDispatcher..."); + ObjectUtils.requireNonEmpty(kafkaBootstrapServers, "Kafka bootstrap servers config cannot be empty."); + + Properties kafkaConfig = new Properties(); + kafkaConfig.put(PropertyNames.BOOTSTRAP_SERVERS, kafkaBootstrapServers); + kafkaConfig.put(PropertyNames.KAFKA_MAX_REQUEST_SIZE, maxRequestSize); + kafkaConfig.put(PropertyNames.KAFKA_ACKS_CONFIG, acksConfig); + kafkaConfig.put(PropertyNames.KAFKA_BATCH_SIZE_CONFIG, batchSizeConfig); + kafkaConfig.put(PropertyNames.KAFKA_RETRIES_CONFIG, retriesConfig); + kafkaConfig.put(PropertyNames.KAFKA_LINGER_MS_CONFIG, lingerMsConfig); + kafkaConfig.put(PropertyNames.KAFKA_BUFFER_MEMORY_CONFIG, bufferMemoryConfig); + kafkaConfig.put(PropertyNames.KAFKA_REQUEST_TIMEOUT_MS_CONFIG, requestTimeoutMsConfig); + kafkaConfig.put(PropertyNames.KAFKA_DELIVERY_TIMEOUT_MS_CONFIG, deliveryTimeoutMsConfig); + kafkaConfig.put(PropertyNames.KAFKA_COMPRESSION_TYPE_CONFIG, compressionTypeConfig); + if (StringUtils.isNotBlank(clientId)) { + kafkaConfig.put(PropertyNames.CLIENT_ID, clientId); + } + kafkaSslConfig.setSslPropsIfEnabled(kafkaConfig); + kafkaProducer = KafkaProducerInstance.getProducerInstance(kafkaConfig); + logger.debug("Initialized Kafka Producer for DMA KafkaDispatcher."); + } + } + + /** + * setup(). + * + * @param brokerToEcuTypesMapping brokerToEcuTypesMapping + * @param spc spc + */ + + public void setup(Map> brokerToEcuTypesMapping, StreamProcessingContext spc) { + this.brokerToEcuTypesMapping = brokerToEcuTypesMapping; + this.spc = spc; + init(); + } + + /** + * Dispatch the key,value to the kafka topic and if some exception occurs while dispatch, + * publish the failed device message event to the feedback topic. + * This method does not throw any exception as we don't want to stop the application from + * serving other VINs / Requests. + * + * @param key the key + * @param value the value + */ + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Override + public void dispatch(IgniteKey key, DeviceMessage value) { + if (key == null) { + logger.error("Key is NULL. Not dispatching the data to Kafka"); + return; + } + if (value == null) { + logger.error("Value is NULL. Not dispatching the data to kafka."); + return; + } + logger.debug("Recevied key: {} and value {} in KafkaDispatcher to dispatch.", key.toString(), value); + + /* + * brokerToEcuTypesMapping.get(DeviceMessageDispatchers.KAFKA) will get us the name of the broker/platform + * which again contains a map of ecuType-topic mapping against it. + * Hence the another call to get method will get us the name of the Kafka topic against this ecuType. + */ + String kafkaTopic = brokerToEcuTypesMapping.get(DeviceMessageDispatchers.KAFKA) + .get(value.getEvent().getEcuType().toLowerCase()); + byte[] keyBytes = keyTransformer.toBlob(key); + byte[] message = value.getMessage(); + Map kafkaHeaders = value.getEvent().getKafkaHeaders(); + List
    kafkaHeadersList = new ArrayList<>(); + logger.debug("Retreived kafka headers from IgniteEvent with requestID : {} , headers : {}", + value.getDeviceMessageHeader().getRequestId(), kafkaHeaders); + if (kafkaHeaders != null && !kafkaHeaders.isEmpty()) { + kafkaHeaders.forEach((k, v) -> { + if (!StringUtils.isEmpty(v)) { + kafkaHeadersList.add(new RecordHeader(k, v.getBytes(StandardCharsets.UTF_8))); + } + }); + } + + //Send this data to the kafka topic and if any exception occurs, publish the failed DeviceMessage event + //to the feedback topic. + kafkaProducer.send(new ProducerRecord<>(kafkaTopic, null, keyBytes, message, kafkaHeadersList), + (metadata, exception) -> { + if (exception != null) { + logger.error("Exception when pushing message directly to stream: ", exception); + DeviceMessageFailureEventDataV1_0 data = new DeviceMessageFailureEventDataV1_0(); + data.setFailedIgniteEvent(value.getEvent()); + data.setErrorCode(DeviceMessageErrorCode.KAFKA_DISPATCH_FAILED); + data.setDeviceStatusInactive(true); + deviceMessageUtils.postFailureEvent(data, key, spc, value.getFeedBackTopic()); + } + }); + logger.info("Successfully published the message: {} with key: {}, and headers : {}, on kafka topic: {}", + value, key.toString(), kafkaHeadersList, kafkaTopic); + dmaPostDispatchHandler.handle(key, value); + } + + /** + * Sets the next handler. + * + * @param dmaPostDispatchHandler the new next handler + */ + public void setNextHandler(DeviceMessageHandler dmaPostDispatchHandler) { + this.dmaPostDispatchHandler = dmaPostDispatchHandler; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaSslUtils.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaSslUtils.java new file mode 100644 index 0000000..935d364 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaSslUtils.java @@ -0,0 +1,171 @@ +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.apache.kafka.clients.CommonClientConfigs; +import org.apache.kafka.common.config.SaslConfigs; +import org.apache.kafka.common.config.SslClientAuth; +import org.apache.kafka.common.config.SslConfigs; +import org.apache.kafka.common.config.internals.BrokerSecurityConfigs; +import org.apache.kafka.common.security.auth.SecurityProtocol; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; + +import java.util.Properties; + + +/** + * A utility class to apply the SSL or SASL config depending on which one is enabled, + * on Kafka Producer or Kafka Consumer. + * + * @author karora + * + */ +public final class KafkaSslUtils { + + /** + * Instantiates a new kafka ssl utils. + */ + private KafkaSslUtils() { + // This is an utility class and should not be initialized + } + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(KafkaSslUtils.class); + + /** + * Checks and applies necessary configurations related to SASL or SSL. + * + * @param props The properties instance. + */ + public static void checkAndApplySslProperties(Properties props) { + if (isSslEnabled(props) || isOneWayTlsEnabled(props)) { + applySslProperties(props); + } + } + + /** + * Utility method to set the required properties for enabling SASL_SSL or SSL. + * + * @param props The properties instance. + * @return The properties instance with desired configs applied. + */ + public static Properties applySslProperties(Properties props) { + String sslClientAuth = SslClientAuth.NONE.toString(); + if (isSslEnabled(props)) { + String keystore = props.getProperty(PropertyNames.KAFKA_CLIENT_KEYSTORE); + ObjectUtils.requireNonEmpty(keystore, "Kafka client key store must be provided as SSL is enabled"); + String keystorePwd = props.getProperty(PropertyNames.KAFKA_CLIENT_KEYSTORE_PASSWORD); + ObjectUtils.requireNonEmpty(keystorePwd, "Kafka client key store password must be " + + "provided as SSL is enabled"); + String keyPwd = props.getProperty(PropertyNames.KAFKA_CLIENT_KEY_PASSWORD); + ObjectUtils.requireNonEmpty(keyPwd, "Kafka client key password must be provided as SSL is enabled"); + sslClientAuth = props.getProperty(PropertyNames.KAFKA_SSL_CLIENT_AUTH, SslClientAuth.REQUIRED.toString()); + props.setProperty(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, SecurityProtocol.SSL.name); + props.setProperty(SslConfigs.SSL_KEYSTORE_LOCATION_CONFIG, keystore); + props.setProperty(SslConfigs.SSL_KEYSTORE_PASSWORD_CONFIG, keystorePwd); + props.setProperty(SslConfigs.SSL_KEY_PASSWORD_CONFIG, keyPwd); + logger.debug("Properties set for SSL"); + } else if (isOneWayTlsEnabled(props)) { + String saslMechanism = props.getProperty(PropertyNames.KAFKA_SASL_MECHANISM); + ObjectUtils.requireNonEmpty(saslMechanism, "Kafka SASL Mechanism must be provided " + + "as Kafka SASL is enabled."); + String saslJaasConfig = props.getProperty(PropertyNames.KAFKA_SASL_JAAS_CONFIG); + ObjectUtils.requireNonEmpty(saslJaasConfig, "Kafka SASL JAAS configuration must be " + + "provided as Kafka SASL is enabled."); + props.setProperty(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, SecurityProtocol.SASL_SSL.name); + props.setProperty(SaslConfigs.SASL_MECHANISM, saslMechanism); + props.setProperty(SaslConfigs.SASL_JAAS_CONFIG, saslJaasConfig); + logger.debug("Properties set for SASL"); + } + + String truststore = props.getProperty(PropertyNames.KAFKA_CLIENT_TRUSTSTORE); + ObjectUtils.requireNonEmpty(truststore, "Kafka client trust store must be provided"); + String truststorePwd = props.getProperty(PropertyNames.KAFKA_CLIENT_TRUSTSTORE_PASSWORD); + ObjectUtils.requireNonEmpty(truststorePwd, "Kafka client trust store password must be provided"); + String sslEndpointAlgo = props.getProperty(PropertyNames.KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM, ""); + props.setProperty(SslConfigs.SSL_ENDPOINT_IDENTIFICATION_ALGORITHM_CONFIG, sslEndpointAlgo); + props.setProperty(BrokerSecurityConfigs.SSL_CLIENT_AUTH_CONFIG, sslClientAuth); + props.setProperty(SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG, truststore); + props.setProperty(SslConfigs.SSL_TRUSTSTORE_PASSWORD_CONFIG, truststorePwd); + + return props; + } + + /** + * Utility method to set the required properties for enabling SASL_SSL or SSL. + * + * @param targetProps The target properties instance to set the configs into. + * @param sourceProps The source properties instance. + * @return The properties instance with desired configs set. + */ + public static Properties applySslProperties(Properties targetProps, Properties sourceProps) { + + String sslClientAuth = SslClientAuth.NONE.toString(); + if (isSslEnabled(sourceProps)) { + String keystore = sourceProps.getProperty(PropertyNames.KAFKA_CLIENT_KEYSTORE); + ObjectUtils.requireNonEmpty(keystore, "Kafka client key store must be provided"); + String keystorePwd = sourceProps.getProperty(PropertyNames.KAFKA_CLIENT_KEYSTORE_PASSWORD); + ObjectUtils.requireNonEmpty(keystorePwd, "Kafka client key store password must be provided"); + String keyPwd = sourceProps.getProperty(PropertyNames.KAFKA_CLIENT_KEY_PASSWORD); + ObjectUtils.requireNonEmpty(keyPwd, "Kafka client key password must be provided"); + sslClientAuth = sourceProps.getProperty(PropertyNames.KAFKA_SSL_CLIENT_AUTH, + SslClientAuth.REQUIRED.toString()); + targetProps.setProperty(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, SecurityProtocol.SSL.name); + targetProps.setProperty(SslConfigs.SSL_KEYSTORE_LOCATION_CONFIG, keystore); + targetProps.setProperty(SslConfigs.SSL_KEYSTORE_PASSWORD_CONFIG, keystorePwd); + targetProps.setProperty(SslConfigs.SSL_KEY_PASSWORD_CONFIG, keyPwd); + logger.debug("Properties set for SSL"); + } else if (isOneWayTlsEnabled(sourceProps)) { + String saslMechanism = sourceProps.getProperty(PropertyNames.KAFKA_SASL_MECHANISM); + ObjectUtils.requireNonEmpty(saslMechanism, "Kafka SASL Mechanism must be provided " + + "because Kafka SASL is enabled."); + String saslJaasConfig = sourceProps.getProperty(PropertyNames.KAFKA_SASL_JAAS_CONFIG); + ObjectUtils.requireNonEmpty(saslJaasConfig, "Kafka SASL JAAS configuration must " + + "be provided because Kafka SASL is enabled."); + targetProps.setProperty(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, SecurityProtocol.SASL_SSL.name); + targetProps.setProperty(SaslConfigs.SASL_MECHANISM, saslMechanism); + targetProps.setProperty(SaslConfigs.SASL_JAAS_CONFIG, saslJaasConfig); + logger.debug("Properties set for SASL"); + } + String truststore = sourceProps.getProperty(PropertyNames.KAFKA_CLIENT_TRUSTSTORE); + ObjectUtils.requireNonEmpty(truststore, "Kafka client trust store must be provided"); + String truststorePwd = sourceProps.getProperty(PropertyNames.KAFKA_CLIENT_TRUSTSTORE_PASSWORD); + ObjectUtils.requireNonEmpty(truststorePwd, "Kafka client trust store password must be provided"); + String sslEndpointAlgo = sourceProps.getProperty(PropertyNames.KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM, ""); + + targetProps.setProperty(SslConfigs.SSL_ENDPOINT_IDENTIFICATION_ALGORITHM_CONFIG, sslEndpointAlgo); + targetProps.setProperty(BrokerSecurityConfigs.SSL_CLIENT_AUTH_CONFIG, sslClientAuth); + targetProps.setProperty(SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG, truststore); + targetProps.setProperty(SslConfigs.SSL_TRUSTSTORE_PASSWORD_CONFIG, truststorePwd); + + return targetProps; + } + + /** + * Returns whether SSL is enabled or not. + * + * @param props The properties instance. + * @return boolean + */ + private static boolean isSslEnabled(Properties props) { + Object isSslEnabledObj = props.get(PropertyNames.KAFKA_SSL_ENABLE); + return (isSslEnabledObj instanceof Boolean isSslEnabled + && Boolean.TRUE.equals(isSslEnabled)) || (isSslEnabledObj + instanceof String sslEnabledStr && sslEnabledStr.equalsIgnoreCase(Constants.TRUE)); + } + + /** + * Returns whether one way TLS is enabled or not. + * + * @param props The properties instance. + * @return boolean + */ + private static boolean isOneWayTlsEnabled(Properties props) { + Object isOneWayTlsEnabledObj = props.get(PropertyNames.KAFKA_ONE_WAY_TLS_ENABLE); + return (isOneWayTlsEnabledObj instanceof Boolean isOneWayTlsEnabled + && Boolean.TRUE.equals(isOneWayTlsEnabled)) + || (isOneWayTlsEnabledObj instanceof String onewayTlsEnabledStr + && onewayTlsEnabledStr.equalsIgnoreCase(Constants.TRUE)); + } + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaTestUtils.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaTestUtils.java new file mode 100644 index 0000000..d8612ec --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaTestUtils.java @@ -0,0 +1,389 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.Producer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.utils.Utils; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.StreamsConfig; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeoutException; + + +/** + * Utility functions to make integration testing more convenient. + */ +public class KafkaTestUtils { + + /** + * Instantiates a new kafka test utils. + */ + private KafkaTestUtils() { + + } + + /** The Constant UNLIMITED_MESSAGES. */ + private static final int UNLIMITED_MESSAGES = -1; + + /** + * Returns up to `maxMessages` message-values from the topic. + * + * @param the key type + * @param the value type + * @param topic Kafka topic to read messages from + * @param consumerConfig Kafka consumer configuration + * @param maxMessages Maximum number of messages to read via the consumer. + * @return The values retrieved via the consumer. + */ + public static List readValues(String topic, Properties consumerConfig, int maxMessages) { + List returnList = new ArrayList<>(); + List> kvs = readKeyValues(topic, consumerConfig, maxMessages); + for (KeyValue kv : kvs) { + returnList.add(kv.value); + } + return returnList; + } + + /** + * Returns as many messages as possible from the topic until a (currently hardcoded) timeout is reached. + * + * @param the key type + * @param the value type + * @param topic Kafka topic to read messages from + * @param consumerConfig Kafka consumer configuration + * @return The KeyValue elements retrieved via the consumer. + */ + public static List> readKeyValues(String topic, Properties consumerConfig) { + return readKeyValues(topic, consumerConfig, UNLIMITED_MESSAGES); + } + + /** + * Returns up to `maxMessages` by reading via the provided consumer + * (the topic(s) to read from are already configured in the consumer). + * + * @param the key type + * @param the value type + * @param topic Kafka topic to read messages from + * @param consumerConfig Kafka consumer configuration + * @param maxMessages Maximum number of messages to read via the consumer + * @return The KeyValue elements retrieved via the consumer + */ + public static List> readKeyValues(String topic, Properties consumerConfig, int maxMessages) { + KafkaConsumer consumer = new KafkaConsumer<>(consumerConfig); + List> consumedValues = new ArrayList<>(); + try { + consumer.subscribe(Collections.singletonList(topic)); + int pollIntervalMs = Constants.THREAD_SLEEP_TIME_100; + int maxTotalPollTimeMs = Constants.THREAD_SLEEP_TIME_4000; + int totalPollTimeMs = 0; + while (totalPollTimeMs < maxTotalPollTimeMs && continueConsuming(consumedValues.size(), maxMessages)) { + totalPollTimeMs += pollIntervalMs; + ConsumerRecords records = consumer.poll(Duration.ofMillis(pollIntervalMs)); + for (ConsumerRecord consumerRecord : records) { + consumedValues.add(new KeyValue<>(consumerRecord.key(), consumerRecord.value())); + } + } + } finally { + consumer.close(); + } + return consumedValues; + } + + /** + * readKeyValuesWithHeaders(). + * + * @param the key type + * @param the value type + * @param topic topic + * @param consumerConfig consumerConfig + * @param maxMessages maxMessages + * @return the list + */ + public static List> readKeyValuesWithHeaders( + String topic, Properties consumerConfig, int maxMessages) { + KafkaConsumer consumer = new KafkaConsumer<>(consumerConfig); + List> consumerRecords = new ArrayList<>(); + try { + consumer.subscribe(Collections.singletonList(topic)); + int pollIntervalMs = Constants.THREAD_SLEEP_TIME_100; + int maxTotalPollTimeMs = Constants.THREAD_SLEEP_TIME_4000; + int totalPollTimeMs = 0; + while (totalPollTimeMs < maxTotalPollTimeMs && continueConsuming(consumerRecords.size(), maxMessages)) { + totalPollTimeMs += pollIntervalMs; + ConsumerRecords records = consumer.poll(Duration.ofMillis(pollIntervalMs)); + for (ConsumerRecord kvConsumerRecord : records) { + consumerRecords.add(kvConsumerRecord); + } + } + } finally { + consumer.close(); + } + return consumerRecords; + } + + /** + * Continue consuming. + * + * @param messagesConsumed the messages consumed + * @param maxMessages the max messages + * @return true, if successful + */ + private static boolean continueConsuming(int messagesConsumed, int maxMessages) { + return maxMessages <= 0 || messagesConsumed < maxMessages; + } + + /** + * Removes local state stores. Useful to reset state in-between integration test runs. + * + * @param streamsConfiguration Streams configuration settings + * @throws IOException Signals that an I/O exception has occurred. + */ + public static void purgeLocalStreamsState(Properties streamsConfiguration) throws IOException { + String path = streamsConfiguration.getProperty(StreamsConfig.STATE_DIR_CONFIG); + if (path != null) { + File node = Paths.get(path).normalize().toFile(); + // Only purge state when it's under /tmp. This is a safety net to + // prevent accidentally + // deleting important local directory trees. + if (node.getAbsolutePath().startsWith("/tmp")) { + Utils.delete(new File(node.getAbsolutePath())); + } + } + } + + /** + * produceKeyValuesSynchronously(). + * + * @param Key type of the data records + * @param Value type of the data records + * @param topic Kafka topic to write the data records to + * @param records Data records to write to Kafka + * @param producerConfig Kafka producer configuration + * @throws ExecutionException the execution exception + * @throws InterruptedException the interrupted exception + */ + public static void produceKeyValuesSynchronously( + String topic, Collection> records, Properties producerConfig) + throws ExecutionException, InterruptedException { + try (Producer producer = new KafkaProducer<>(producerConfig)) { + for (KeyValue keyValue : records) { + Future f = producer.send( + new ProducerRecord<>(topic, keyValue.key, keyValue.value)); + f.get(); + } + producer.flush(); + } + } + + /** + * produceKeyValuesSynchronouslyWithHeaders(). + * + * @param the key type + * @param the value type + * @param topic topic + * @param records records + * @param producerConfig producerConfig + * @param kafkaHeader kafkaHeader + * @throws ExecutionException ExecutionException + * @throws InterruptedException InterruptedException + */ + public static void produceKeyValuesSynchronouslyWithHeaders( + String topic, Collection> records, Properties producerConfig, List
    kafkaHeader) + throws ExecutionException, InterruptedException { + + try (Producer producer = new KafkaProducer<>(producerConfig)) { + for (KeyValue keyValue : records) { + Future f = producer.send( + new ProducerRecord<>(topic, null, keyValue.key, keyValue.value, kafkaHeader)); + f.get(); + } + producer.flush(); + } + } + + /** + * produceValuesSynchronously(). + * + * @param the value type + * @param topic topic + * @param records records + * @param producerConfig producerConfig + * @throws ExecutionException ExecutionException + * @throws InterruptedException InterruptedException + */ + public static void produceValuesSynchronously( + String topic, Collection records, Properties producerConfig) + throws ExecutionException, InterruptedException { + Collection> keyedRecords = new ArrayList<>(); + for (V value : records) { + KeyValue kv = new KeyValue<>(null, value); + keyedRecords.add(kv); + } + produceKeyValuesSynchronously(topic, keyedRecords, producerConfig); + } + + /** + * readMessages(). + * + * @param topic topic + * @param consumerProps consumerProps + * @param i i + * @return List + */ + public static List readMessages(String topic, Properties consumerProps, int i) { + return KafkaTestUtils.readKeyValues(topic, consumerProps, i).stream() + .map(t -> new String[] { (String) t.key, (String) t.value }).toList(); + } + + /** + * Read messages with headers. + * + * @param the key type + * @param the value type + * @param topic the topic + * @param consumerProps the consumer props + * @param i the i + * @return the list + */ + public static List> readMessagesWithHeaders( + String topic, Properties consumerProps, int i) { + return KafkaTestUtils.readKeyValuesWithHeaders(topic, consumerProps, i); + } + + /**. + * sendMessages(). + * + * @param topic topic + * @param producerProps producerProps + * @param strings strings + * @throws ExecutionException ExecutionException + * @throws InterruptedException InterruptedException + */ + public static void sendMessages(String topic, Properties producerProps, String... strings) + throws ExecutionException, InterruptedException { + Collection> kvs = new ArrayList<>(); + for (int i = 1; i <= strings.length; i++) { + if (i % Constants.TWO == 0) { + kvs.add(new KeyValue(strings[i - Constants.TWO], strings[i - 1])); + } + } + KafkaTestUtils.produceKeyValuesSynchronously(topic, kvs, producerProps); + } + + /** + * sendMessages(). + * + * @param the key type + * @param the value type + * @param topic topic + * @param producerProps producerProps + * @param key key + * @param value value + * @throws ExecutionException ExecutionException + * @throws InterruptedException InterruptedException + */ + public static void sendMessages(String topic, Properties producerProps, K key, V value) + throws ExecutionException, InterruptedException { + Collection> kvs = new ArrayList<>(); + kvs.add(new KeyValue<>(key, value)); + KafkaTestUtils.produceKeyValuesSynchronously(topic, kvs, producerProps); + } + + /** + * sendMessages(). + * + * @param topic topic + * @param producerProps producerProps + * @param bytes bytes + * @throws ExecutionException ExecutionException + * @throws InterruptedException InterruptedException + */ + public static void sendMessages(String topic, Properties producerProps, List bytes) + throws ExecutionException, InterruptedException { + Collection> kvs = new ArrayList<>(); + for (int i = 1; i <= bytes.size(); i++) { + if (i % Constants.TWO == 0) { + kvs.add(new KeyValue<>(bytes.get(i - Constants.TWO), bytes.get(i - 1))); + } + } + KafkaTestUtils.produceKeyValuesSynchronously(topic, kvs, producerProps); + } + + /** + * getMessages(). + * + * @param topic topic + * @param consumerProps consumerProps + * @param n n + * @param waitTime waitTime + * @return List + * @throws InterruptedException InterruptedException + */ + public static List getMessages(String topic, Properties consumerProps, int n, int waitTime) + throws InterruptedException { + int timeWaited = 0; + int increment = Constants.THREAD_SLEEP_TIME_500; + List messages = new ArrayList<>(); + while ((messages.size() < n) && (timeWaited <= waitTime)) { + messages.addAll(KafkaTestUtils.readMessages(topic, consumerProps, n)); + Thread.sleep(increment); + timeWaited = timeWaited + increment; + } + return messages; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttConfig.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttConfig.java new file mode 100644 index 0000000..bfbdcf9 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttConfig.java @@ -0,0 +1,342 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + + +/** + * Class for all the configurations for an MqttClient. + */ +public class MqttConfig { + + /** The mqtt user name. */ + private String mqttUserName; + + /** The mqtt user password. */ + private String mqttUserPassword; + + /** The clean session. */ + private boolean cleanSession; + + /** The keep alive interval. */ + private int keepAliveInterval; + + /** The max inflight. */ + private int maxInflight; + + /** The broker url. */ + private String brokerUrl; + + /** The mqtt broker port. */ + private int mqttBrokerPort; + + /** The mqtt timeout in millis. */ + private int mqttTimeoutInMillis; + + /** The mqtt qos value. */ + private int mqttQosValue; + + /** The mqtt client auth mechanism. */ + private String mqttClientAuthMechanism; + + /** The mqtt service trust store path. */ + private String mqttServiceTrustStorePath; + + /** The mqtt service trust store password. */ + private String mqttServiceTrustStorePassword; + + /** The mqtt service trust store type. */ + private String mqttServiceTrustStoreType; + + /** + * Gets the mqtt user name. + * + * @return the mqtt user name + */ + public String getMqttUserName() { + return mqttUserName; + } + + /** + * Sets the mqtt user name. + * + * @param mqttUserName the new mqtt user name + */ + public void setMqttUserName(String mqttUserName) { + this.mqttUserName = mqttUserName; + } + + /** + * Gets the mqtt user password. + * + * @return the mqtt user password + */ + public String getMqttUserPassword() { + return mqttUserPassword; + } + + /** + * Sets the mqtt user password. + * + * @param mqttUserPassword the new mqtt user password + */ + public void setMqttUserPassword(String mqttUserPassword) { + this.mqttUserPassword = mqttUserPassword; + } + + /** + * Checks if is clean session. + * + * @return true, if is clean session + */ + public boolean isCleanSession() { + return cleanSession; + } + + /** + * Sets the clean session. + * + * @param cleanSession the new clean session + */ + public void setCleanSession(boolean cleanSession) { + this.cleanSession = cleanSession; + } + + /** + * Gets the keep alive interval. + * + * @return the keep alive interval + */ + public int getKeepAliveInterval() { + return keepAliveInterval; + } + + /** + * Sets the keep alive interval. + * + * @param keepAliveInterval the new keep alive interval + */ + public void setKeepAliveInterval(int keepAliveInterval) { + this.keepAliveInterval = keepAliveInterval; + } + + /** + * Gets the max inflight. + * + * @return the max inflight + */ + public int getMaxInflight() { + return maxInflight; + } + + /** + * Sets the max inflight. + * + * @param maxInflight the new max inflight + */ + public void setMaxInflight(int maxInflight) { + this.maxInflight = maxInflight; + } + + /** + * Gets the broker url. + * + * @return the broker url + */ + public String getBrokerUrl() { + return brokerUrl; + } + + /** + * Sets the broker url. + * + * @param brokerUrl the new broker url + */ + public void setBrokerUrl(String brokerUrl) { + this.brokerUrl = brokerUrl; + } + + /** + * Gets the mqtt broker port. + * + * @return the mqtt broker port + */ + public int getMqttBrokerPort() { + return mqttBrokerPort; + } + + /** + * Sets the mqtt broker port. + * + * @param mqttBrokerPort the new mqtt broker port + */ + public void setMqttBrokerPort(int mqttBrokerPort) { + this.mqttBrokerPort = mqttBrokerPort; + } + + /** + * Gets the mqtt timeout in millis. + * + * @return the mqtt timeout in millis + */ + public int getMqttTimeoutInMillis() { + return mqttTimeoutInMillis; + } + + /** + * Sets the mqtt timeout in millis. + * + * @param mqttTimeoutInMillis the new mqtt timeout in millis + */ + public void setMqttTimeoutInMillis(int mqttTimeoutInMillis) { + this.mqttTimeoutInMillis = mqttTimeoutInMillis; + } + + /** + * Gets the mqtt qos value. + * + * @return the mqtt qos value + */ + public int getMqttQosValue() { + return mqttQosValue; + } + + /** + * Sets the mqtt qos value. + * + * @param mqttQosValue the new mqtt qos value + */ + public void setMqttQosValue(int mqttQosValue) { + this.mqttQosValue = mqttQosValue; + } + + /** + * Gets the mqtt client auth mechanism. + * + * @return the mqtt client auth mechanism + */ + public String getMqttClientAuthMechanism() { + return mqttClientAuthMechanism; + } + + /** + * Sets the mqtt client auth mechanism. + * + * @param mqttClientAuthMechanism the new mqtt client auth mechanism + */ + public void setMqttClientAuthMechanism(String mqttClientAuthMechanism) { + this.mqttClientAuthMechanism = mqttClientAuthMechanism; + } + + /** + * Gets the mqtt service trust store path. + * + * @return the mqtt service trust store path + */ + public String getMqttServiceTrustStorePath() { + return mqttServiceTrustStorePath; + } + + /** + * Sets the mqtt service trust store path. + * + * @param mqttServiceTrustStorePath the new mqtt service trust store path + */ + public void setMqttServiceTrustStorePath(String mqttServiceTrustStorePath) { + this.mqttServiceTrustStorePath = mqttServiceTrustStorePath; + } + + /** + * Gets the mqtt service trust store password. + * + * @return the mqtt service trust store password + */ + public String getMqttServiceTrustStorePassword() { + return mqttServiceTrustStorePassword; + } + + /** + * Sets the mqtt service trust store password. + * + * @param mqttServiceTrustStorePassword the new mqtt service trust store password + */ + public void setMqttServiceTrustStorePassword(String mqttServiceTrustStorePassword) { + this.mqttServiceTrustStorePassword = mqttServiceTrustStorePassword; + } + + /** + * Gets the mqtt service trust store type. + * + * @return the mqtt service trust store type + */ + public String getMqttServiceTrustStoreType() { + return mqttServiceTrustStoreType; + } + + /** + * Sets the mqtt service trust store type. + * + * @param mqttServiceTrustStoreType the new mqtt service trust store type + */ + public void setMqttServiceTrustStoreType(String mqttServiceTrustStoreType) { + this.mqttServiceTrustStoreType = mqttServiceTrustStoreType; + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return "MqttConfig{" + + "mqttUserName='" + mqttUserName + '\'' + + ", cleanSession=" + cleanSession + + ", keepAliveInterval=" + keepAliveInterval + + ", maxInflight=" + maxInflight + + ", brokerUrl='" + brokerUrl + '\'' + + ", mqttBrokerPort=" + mqttBrokerPort + + ", mqttTimeoutInMillis=" + mqttTimeoutInMillis + + ", mqttQosValue=" + mqttQosValue + + ", mqttClientAuthMechanism='" + mqttClientAuthMechanism + '\'' + + ", mqttServiceTrustStorePath='" + mqttServiceTrustStorePath + '\'' + + ", mqttServiceTrustStoreType='" + mqttServiceTrustStoreType + '\'' + + '}'; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcher.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcher.java new file mode 100644 index 0000000..f0b06d9 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcher.java @@ -0,0 +1,751 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamBaseConstant; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.platform.MqttTopicNameGenerator; +import org.eclipse.ecsp.domain.AbstractBlobEventData.Encoding; +import org.eclipse.ecsp.domain.BlobDataV1_0; +import org.eclipse.ecsp.domain.DeviceMessageFailureEventDataV1_0; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.IgniteEventSource; +import org.eclipse.ecsp.entities.IgniteBlobEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageErrorCode; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.serializer.IngestionSerializer; +import org.eclipse.ecsp.stream.dma.handler.DeviceMessageHandler; +import org.eclipse.ecsp.stream.dma.handler.DeviceMessageUtils; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.eclipse.ecsp.utils.metrics.IgniteErrorCounter; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Scope; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + + +/** + * Class that dispatches data to Mqtt topic. + */ +@Component +@Scope("prototype") +@ConditionalOnProperty(name = PropertyNames.DMA_ENABLED, havingValue = "true") +public abstract class MqttDispatcher implements Dispatcher, DeviceMessage> { + + /** The Constant THRESHOLD. */ + protected static final int THRESHOLD = (Integer.MAX_VALUE - 2); + // RTC-155383 - Running Kafka and Zookeeper on dynamic ports to resolve bind + // address issue in streambase project while running test cases + /** The overridden mqtt broker url. */ + // This url will be used while running test cases + protected static String overriddenMqttBrokerUrl = null; + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(MqttDispatcher.class); + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** The health monitor. */ + @Autowired + protected MqttHealthMonitor healthMonitor; + + /** The broker url. */ + // mqtt broker url + @Value("${" + PropertyNames.MQTT_BROKER_URL + "}") + protected String brokerUrl; + + /** The mqtt qos value. */ + // QoS = Quality of Service + @Value("${" + PropertyNames.MQTT_CONFIG_QOS + "}") + protected int mqttQosValue; + + /** The mqtt client auth mechanism. */ + //client auth mechanism + @Value("${" + PropertyNames.MQTT_CLIENT_AUTH_MECHANISM + "}") + protected String mqttClientAuthMechanism; + + /** The user name. */ + // mqtt user name + @Value("${" + PropertyNames.MQTT_USER_NAME + "}") + protected String userName; + + /** The password. */ + // mqtt password + @Value("${" + PropertyNames.MQTT_USER_PASSWORD + "}") + protected String password; + + /** The max inflight. */ + // mqtt maxInflight + @Value("${" + PropertyNames.MQTT_MAX_INFLIGHT + "}") + protected int maxInflight; + + /** The retry count. */ + @Value("${" + PropertyNames.MQTT_CONNECTION_RETRY_COUNT + ":3}") + protected int retryCount; + + /** The mqtt timeout in millis. */ + @Value("${" + PropertyNames.MQTT_TIMEOUT_IN_MILLIS + ":60000}") + protected int mqttTimeoutInMillis; + + /** The retry interval. */ + @Value("${" + PropertyNames.MQTT_CONNECTION_RETRY_INTERVAL + ":1000}") + protected long retryInterval; + + /** The ignite serialization impl. */ + @Value("${" + PropertyNames.INGESTION_SERIALIZER_CLASS + + ":org.eclipse.ecsp.serializer.IngestionSerializerFstImpl}") + protected String igniteSerializationImpl; + + /** The wrap dispatch event. */ + // Below properties are for tracing + @Value("${" + PropertyNames.WRAP_DISPATCH_EVENT + ":false}") + protected boolean wrapDispatchEvent; + + /** The event wrap frequency. */ + @Value("${" + PropertyNames.EVENT_WRAP_FREQUENCY + ":10}") + protected int eventWrapFrequency; + + /** The mqtt service trust store path. */ + @Value("${" + PropertyNames.MQTT_SERVICE_TRUSTSTORE_PATH + "}") + protected String mqttServiceTrustStorePath; + + /** The mqtt service trust store password. */ + @Value("${" + PropertyNames.MQTT_SERVICE_TRUSTSTORE_PASSWORD + "}") + protected String mqttServiceTrustStorePassword; + + /** The mqtt service trust store type. */ + @Value("${" + PropertyNames.MQTT_SERVICE_TRUSTSTORE_TYPE + "}") + protected String mqttServiceTrustStoreType; + + /** The pod name. */ + /* + * Below property's value will be used to set the mqtt client ID connecting + * to HiveMQ. So that while debugging any issue, we get to know which pod + * connected to or disconnected from HiveMQ. + * + * RDNG: 170796 + */ + @Value("${HOSTNAME:localhost}") + protected String podName; + + /** The mqtt broker port. */ + @Value("${" + PropertyNames.MQTT_BROKER_PORT + ":1883}") + protected int mqttBrokerPort; + + /** The keep alive interval. */ + @Value("${" + PropertyNames.MQTT_KEEP_ALIVE_INTERVAL + ":120}") + protected int keepAliveInterval; + + /** The event dispatch counter. */ + protected AtomicInteger eventDispatchCounter = new AtomicInteger(1); + + /** The mqtt topic name service impl. */ + @Value("${" + PropertyNames.MQTT_TOPIC_GENERATOR_SERVICE_IMPL_CLASS_NAME + ":" + + PropertyNames.DEFAULT_TOPIC_NAME_GENERATOR_IMPL + "}") + private String mqttTopicNameServiceImpl; + + /** The device message utils. */ + @Autowired + private DeviceMessageUtils deviceMessageUtils; + + /** The mqtt topic name generator. */ + @Autowired + private MqttTopicNameGenerator mqttTopicNameGenerator; + + /** The healthy. */ + protected volatile boolean healthy; + + /** The transformer. */ + protected IngestionSerializer transformer; + + /** The global broadcast retention topic list. */ + @Value("#{'${" + PropertyNames.MQTT_GLOBAL_BROADCAST_RETENTION_TOPICS + "}'.split(',')}") + protected List globalBroadcastRetentionTopicList; + + /** The clean session. */ + @Value("${" + PropertyNames.MQTT_CLEAN_SESSION + ":false}") + protected boolean cleanSession; + + /** The error counter. */ + @Autowired + protected IgniteErrorCounter errorCounter; + + /** The task id. */ + protected String taskId; + + /** The mqtt broker platform id mapping. */ + @Value("#{${mqtt.broker.platformId.mapping: {:} }}") + protected Map mqttBrokerPlatformIdMapping; + + /** The mqtt platform config map. */ + protected Map mqttPlatformConfigMap; + + /** The environment. */ + @Autowired + private Environment environment; + + /** The dma post dispatch handler. */ + private DeviceMessageHandler dmaPostDispatchHandler; + + /** The forced check value. */ + private DeviceMessage forcedCheckValue; + + /** The forced check key. */ + private IgniteStringKey forcedCheckKey; + + /** + * Method to verify if all the required properties are valid, and not null + * or empty. + */ + @PostConstruct + private void verifyAndDumpMqttProperties() { + ObjectUtils.requireNonEmpty(brokerUrl, "Broker url shouldn't be null or empty."); + ObjectUtils.requireNonNegative(mqttQosValue, "Mqtt Qos (Quality of Service) value cannot be negative."); + ObjectUtils.requireNonEmpty(userName, "Username shouldn't be null or empty."); + ObjectUtils.requireNonEmpty(password, "Password shouldn't be null or empty."); + + if (StreamBaseConstant.DMA_ONE_WAY_TLS_AUTH_MECHANISM.equalsIgnoreCase(mqttClientAuthMechanism)) { + ObjectUtils.requireNonEmpty(mqttServiceTrustStorePath, Constants.MQTT_CLIENT_AS_ONE_WAY_TLS + + " mqttServiceTrustStorePath" + Constants.CANNOT_BE_NULL_ERROR_MSG); + ObjectUtils.requireNonEmpty(mqttServiceTrustStorePassword, Constants.MQTT_CLIENT_AS_ONE_WAY_TLS + + " mqttServiceTrustStorePassword" + Constants.CANNOT_BE_NULL_ERROR_MSG); + ObjectUtils.requireNonEmpty(mqttServiceTrustStoreType, Constants.MQTT_CLIENT_AS_ONE_WAY_TLS + + " mqttServiceTrustStoreType" + Constants.CANNOT_BE_NULL_ERROR_MSG); + } + + validateEventWrapFrequency(); + // RTC-155383 - Running Kafka and Zookeeper on dynamic ports to resolve + // bind address issue in streambase project while running test cases + if (null != overriddenMqttBrokerUrl) { + brokerUrl = overriddenMqttBrokerUrl; + } + logger.debug("Mqtt clean Session value: {}", cleanSession); + logger.info("Mqtt Broker url:{}, QoS:{}, userName:{}, mqttClientAuthMechanism:{}", + brokerUrl, mqttQosValue, userName, mqttClientAuthMechanism); + logger.info("Global Broadcast Topic List configured for retaining messages : {}", + globalBroadcastRetentionTopicList); + } + + /** + * Validate event wrap frequency. + */ + void validateEventWrapFrequency() { + if (eventWrapFrequency < 1) { + throw new IllegalArgumentException("Value for property event.wrap.frequency should be greater than 0"); + } + } + + /** + * Dispatch. + * + * @param key the key + * @param entity the entity + */ + @Override + public void dispatch(IgniteKey key, DeviceMessage entity) { + if (invalidKeyOrValue(key, entity)) { + return; + } + String platform = (StringUtils.isBlank(entity.getEvent().getPlatformId()) ? PropertyNames.DEFAULT_PLATFORMID + : entity.getEvent().getPlatformId()); + logger.debug("Dispatching key:{} and value:{} to MQTT with platformID : {}", + key.toString(), entity.toString(), platform); + createMqttClient(platform); + DeviceMessageHeader header = entity.getDeviceMessageHeader(); + Optional mqttTopicNameOpt = mqttTopicNameGenerator.getMqttTopicName(key, header, + entity.getEvent().getEventId()); + if (!mqttTopicNameOpt.isPresent()) { + logger.error("Received Empty topic name. Not proceeding further."); + return; + } + String mqttTopicName = mqttTopicNameOpt.get(); + logger.info("Mqtt Topic Name fetched from " + mqttTopicNameServiceImpl + " class is " + mqttTopicName); + + /* + * DataPlatform 101-HCP-12088, All the events going to Mqtt are + * whitelisted and Mqtt Topic is vehicleId/eventId + * + * Later per vehicleID we will see whether this vehicleID has subscribed + * to this event or not + */ + byte[] payLoad = entity.getMessage(); + if (wrapDispatchEvent && (eventDispatchCounter.getAndIncrement() % eventWrapFrequency == 0)) { + setMqttMessagePayload(transformToIgniteBlobEvent(payLoad, header.getTargetDeviceId(), + header.getVehicleId(), header.getRequestId())); + } else { + setMqttMessagePayload(payLoad); + } + eventDispatchCounter.compareAndSet(THRESHOLD, 0); + boolean isRetainedMessage = (null != globalBroadcastRetentionTopicList) + && !globalBroadcastRetentionTopicList.isEmpty() + && (header.isGlobalTopicNameProvided()) && (globalBroadcastRetentionTopicList.contains(mqttTopicName)); + try { + publishMessageToMqttTopic(mqttTopicName, isRetainedMessage, platform); + logger.info("Successfully published the event : {}, to the mqtt topic : {}, " + + "with retained flag as {}, for platformID {}", entity.getEvent(), + mqttTopicName, isRetainedMessage, platform); + healthy = true; + if (null != dmaPostDispatchHandler) { + dmaPostDispatchHandler.handle(key, entity); + } + } catch (Exception e) { + if (header.isGlobalTopicNameProvided()) { + DeviceMessageFailureEventDataV1_0 failEventData = new DeviceMessageFailureEventDataV1_0(); + failEventData.setFailedIgniteEvent(entity.getEvent()); + failEventData.setErrorCode(DeviceMessageErrorCode.MQTT_DISPATCH_FAILED); + deviceMessageUtils.postFailureEvent(failEventData, key, spc, entity.getFeedBackTopic()); + } + errorCounter.incErrorCounter(Optional.ofNullable(taskId), e.getClass()); + logger.error("Unable to push the event:{} to the mqtt topic:{} for platform:{}, with exception: {}", + entity.toString(), mqttTopicName, platform, e); + /* + * DataPlatform 101-HCP-12088, We will not do any retry if + * exception is thrown. + * + * Future: We will retry sending the event for a configurable amount + * of time before bailing out + */ + healthy = false; + closeMqttConnection(platform); + } + } + + /** + * Invalid key or value. + * + * @param key the key + * @param entity the entity + * @return true, if successful + */ + private boolean invalidKeyOrValue(IgniteKey key, DeviceMessage entity) { + if (null == key) { + logger.error("Null key received. Data will not be processed further."); + return true; + } + if (null == entity) { + logger.error("Null value received. Data will not be processed further ."); + return true; + } + return false; + } + + /** + * Publish message to mqtt topic. + * + * @param mqttTopicName the mqtt topic name + * @param isRetainedMessage the is retained message + * @param platform the platform + * @throws MqttException the mqtt exception + */ + protected abstract void publishMessageToMqttTopic(String mqttTopicName, + boolean isRetainedMessage, String platform) throws MqttException; + + /** + * Sets the mqtt message payload. + * + * @param payload the new mqtt message payload + */ + protected abstract void setMqttMessagePayload(byte[] payload); + + /** + * Creates the mqtt client. + * + * @param platform the platform + */ + protected abstract void createMqttClient(String platform); + + /** + * Close mqtt connections. + */ + protected abstract void closeMqttConnections(); + + /** + * Close mqtt connection. + * + * @param platform the platform + */ + protected abstract void closeMqttConnection(String platform); + + /** + * Close. + */ + public void close() { + closeMqttConnections(); + } + + /** + * Transform to ignite blob event. + * + * @param payload the payload + * @param deviceId the device id + * @param vehicleId the vehicle id + * @param requestId the request id + * @return the byte[] + */ + protected byte[] transformToIgniteBlobEvent(byte[] payload, String deviceId, + String vehicleId, String requestId) { + IgniteBlobEvent igniteBlobEvent = new IgniteBlobEvent(); + igniteBlobEvent.setTimestamp(System.currentTimeMillis()); + igniteBlobEvent.setSourceDeviceId(deviceId); + igniteBlobEvent.setVehicleId(vehicleId); + igniteBlobEvent.setVersion(org.eclipse.ecsp.domain.Version.V1_0); + igniteBlobEvent.setEventId(EventID.BLOBDATA); + igniteBlobEvent.setRequestId(requestId); + BlobDataV1_0 blobData = new BlobDataV1_0(); + blobData.setEventSource(IgniteEventSource.IGNITE); + blobData.setEncoding(Encoding.JSON); + blobData.setPayload(payload); + igniteBlobEvent.setEventData(blobData); + return transformer.serialize(igniteBlobEvent); + } + + /** + * Sets the up. + * + * @param taskId the new up + */ + public void setup(String taskId) { + this.taskId = taskId; + } + + /** + * isHealthy(). + * + * @param forceHealthCheck + * forceHealthCheck + * @return boolean + */ + public boolean isHealthy(boolean forceHealthCheck) { + + if (forceHealthCheck) { + dispatch(forcedCheckKey, forcedCheckValue); + } + return healthy; + } + + /** + * initializeForcedHealthCheckEvent(). + */ + public void initializeForcedHealthCheckEvent() { + forcedCheckKey = new IgniteStringKey(); + forcedCheckKey.setKey(Constants.FORCED_HEALTH_CHECK_DEVICE_ID); + ForcedHealthCheckEvent forcedHealthCheckEvent = new ForcedHealthCheckEvent(); + forcedHealthCheckEvent.setDevMsgTopicSuffix(Constants.FORCED_HEALTH_DEFAULT_TEST_TOPIC_NAME); + forcedHealthCheckEvent.setTargetDeviceId(Constants.FORCED_HEALTH_CHECK_DEVICE_ID); + forcedCheckValue = new DeviceMessage(); + forcedCheckValue.setMessage("forcedHealthCheckDummyMsg".getBytes()); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId(Constants.FORCED_HEALTH_CHECK_DEVICE_ID); + header.withDevMsgTopicSuffix(Constants.FORCED_HEALTH_DEFAULT_TEST_TOPIC_NAME); + forcedCheckValue.setEvent(forcedHealthCheckEvent); + forcedCheckValue.setDeviceMessageHeader(header); + } + + /** + * Sets the mqtt config for default platform id. + * + * @param mqttConfig the new mqtt config for default platform id + */ + private void setMqttConfigForDefaultPlatformId(MqttConfig mqttConfig) { + mqttConfig.setMqttUserName(userName); + mqttConfig.setMqttUserPassword(password); + mqttConfig.setBrokerUrl(brokerUrl); + mqttConfig.setMqttBrokerPort(mqttBrokerPort); + mqttConfig.setMqttQosValue(mqttQosValue); + mqttConfig.setMaxInflight(maxInflight); + mqttConfig.setMqttTimeoutInMillis(mqttTimeoutInMillis); + mqttConfig.setKeepAliveInterval(keepAliveInterval); + mqttConfig.setCleanSession(cleanSession); + mqttConfig.setMqttClientAuthMechanism(mqttClientAuthMechanism); + if (StreamBaseConstant.DMA_ONE_WAY_TLS_AUTH_MECHANISM.equalsIgnoreCase(mqttClientAuthMechanism)) { + mqttConfig.setMqttServiceTrustStorePath(mqttServiceTrustStorePath); + mqttConfig.setMqttServiceTrustStorePassword(mqttServiceTrustStorePassword); + mqttConfig.setMqttServiceTrustStoreType(mqttServiceTrustStoreType); + } + } + + /** + * Validate ssl configs. + * + * @param mqttServiceTrustStorePasswordPlatform the mqtt service trust store password platform + * @param mqttServiceTrustStorePathPlatform the mqtt service trust store path platform + * @param mqttServiceTrustStoreTypePlatform the mqtt service trust store type platform + * @param platform the platform + */ + private void validateSslConfigs(String mqttServiceTrustStorePasswordPlatform, + String mqttServiceTrustStorePathPlatform, String mqttServiceTrustStoreTypePlatform, + String platform) { + ObjectUtils.requireNonEmpty(mqttServiceTrustStorePasswordPlatform, + "MQTT Truststore password for a given platformID: " + platform + + Constants.CANNOT_BE_NULL_ERROR_MSG); + ObjectUtils.requireNonEmpty(mqttServiceTrustStorePathPlatform, + "MQTT Truststore path for a given platformID: " + platform + + Constants.CANNOT_BE_NULL_ERROR_MSG); + ObjectUtils.requireNonEmpty(mqttServiceTrustStoreTypePlatform, + "MQTT TrustStore type for a given platformID: " + platform + + Constants.CANNOT_BE_NULL_ERROR_MSG); + } + + /** + * Validate mqtt configs. + * + * @param platform the platform + * @param mqttBrokerUrl the mqtt broker url + * @param mqttUserName the mqtt user name + * @param mqttPass the mqtt pass + */ + private void validateMqttConfigs(String platform, String mqttBrokerUrl, String mqttUserName, String mqttPass) { + ObjectUtils.requireNonEmpty(mqttBrokerUrl, + "MQTT broker url for a given platformID: " + platform + Constants.CANNOT_BE_NULL_ERROR_MSG); + ObjectUtils.requireNonEmpty(mqttUserName, + "MQTT username for a given platformID: " + platform + Constants.CANNOT_BE_NULL_ERROR_MSG); + ObjectUtils.requireNonEmpty(mqttPass, + "MQTT password for a given platformID: " + platform + Constants.CANNOT_BE_NULL_ERROR_MSG); + } + + /** + * Sets the mqtt properties. + * + * @param mqttConfig the mqtt config + * @param mqttUserName the mqtt user name + * @param mqttPass the mqtt pass + * @param mqttBrokerUrl the mqtt broker url + */ + private void setMqttProperties(MqttConfig mqttConfig, String mqttUserName, String mqttPass, String mqttBrokerUrl) { + mqttConfig.setMqttUserName(mqttUserName); + mqttConfig.setMqttUserPassword(mqttPass); + mqttConfig.setBrokerUrl(mqttBrokerUrl); + } + + /** + * Gets the mqtt config for broker. + * + * @param platform the platform + * @param mqttBroker the mqtt broker + * @return the mqtt config for broker + */ + protected MqttConfig getMqttConfigForBroker(String platform, String mqttBroker) { + MqttConfig mqttConfig = new MqttConfig(); + if (StringUtils.equalsIgnoreCase(platform, PropertyNames.DEFAULT_PLATFORMID)) { + setMqttConfigForDefaultPlatformId(mqttConfig); + } else { + String mqttBrokerUrl = environment.getProperty(mqttBroker + PropertyNames.MQTT_BROKER_URL_SUFFIX); + String mqttUserName = environment.getProperty(mqttBroker + PropertyNames.MQTT_USER_NAME_SUFFIX); + String mqttPass = environment.getProperty(mqttBroker + PropertyNames.MQTT_USER_PASSWORD_SUFFIX); + String mqttClientAuthMechanismPlatform = environment.getProperty(mqttBroker + + PropertyNames.MQTT_CLIENT_AUTH_MECHANISM_SUFFIX, String.class); + mqttConfig.setMqttClientAuthMechanism(mqttClientAuthMechanismPlatform); + String mqttServiceTrustStorePasswordPlatform = null; + String mqttServiceTrustStorePathPlatform = null; + String mqttServiceTrustStoreTypePlatform = null; + + try { + if (StreamBaseConstant.DMA_ONE_WAY_TLS_AUTH_MECHANISM + .equalsIgnoreCase(mqttClientAuthMechanismPlatform)) { + mqttServiceTrustStorePasswordPlatform = environment.getProperty(mqttBroker + + PropertyNames.MQTT_SERVICE_TRUSTSTORE_PASSWORD_SUFFIX, String.class); + mqttServiceTrustStorePathPlatform = environment.getProperty(mqttBroker + + PropertyNames.MQTT_SERVICE_TRUSTSTORE_PATH_SUFFIX, String.class); + mqttServiceTrustStoreTypePlatform = environment.getProperty(mqttBroker + + PropertyNames.MQTT_SERVICE_TRUSTSTORE_TYPE_SUFFIX, String.class); + validateSslConfigs(mqttServiceTrustStorePasswordPlatform, mqttServiceTrustStorePathPlatform, + mqttServiceTrustStoreTypePlatform, platform); + } + validateMqttConfigs(platform, mqttBrokerUrl, mqttUserName, mqttPass); + } catch (Exception e) { + logger.error("Error occurred when verifying properties for platformID : " + platform, e); + return null; + } + + final Integer mqttBrokerPortPlatform = + environment.getProperty(mqttBroker + PropertyNames.MQTT_BROKER_PORT_SUFFIX, Integer.class); + final int mqttQosValuePlatform = environment.getProperty(mqttBroker + PropertyNames.MQTT_CONFIG_QOS_SUFFIX, + Integer.class, this.mqttQosValue); + final int mqttMaxInflight = environment.getProperty(mqttBroker + PropertyNames.MQTT_MAX_INFLIGHT_SUFFIX, + Integer.class, this.maxInflight); + final int mqttTimeoutInMilli = environment.getProperty(mqttBroker + + PropertyNames.MQTT_TIMEOUT_IN_MILLIS_SUFFIX, Integer.class, this.mqttTimeoutInMillis); + final int mqttKeepAliveInterval = environment.getProperty(mqttBroker + + PropertyNames.MQTT_KEEP_ALIVE_INTERVAL_SUFFIX, Integer.class, this.keepAliveInterval); + final boolean mqttCleanSession = environment.getProperty(mqttBroker + + PropertyNames.MQTT_CLEAN_SESSION_SUFFIX, Boolean.class, this.cleanSession); + setMqttProperties(mqttConfig, mqttUserName, mqttPass, mqttBrokerUrl); + if (mqttBrokerPortPlatform != null) { + mqttConfig.setMqttBrokerPort(mqttBrokerPortPlatform.intValue()); + } + mqttConfig.setMqttServiceTrustStorePassword(mqttServiceTrustStorePasswordPlatform); + mqttConfig.setMqttServiceTrustStorePath(mqttServiceTrustStorePathPlatform); + mqttConfig.setMqttServiceTrustStoreType(mqttServiceTrustStoreTypePlatform); + mqttConfig.setMqttQosValue(mqttQosValuePlatform); + mqttConfig.setMaxInflight(mqttMaxInflight); + mqttConfig.setMqttTimeoutInMillis(mqttTimeoutInMilli); + mqttConfig.setKeepAliveInterval(mqttKeepAliveInterval); + mqttConfig.setCleanSession(mqttCleanSession); + } + logger.info("Mqtt config created for platformId {} - {}", platform, mqttConfig); + mqttPlatformConfigMap.put(platform, mqttConfig); + return mqttConfig; + } + + /** + * Sets the mqtt connection opt. + * + * @param mqttPlatformId the mqtt platform id + * @param mqttConfig the mqtt config + */ + protected void setMqttConnectionOpt(String mqttPlatformId, MqttConfig mqttConfig) { + // + } + + /** + * Creates the mqtt config for platforms. + */ + protected void createMqttConfigForPlatforms() { + if (!mqttBrokerPlatformIdMapping.isEmpty()) { + for (Map.Entry entry : mqttBrokerPlatformIdMapping.entrySet()) { + String platform = entry.getKey(); + String mqttBroker = entry.getValue(); + logger.info("Processing properties for mqtt broker {}, against platformID {}", mqttBroker, platform); + if (StringUtils.isEmpty(platform) || StringUtils.isEmpty(mqttBroker)) { + logger.info("MQTT broker or platformID not set in mqtt.broker.platformId.mapping " + + "Client will not be created for mqtt broker : {} , platform : {}.", mqttBroker, platform); + continue; + } + MqttConfig mqttConfig = getMqttConfigForBroker(platform, mqttBroker); + if (null != mqttConfig) { + setMqttConnectionOpt(platform, mqttConfig); + } + } + } + } + + /** + * Creates the mqtt config for default platform. + */ + protected void createMqttConfigForDefaultPlatform() { + MqttConfig mqttConfig = getMqttConfigForBroker(PropertyNames.DEFAULT_PLATFORMID, null); + if (mqttConfig != null) { + setMqttConnectionOpt(PropertyNames.DEFAULT_PLATFORMID, mqttConfig); + } + } + + /** + * Getting Mqtt connection option object to create Mqtt client. Always use + * this method + * + * @param platform the platform + * @return MqttConfig instance with all the configurations set for the Mqtt Client. + */ + public Optional getMqttConfig(String platform) { + return Optional.ofNullable(mqttPlatformConfigMap.get(platform)); + } + + /** + * Sets the next handler. + * + * @param dmaPostDispatchHandler the new next handler + */ + public void setNextHandler(DeviceMessageHandler dmaPostDispatchHandler) { + this.dmaPostDispatchHandler = dmaPostDispatchHandler; + } + + /** + * Sets the stream processing context. + * + * @param ctx the ctx + */ + public void setStreamProcessingContext(StreamProcessingContext, IgniteEvent> ctx) { + this.spc = ctx; + } + + /** + * Sets the event wrap frequency. + * + * @param eventWrapFrequency the new event wrap frequency + */ + // Below setters are for testing + void setEventWrapFrequency(int eventWrapFrequency) { + this.eventWrapFrequency = eventWrapFrequency; + } + + /** + * Sets the wrap dispatch event. + * + * @param wrapDispatchEvent the new wrap dispatch event + */ + void setWrapDispatchEvent(boolean wrapDispatchEvent) { + this.wrapDispatchEvent = wrapDispatchEvent; + } + + /** + * Sets the transformer. + * + * @param transformer the new transformer + */ + void setTransformer(IngestionSerializer transformer) { + this.transformer = transformer; + } + + /** + * Sets the global broadcast retention topic list. + * + * @param globalBroadcastRetentionTopicList the new global broadcast retention topic list + */ + void setGlobalBroadcastRetentionTopicList(List globalBroadcastRetentionTopicList) { + this.globalBroadcastRetentionTopicList = globalBroadcastRetentionTopicList; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttHealthMonitor.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttHealthMonitor.java new file mode 100644 index 0000000..b0423be --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttHealthMonitor.java @@ -0,0 +1,157 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.healthcheck.HealthMonitor; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + + +/** + * class MqttHealthMonitor implements HealthMonitor. + */ +@Component +public class MqttHealthMonitor implements HealthMonitor { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(MqttHealthMonitor.class); + + /** The mqtt health monitor enabled. */ + @Value("${" + PropertyNames.HEALTH_MQTT_MONITOR_ENABLED + ": true }") + private boolean mqttHealthMonitorEnabled; + + /** The Constant HEALTH_MQTT_MONITOR_NAME. */ + public static final String HEALTH_MQTT_MONITOR_NAME = "MQTT_HEALTH_MONITOR"; + + /** The Constant HEALTH_MQTT_MONTIOR_GUAGE. */ + public static final String HEALTH_MQTT_MONTIOR_GUAGE = "MQTT_HEALTH_GUAGE"; + + /** The mqtt restart on failure. */ + @Value("${" + PropertyNames.HEALTH_MQTT_MONITOR_RESTART_ON_FAILURE + ": true }") + private boolean mqttRestartOnFailure; + + /** The dispatchers. */ + private List dispatchers = new ArrayList<>(); + + /** + * Checks if is healthy. + * + * @param forceHealthCheck the force health check + * @return true, if is healthy + */ + @Override + public boolean isHealthy(boolean forceHealthCheck) { + if (dispatchers.isEmpty()) { + logger.error("No mqtt dispatchers have been registered with MqttHealthMonitor. " + + "This error should be ignored if its part of initial health check."); + return false; + } + boolean healthy = true; + for (MqttDispatcher dispatcher : dispatchers) { + healthy = healthy && dispatcher.isHealthy(forceHealthCheck); + } + return healthy; + } + + /** + * Monitor name. + * + * @return the string + */ + @Override + public String monitorName() { + return HEALTH_MQTT_MONITOR_NAME; + } + + /** + * Needs restart on failure. + * + * @return true, if successful + */ + @Override + public boolean needsRestartOnFailure() { + return mqttRestartOnFailure; + } + + /** + * Metric name. + * + * @return the string + */ + @Override + public String metricName() { + return HEALTH_MQTT_MONTIOR_GUAGE; + } + + /** + * Checks if is enabled. + * + * @return true, if is enabled + */ + @Override + public boolean isEnabled() { + return mqttHealthMonitorEnabled; + } + + /** + * Gets the dispatchers. + * + * @return the dispatchers + */ + List getDispatchers() { + return dispatchers; + } + + /** + * Register. + * + * @param dispatcher the dispatcher + */ + public void register(MqttDispatcher dispatcher) { + dispatchers.add(dispatcher); + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/NoMqttClientFoundException.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/NoMqttClientFoundException.java new file mode 100644 index 0000000..216e113 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/NoMqttClientFoundException.java @@ -0,0 +1,70 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + + +/** + * Custom exception for MqttClient not found. + */ +public class NoMqttClientFoundException extends RuntimeException { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new no mqtt client found exception. + * + * @param msg the msg + */ + public NoMqttClientFoundException(String msg) { + super(msg); + } + + /** + * Instantiates a new no mqtt client found exception. + * + * @param msg the msg + * @param e the e + */ + public NoMqttClientFoundException(String msg, Exception e) { + super(msg, e); + } + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ObjectUtils.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ObjectUtils.java new file mode 100644 index 0000000..be3df94 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ObjectUtils.java @@ -0,0 +1,162 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.analytics.stream.base.exception.ObjectUtilsException; +import java.util.Collection; +import java.util.Objects; + + +/** + * {@link ObjectUtils}. + */ +@SuppressWarnings("checkstyle:HideUtilityClassConstructor") +public class ObjectUtils { + + /** + * requireNonEmpty(). + */ + protected ObjectUtils() { + + } + + /** + * Checks whether an object instance is null or not. + + * @param The type of the object. + * @param obj The object to apply the check on. + * @param errorMsg The error to throw if the object is found null. + * @return The object instance. + */ + public static T requireNonEmpty(T obj, String errorMsg) { + Objects.requireNonNull(obj, errorMsg); + if (obj instanceof String str && str.isEmpty()) { + throw new ObjectUtilsException(errorMsg); + } + return obj; + } + + /** + * requireSizeOf(). + * + * @param the generic type + * @param t t + * @param expectedSize expectedSize + * @param errorMsg errorMsg + * @return boolean + */ + public static boolean requireSizeOf(Collection t, int expectedSize, String errorMsg) { + if (t.size() != expectedSize) { + throw new ObjectUtilsException(errorMsg); + } + return true; + } + + /** + * Require non null. + * + * @param the generic type + * @param obj the obj + * @param errorMsg the error msg + * @return the t + */ + public static T requireNonNull(T obj, String errorMsg) { + return Objects.requireNonNull(obj, errorMsg); + } + + /** + * requireMinSize(). + * + * @param the generic type + * @param t Collection + * @param expectedSize expectedSize + * @param errorMsg errorMsg + * @return boolean + */ + public static boolean requireMinSize(Collection t, int expectedSize, String errorMsg) { + if (t.size() < expectedSize) { + throw new ObjectUtilsException(errorMsg); + } + return true; + } + + /** + * requiresNotNullAndNotEmpy(). + * + * @param the generic type + * @param t Collection + * @param errorMsg errorMsg + * @return boolean + */ + public static boolean requiresNotNullAndNotEmpy(Collection t, String errorMsg) { + Objects.requireNonNull(t, errorMsg); + if (t.isEmpty()) { + throw new ObjectUtilsException(errorMsg); + } + return true; + } + + /** + * to check if a integer number is negative or not. + * + * @param the generic type + * @param obj obj + * @param errorMessage errorMessage + * @return the t + */ + public static T requireNonNegative(T obj, String errorMessage) { + Objects.requireNonNull(obj, errorMessage); + Integer num = null; + if (obj instanceof Integer integer) { + num = integer; + } else if (obj instanceof String str) { + num = Integer.parseInt(str); + } else { + throw new ObjectUtilsException("Method requireNonNegative suppports only integers or strings."); + } + + if (num < 0) { + throw new ObjectUtilsException(errorMessage); + } + return obj; + + } + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/PahoMqttDispatcher.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/PahoMqttDispatcher.java new file mode 100644 index 0000000..c508b11 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/PahoMqttDispatcher.java @@ -0,0 +1,361 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import jakarta.annotation.PostConstruct; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamBaseConstant; +import org.eclipse.ecsp.analytics.stream.base.exception.DeviceMessagingMqttClientTrustStoreException; +import org.eclipse.ecsp.serializer.IngestionSerializerFactory; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManagerFactory; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + + + +/** + * class PahoMqttDispatcher extends MqttDispatcher. + */ +@ConditionalOnExpression( + "${" + PropertyNames.DMA_ENABLED + ":true} and !('${" + + PropertyNames.MQTT_CLIENT + "}'.equalsIgnoreCase('hivemq') )" +) +@Component +@Scope("prototype") +public class PahoMqttDispatcher extends MqttDispatcher { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(PahoMqttDispatcher.class); + + /** The mqtt message. */ + private MqttMessage mqttMessage; + + /** The mqtt client map. */ + private Map mqttClientMap; + + /** The mqtt conn opts. */ + protected Map mqttConnOpts; + + /** The Constant TLS_V1_2. */ + private static final String TLS_V1_2 = "TLSv1.2"; + + /** + * Inits the. + */ + @PostConstruct + private void init() { + mqttPlatformConfigMap = new ConcurrentHashMap<>(); + mqttConnOpts = new ConcurrentHashMap<>(); + mqttClientMap = new ConcurrentHashMap<>(); + logger.info("Property loaded for mqtt broker to platformId mapping {}", mqttBrokerPlatformIdMapping); + createMqttConfigForDefaultPlatform(); + createMqttConfigForPlatforms(); + initializeForcedHealthCheckEvent(); + healthMonitor.register(this); + transformer = IngestionSerializerFactory.getInstance(igniteSerializationImpl); + } + + /** + * Creates the mqtt client. + * + * @param platform the platform + */ + protected void createMqttClient(String platform) { + createPahoMqttClient(platform); + } + + /** + * Creates the paho mqtt client. + * + * @param platform the platform + */ + private void createPahoMqttClient(String platform) { + logger.debug("Fetching Paho MqttClient for platformID : {}", platform); + MqttClient client = mqttClientMap.get(platform); + if (client == null || !client.isConnected()) { + logger.warn("Initializing Paho MqttClient because it is null or not connected for platformID : {}", + platform); + Optional cl = getConnection(platform); + if (!cl.isPresent()) { + logger.error("Unable to establish connection to Mqtt broker with PAHO client for platformID : {}", + platform); + healthy = false; + errorCounter.incErrorCounter(Optional.ofNullable(taskId), MqttException.class); + } else { + client = cl.get(); + mqttClientMap.put(platform, client); + healthy = true; + } + } else { + logger.debug("Paho Client with ID : {} is already connected. " + + "Need not reconnect for platformID : {}", client.getClientId(), platform); + } + } + + /** + * Publish message to mqtt topic. + * + * @param mqttTopicName the mqtt topic name + * @param isRetainedMessage the is retained message + * @param platform the platform + * @throws MqttException the mqtt exception + */ + @Override + protected void publishMessageToMqttTopic(String mqttTopicName, boolean isRetainedMessage, String platform) + throws MqttException { + MqttClient client = mqttClientMap.get(platform); + if (null == client) { + throw new NoMqttClientFoundException("Unable to publish message to topic : " + mqttTopicName + + ". No MQTT client found against platformID : " + platform); + } + Optional mqttConfigOpt = getMqttConfig(platform); + int qos = (mqttConfigOpt.isPresent() ? mqttConfigOpt.get().getMqttQosValue() : mqttQosValue); + logger.debug("Publishing event via PAHO client to the mqtt topic : {}, with retained flag as {}, " + + "platformId {}, clientID {}", mqttTopicName, isRetainedMessage, platform, client.getClientId()); + mqttMessage.setRetained(isRetainedMessage); + mqttMessage.setQos(qos); + client.publish(mqttTopicName, mqttMessage); + } + + /** + * Sets the mqtt message payload. + * + * @param payload the new mqtt message payload + */ + @Override + protected void setMqttMessagePayload(byte[] payload) { + mqttMessage = new MqttMessage(); + mqttMessage.setPayload(payload); + } + + /** + * Key is vehicleID and value is event that needs to be send to Mqtt topic. + * + * @param platform the platform + * @return the connection + */ + + private Optional getConnection(String platform) { + try { + for (int i = 0; i < retryCount; i++) { + Optional client = getMqttClient(platform); + if (client.isPresent()) { + return client; + } + logger.debug("Retrying connecting to MQTT broker with PAHO client for platformID : {}. " + + "Current retry count : {}", platform, i); + Thread.sleep(retryInterval); + } + } catch (InterruptedException e) { + logger.error("Error occurred when sleeping during retry for creating connection to MQTT broker", e); + Thread.currentThread().interrupt(); + } + return Optional.empty(); + } + + /** + * Method to fetch the mqtt client. + * + * @param platform the platform + * @return MqttClient + */ + public synchronized Optional getMqttClient(String platform) { + MqttClient client = null; + logger.info("Creating Paho MQTT client for platformID : {}", platform); + Optional connectOptions = getMqttConnectOptions(platform); + Optional mqttConfig = getMqttConfig(platform); + if (connectOptions.isPresent() && mqttConfig.isPresent()) { + try { + String mqttClientId = podName + Constants.HYPHEN + UUID.randomUUID().toString(); + client = new MqttClient(mqttConfig.get().getBrokerUrl(), mqttClientId, null); + client.setTimeToWait(mqttConfig.get().getMqttTimeoutInMillis()); + client.connect(connectOptions.get()); + logger.info("Paho MQTT client creation is successful with client ID : {}, for platformID : {}", + client.getClientId(), platform); + } catch (MqttException e) { + logger.error("Paho MQTT client could not connect for platformID : {}. " + + "Exception while creating Paho Mqtt client and the error msg is : {}", platform, e); + } + } else { + logger.error("Error attempting to create connection with Paho MQTT client. " + + "No ConnectOptions or MqttConfig found for platformID : {}", platform); + } + return Optional.ofNullable(client); + + } + + /** + * Sets the mqtt connection opt. + * + * @param mqttPlatformId the mqtt platform id + * @param mqttConfig the mqtt config + */ + @Override + protected void setMqttConnectionOpt(String mqttPlatformId, MqttConfig mqttConfig) { + MqttConnectOptions connOpt = new MqttConnectOptions(); + logger.debug("Creating MQTT connection options for MQTT broker against platformId {}", mqttPlatformId); + connOpt.setUserName(mqttConfig.getMqttUserName()); + connOpt.setPassword(mqttConfig.getMqttUserPassword().toCharArray()); + connOpt.setServerURIs(new String[]{mqttConfig.getBrokerUrl()}); + connOpt.setCleanSession(mqttConfig.isCleanSession()); + connOpt.setKeepAliveInterval(mqttConfig.getKeepAliveInterval()); + connOpt.setMaxInflight(mqttConfig.getMaxInflight()); + if (StreamBaseConstant.DMA_ONE_WAY_TLS_AUTH_MECHANISM + .equalsIgnoreCase(mqttConfig.getMqttClientAuthMechanism())) { + connOpt.setSocketFactory(getSocketFactory(mqttPlatformId, mqttConfig)); + } + mqttConnOpts.put(mqttPlatformId, connOpt); + } + + /** + * Getting Mqtt connection option object to create MQTT client. Always use + * this method + * + * @param platform the platform + * @return MqttConnectOptions instance. + */ + protected Optional getMqttConnectOptions(String platform) { + return Optional.ofNullable(mqttConnOpts.get(platform)); + } + + /** + * Method to create SSLSocketFactory from Truststore details. + * + * @param platformId the platform id + * @param mqttConfig the mqtt config + * @return the socket factory + */ + private static SSLSocketFactory getSocketFactory(String platformId, MqttConfig mqttConfig) { + try { + TrustManagerFactory trustManagerFactory = DMATLSFactory.getTrustManagerFactory(platformId, mqttConfig); + SSLContext sslContext = SSLContext.getInstance(TLS_V1_2); + sslContext.init(null, trustManagerFactory.getTrustManagers(), null); + return sslContext.getSocketFactory(); + } catch (NoSuchAlgorithmException | KeyManagementException exception) { + throw new DeviceMessagingMqttClientTrustStoreException( + "Error encountered either while loading the TrustStore: " + mqttConfig.getMqttServiceTrustStorePath() + + " or in initializing the TrustManagerFactory. Exception is: " + exception, + exception.getCause()); + } + } + + /** + * Close mqtt connections. + */ + @Override + protected void closeMqttConnections() { + for (Map.Entry entry : mqttClientMap.entrySet()) { + closeMqttConnection(entry.getKey()); + } + } + + /** + * Close mqtt connection. + * + * @param platform the platform + */ + @SuppressWarnings("resource") + @Override + protected void closeMqttConnection(String platform) { + MqttClient client; + String mqttClientId = null; + if (mqttClientMap.containsKey(platform)) { + client = mqttClientMap.get(platform); + } else { + logger.info("No MQTT client found to be closed against platformID : {} in mqttClientMap", platform); + return; + } + try { + if (client != null && client.isConnected()) { + mqttClientId = client.getClientId(); + client.disconnect(); + client.close(); + client = null; + } + } catch (MqttException mqttException) { + logger.error("Unable to close the Paho client with id {}, for platformID {} and error msg is {}. " + + "Trying to disconnect forcibly! ", client.getClientId(), platform, mqttException); + try { + client.disconnectForcibly(); + client.close(); + client = null; + } catch (MqttException exception) { + logger.error("Exception while disconnecting Paho client with id {} forcibly: {} ", + client.getClientId(), exception); + } + } finally { + if (client == null || !client.isConnected()) { + logger.info("Paho Client with ID : {} disconnected successfully, for platformID : {}.", + mqttClientId, platform); + } else { + logger.info("Unable to disconnect Paho Client with id {} , for platformID : {}. " + + "Resetting reference to null.", mqttClientId, platform); + client = null; + } + mqttClientMap.put(platform, null); + } + } + + /** + * Sets the mqtt client map. + * + * @param mqttClientMap the mqtt client map + */ + // setter for unit test cases + void setMqttClientMap(Map mqttClientMap) { + this.mqttClientMap = mqttClientMap; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Pair.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Pair.java new file mode 100644 index 0000000..f800879 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Pair.java @@ -0,0 +1,181 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + + +/** + * class Pair implements Serializable. + * + * @param the generic type + * @param the generic type + */ +public class Pair { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = -2994843765375347811L; + + /** The a. */ + private A a; + + /** The b. */ + private B b; + + /** + * Instantiates a new pair. + */ + public Pair() { + } + + /** + * Instantiates a new pair. + * + * @param a the a + * @param b the b + */ + public Pair(A a, B b) { + this.a = a; + this.b = b; + } + + /** + * Gets the a. + * + * @return the a + */ + public A getA() { + return a; + } + + /** + * Sets the a. + * + * @param a the new a + */ + public void setA(A a) { + this.a = a; + } + + /** + * Gets the b. + * + * @return the b + */ + public B getB() { + return b; + } + + /** + * Sets the b. + * + * @param b the new b + */ + public void setB(B b) { + this.b = b; + } + + /** + * Hash code. + * + * @return the int + */ + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((a == null) ? 0 : a.hashCode()); + result = prime * result + ((b == null) ? 0 : b.hashCode()); + return result; + } + + /** + * Equals. + * + * @param obj the obj + * @return true, if successful + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + Pair other = (Pair) obj; + if (a == null) { + if (other.a != null) { + return false; + } + } else if (!a.equals(other.a)) { + return false; + } + if (b == null) { + if (other.b != null) { + return false; + } + } else if (!b.equals(other.b)) { + return false; + } + return true; + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return "Pair [a=" + a + ", b=" + b + "]"; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/RetryUtils.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/RetryUtils.java new file mode 100644 index 0000000..3ca819c --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/RetryUtils.java @@ -0,0 +1,122 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.analytics.stream.base.exception.MaxRetriesFailedException; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; + +import java.util.function.Function; + + +/** + * RetryUtils: utility classs for {@link reactor.util.retry.Retry}. + */ +public class RetryUtils { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(RetryUtils.class); + + /** + * Instantiates a new retry utils. + */ + private RetryUtils() { + + } + + /** + * Retries a function call and returns result or throws + * exception if function didn't return a non-null response for all attempts. Retry + * interval is 250ms. + * + * @param the generic type + * @param n - number of retries to attempt + * @param f - function that should return a result if it is successful + * @return result from function + */ + + public static R retryWithException(int n, Function f) { + for (int i = 0; i < n; i++) { + logger.info("attempt {}", i); + R r = f.apply(null); + if (r != null) { + logger.info("Received non-null from function. Returning"); + return r; + } + logger.info("Received null from function. Will sleep and retry"); + try { + Thread.sleep(Constants.THREAD_SLEEP_TIME_250); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + throw new MaxRetriesFailedException("Max retries failed"); + } + + /** + * Retries a function call and returns result or returns null if + * function didn't return a non-null response for all attempts. Retry + * interval is 250ms. + * + * @param the generic type + * @param n - number of retries to attempt + * @param f - function that should return a result if it is successful + * @return result from function or null + */ + public static R retry(int n, Function f) { + for (int i = 0; i < n; i++) { + logger.info("attempt {}", i); + R r = f.apply(null); + if (r != null) { + logger.info("Received non-null from function. Returning"); + return r; + } + logger.info("Received null from function. Will sleep and retry"); + try { + Thread.sleep(Constants.THREAD_SLEEP_TIME_250); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + return null; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ThreadUtils.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ThreadUtils.java new file mode 100644 index 0000000..c92496c --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/ThreadUtils.java @@ -0,0 +1,123 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; + + +/** + * ThreadUtils: Utility class. + */ +public class ThreadUtils { + + /** + * Instantiates a new thread utils. + */ + private ThreadUtils() { + + } + + /** The Constant LOGGER. */ + private static final org.slf4j.Logger LOGGER = org.slf4j.LoggerFactory.getLogger(ThreadUtils.class); + + /** + * Shuts down an executor reliably. Optionally allows shutting down the JVM + * if executor doesn't shutdown. + * + * @param exec exec + * @param waitTimeMs waitTimeMs + * @param exitOnFailure exitOnFailure + */ + public static void shutdownExecutor(ExecutorService exec, int waitTimeMs, boolean exitOnFailure) { + if (!exec.isShutdown()) { + LOGGER.info("Shutting down executor service"); + exec.shutdown(); // Disable new tasks from being submitted + try { + // Wait a while for existing tasks to terminate + if (!exec.awaitTermination(waitTimeMs, TimeUnit.MILLISECONDS)) { + LOGGER.info("Shutting down executor service forcefully as it has not " + + "responded to graceful shutdown"); + exec.shutdownNow(); // Cancel currently executing tasks + // Wait a while for tasks to respond to being cancelled + if (!exec.awaitTermination(waitTimeMs, TimeUnit.MILLISECONDS)) { + LOGGER.error("Executor service not closed after waiting {} ms", waitTimeMs); + if (exitOnFailure) { + LOGGER.error("Executor service not closed after waiting {} ms . " + + "Exiting application", waitTimeMs); + System.exit(1); + } + } + + } + } catch (InterruptedException ie) { + handleInterruptedException(exec, waitTimeMs, exitOnFailure); + } + } + } + + /** + * Handle interrupted exception. + * + * @param exec the exec + * @param waitTimeMs the wait time ms + * @param exitOnFailure the exit on failure + */ + private static void handleInterruptedException(ExecutorService exec, int waitTimeMs, boolean exitOnFailure) { + // (Re-)Cancel if current thread also interrupted + exec.shutdownNow(); + try { + if (!exec.awaitTermination(waitTimeMs, TimeUnit.MILLISECONDS)) { + LOGGER.error("Executor service not closed after waiting {} ms", waitTimeMs); + if (exitOnFailure) { + LOGGER.error("Executor service not closed after waiting {} ms . Exiting application", waitTimeMs); + System.exit(1); + } + } + } catch (InterruptedException e) { + LOGGER.error("Interrupted when waiting on executor"); + Thread.currentThread().interrupt(); + if (exitOnFailure) { + LOGGER.error("Executor service shutdown failed. Interrupted. Exiting application"); + System.exit(1); + } + } + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Triplet.java b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Triplet.java new file mode 100644 index 0000000..7f43e32 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/base/utils/Triplet.java @@ -0,0 +1,128 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + + +/** + * {@link Triplet}. + * + * @param a + * @param b + * @param c + */ +public class Triplet { + + /** The a. */ + private A a; + + /** The b. */ + private B b; + + /** The c. */ + private C c; + + /** + * Triplet() public constructor. + * + * @param a the a + * @param b the b + * @param c the c + */ + public Triplet(A a, B b, C c) { + this.a = a; + this.b = b; + this.c = c; + } + + /** + * Gets the a. + * + * @return the a + */ + public A getA() { + return a; + } + + /** + * Sets the a. + * + * @param a the new a + */ + public void setA(A a) { + this.a = a; + } + + /** + * Gets the b. + * + * @return the b + */ + public B getB() { + return b; + } + + /** + * Sets the b. + * + * @param b the new b + */ + public void setB(B b) { + this.b = b; + } + + /** + * Gets the c. + * + * @return the c + */ + public C getC() { + return c; + } + + /** + * Sets the c. + * + * @param c the new c + */ + public void setC(C c) { + this.c = c; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/threadlocal/ContextKey.java b/src/main/java/org/eclipse/ecsp/analytics/stream/threadlocal/ContextKey.java new file mode 100644 index 0000000..2ea85b6 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/threadlocal/ContextKey.java @@ -0,0 +1,71 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.threadlocal; + + +/** + * {@link ContextKey} enum. + */ +public enum ContextKey { + + /** The kafka sink topic. */ + KAFKA_SINK_TOPIC("kafka_sink_topic"); + + /** The context key value. */ + private String contextKeyValue; + + /** + * Gets the context key. + * + * @return the context key + */ + public String getContextKey() { + return contextKeyValue; + } + + /** + * Instantiates a new context key. + * + * @param contextKey the context key + */ + ContextKey(String contextKey) { + this.contextKeyValue = contextKey; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/threadlocal/TaskContextHandler.java b/src/main/java/org/eclipse/ecsp/analytics/stream/threadlocal/TaskContextHandler.java new file mode 100644 index 0000000..32a36d6 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/threadlocal/TaskContextHandler.java @@ -0,0 +1,147 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.threadlocal; + +import jakarta.annotation.PreDestroy; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + + +/** + *Singleton class to handle context data that needs to be forwarded from one service to another. + * It contains ThreadLocal which will will be keyed by taskId and ContextType and value will be the object. + * For example : For a taskId T1 and key KAFKA_SINK_TOPIC value can be eventsTopic. + * + * @author avadakkootko + */ +public class TaskContextHandler { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(TaskContextHandler.class); + + /** The task context handler. */ + private static TaskContextHandler taskContextHandler = new TaskContextHandler(); + + /** The thread local. */ + private ThreadLocal>> threadLocal; + + /** + * Instantiates a new task context handler. + */ + private TaskContextHandler() { + threadLocal = ThreadLocal.withInitial(HashMap::new); + logger.info("Initialised threadLocal inside TaskContextHandler"); + } + + /** + * Gets the task context handler. + * + * @return the task context handler + */ + public static TaskContextHandler getTaskContextHandler() { + return taskContextHandler; + } + + /** + * Destroy. + */ + @PreDestroy() + private void destroy() { + threadLocal.remove(); + } + /** + * Set value per taskId per context type. + * + * @param taskId taskId + * @param key key + * @param value value + */ + + public void setValue(String taskId, ContextKey key, Object value) { + Map> map = threadLocal.get(); + if (map.containsKey(taskId)) { + map.get(taskId).put(key, value); + } else { + Map sc = new EnumMap<>(ContextKey.class); + sc.put(key, value); + map.put(taskId, sc); + } + logger.debug("Inserted value {} for taskId {} and key {} from ThreadLocal Storage", value, taskId, key.name()); + } + + /** + * reset value per taskId. + * + * @param taskId taskId + */ + public void resetTaskContextValue(String taskId) { + Map> map = threadLocal.get(); + if (map.containsKey(taskId)) { + map.remove(taskId); + } + logger.debug("Reset ThreadLocal Storage for taskId", taskId); + } + + /** + * Get value per taskId per context type. + * + * @param taskId taskId + * @param key key + * @return Optional + */ + public Optional getValue(String taskId, ContextKey key) { + Map> map = threadLocal.get(); + if (map.containsKey(taskId)) { + Object value = map.get(taskId).get(key); + logger.debug("Returning value {} for taskId {} and key {} from ThreadLocal Storage", + value, taskId, key.name()); + return Optional.ofNullable(value); + } else { + logger.trace("Context unavailable in threadlocal storage for taskId: {}", taskId); + return Optional.empty(); + } + } + +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileClientApiUtil.java b/src/main/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileClientApiUtil.java new file mode 100644 index 0000000..a5fb8ee --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileClientApiUtil.java @@ -0,0 +1,140 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.vehicleprofile.utils; + +import com.google.gson.Gson; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.http.HttpClient; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.util.Map; + + +/** + * Utility class for {@link org.eclipse.ecsp.vehicleprofile.domain.VehicleProfile} API. + */ +@Component +@Scope("prototype") +public class VehicleProfileClientApiUtil { + + /** The Constant VIN_REGEX. */ + private static final String VIN_REGEX = "\"vin\":\s?\"[a-zA-Z0-9]+\""; + + /** The Constant VIN_REPLACEMENT_STRING. */ + private static final String VIN_REPLACEMENT_STRING = "\"vin\":\"******\""; + + /** The http client. */ + @Autowired + private HttpClient httpClient; + + /** The api url. */ + @Value("${" + PropertyNames.VEHICLE_PROFILE_VIN_URL + ":}") + private String apiUrl; + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(VehicleProfileClientApiUtil.class); + /** + * Invoke the vehicle-profile API for the given deviceId. + * + * @param deviceId The Device ID. + * @return Response. + */ + + public String callVehicleProfile(String deviceId) { + + if (StringUtils.isEmpty(apiUrl)) { + logger.error("No URL configured for vehicle profile API against property : {}. " + + "Unable to fetch vehicleId against deviceId : {}", + PropertyNames.VEHICLE_PROFILE_VIN_URL, deviceId); + return null; + } + String url = appendToUrl(deviceId); + long startTime = System.currentTimeMillis(); + logger.debug("Invoking the Vehicle Profile API with URL: {} for deviceId : {}", url, deviceId); + Map responseData = httpClient.invokeJsonResource(HttpClient.HttpReqMethod.GET, + url, null, null, 0, 0); + long timeTaken = (System.currentTimeMillis() - startTime) / Constants.THOUSAND; + logger.debug("Time taken to fetch the VP details for deviceId : {} is: {} second(s)", deviceId, timeTaken); + logger.info("Received VP data: {} from the API {} for deviceId : {}", responseData, url, deviceId); + Gson gson = new Gson(); + if (responseData == null) { + logger.error("Response returned by vehicle profile API for deviceId : {} is null"); + return null; + } + try { + String responseJson = responseData.get("responseJson").toString(); + logger.info("Received VP responseJson: {} from the API {} for deviceId: {}", + responseJson.replaceAll(VIN_REGEX, VIN_REPLACEMENT_STRING), url, deviceId); + VehicleProfileData vehicle = gson.fromJson(responseJson, VehicleProfileData.class); + if (null == vehicle || !vehicle.getMessage().equals("SUCCESS")) { + logger.error("Vehicle profile data in the response returned by vehicle profile API for " + + "deviceId : {} is null or not SUCCESS."); + return null; + } + if (vehicle.getData().isEmpty() || null == vehicle.getData().get(0)) { + logger.error("No data found in response from vehicle profile API for deviceId : {}"); + return null; + } + return vehicle.getData().get(0).getVin(); + } catch (Exception e) { + logger.error("Error while parsing vehicle profile data - {}", e.getMessage()); + return null; + } + } + + /** + * Append to url. + * + * @param deviceId the device id + * @return the string + */ + private String appendToUrl(String deviceId) { + String url = apiUrl; + url += deviceId; + return url; + } +} diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileData.java b/src/main/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileData.java new file mode 100644 index 0000000..3751e20 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileData.java @@ -0,0 +1,91 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.vehicleprofile.utils; + +import java.util.List; + + +/** + * {@link VehicleProfileData}. + */ +public class VehicleProfileData { + + /** The message. */ + private String message; + + /** The data. */ + private List data; + + /** + * Gets the message. + * + * @return the message + */ + public String getMessage() { + return message; + } + + /** + * Sets the message. + * + * @param message the new message + */ + public void setMessage(String message) { + this.message = message; + } + + /** + * Gets the data. + * + * @return the data + */ + public List getData() { + return data; + } + + /** + * Sets the data. + * + * @param data the new data + */ + public void setData(List data) { + this.data = data; + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileEntity.java b/src/main/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileEntity.java new file mode 100644 index 0000000..ad4c1ce --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileEntity.java @@ -0,0 +1,68 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.vehicleprofile.utils; + + +/** + * VehicleProfileEntity. + */ +public class VehicleProfileEntity { + + /** The vin. */ + private String vin; + + /** + * Sets the vin. + * + * @param vin the new vin + */ + public void setVin(String vin) { + this.vin = vin; + } + + /** + * Gets the vin. + * + * @return the vin + */ + public String getVin() { + return vin; + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/config/DMAConfigResolver.java b/src/main/java/org/eclipse/ecsp/stream/dma/config/DMAConfigResolver.java new file mode 100644 index 0000000..b8c4b1d --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/config/DMAConfigResolver.java @@ -0,0 +1,59 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.config; + +import org.eclipse.ecsp.entities.IgniteEvent; + + +/** + * To configure retry interval at "event level", service is going to have to implement an interface. + * + * @author poorvi + */ +public interface DMAConfigResolver { + + /** + * GETS Retry Interval time. + * + * @param event the event + * @return retryInterval : retryInterval returned from this method will be set on this IgntieEvent. + */ + public long getRetryInterval(IgniteEvent event); +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/config/DefaultDMAConfigResolver.java b/src/main/java/org/eclipse/ecsp/stream/dma/config/DefaultDMAConfigResolver.java new file mode 100644 index 0000000..949a957 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/config/DefaultDMAConfigResolver.java @@ -0,0 +1,71 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.config; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + + +/** + * Default implementation of DefaultDMAConfigResolver. By default dma.service.retry.interval.millis set in stream-base's + * properties file will be returned i.e. 60000. + * + * @author poorvi + */ +@Component +public class DefaultDMAConfigResolver implements DMAConfigResolver { + + /** The retry interval. */ + @Value("${" + PropertyNames.DMA_SERVICE_RETRY_INTERVAL_MILLIS + ":60000}") + private long retryInterval; + + /** + * Gets the retry interval. + * + * @param event the event + * @return the retry interval + */ + @Override + public long getRetryInterval(IgniteEvent event) { + return retryInterval; + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/config/DefaultEventConfig.java b/src/main/java/org/eclipse/ecsp/stream/dma/config/DefaultEventConfig.java new file mode 100644 index 0000000..e254d19 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/config/DefaultEventConfig.java @@ -0,0 +1,61 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.config; + + +/** + * Default implementation of EventConfig. By default this use case is disabled. + * + * + * @author hbadshah + */ +public class DefaultEventConfig implements EventConfig { + + /** + * Fallback to TTL on max retry exhausted. + * + * @return true, if successful + */ + @Override + public boolean fallbackToTTLOnMaxRetryExhausted() { + return false; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/config/DefaultEventConfigProvider.java b/src/main/java/org/eclipse/ecsp/stream/dma/config/DefaultEventConfigProvider.java new file mode 100644 index 0000000..cae7a88 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/config/DefaultEventConfigProvider.java @@ -0,0 +1,63 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.config; + +import org.springframework.stereotype.Component; + + +/** + * Default implementation of EventConfigProvider. + * + * @author hbadshah + */ +@Component +public class DefaultEventConfigProvider implements EventConfigProvider { + + /** + * Gets the event config. + * + * @param eventId the event id + * @return the event config + */ + @Override + public EventConfig getEventConfig(String eventId) { + return getDefaultEventConfig(eventId); + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/config/EventConfig.java b/src/main/java/org/eclipse/ecsp/stream/dma/config/EventConfig.java new file mode 100644 index 0000000..79cbf5e --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/config/EventConfig.java @@ -0,0 +1,61 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.config; + + +/** + * To make use of one of the use cases in DMA, which is to keep retrying events until TTL + * set on them expires even when the retry attempts are exhausted, service must + * implement this interface to enable this use case. + * + * @author hbadshah + */ +public interface EventConfig { + + /** + * Fallback to TTL on max retry exhausted. + * + * @return true, if successful + */ + /* + * @return boolean: signaling whether to enable this use case or not. + */ + public boolean fallbackToTTLOnMaxRetryExhausted(); +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/config/EventConfigProvider.java b/src/main/java/org/eclipse/ecsp/stream/dma/config/EventConfigProvider.java new file mode 100644 index 0000000..6006215 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/config/EventConfigProvider.java @@ -0,0 +1,75 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.config; + + +/** + * Service needs to implement this use case to provide EventConfig for + * specific eventId. Required for DMA to know whether to enable the use case for + * this eventId or not. + * + * @author hbadshah + */ +public interface EventConfigProvider { + + /** The default event config. */ + public final EventConfig DEFAULT_EVENT_CONFIG = new DefaultEventConfig(); + + /** + * Gets the event config. + * + * @param eventId the event id + * @return the event config + */ + /* + * @returns EventConfig + */ + public EventConfig getEventConfig(String eventId); + + /** + * Gets the default event config. + * + * @param eventId the event id + * @return the default event config + */ + default EventConfig getDefaultEventConfig(String eventId) { + return DEFAULT_EVENT_CONFIG; + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMAConstants.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMAConstants.java new file mode 100644 index 0000000..0133ebe --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMAConstants.java @@ -0,0 +1,128 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + + +/** + * Constants class. + */ +public class DMAConstants { + + /** + * Instantiates a new DMA constants. + */ + private DMAConstants() { + throw new UnsupportedOperationException("Objects for utility classes can not be created"); + } + + /** The Constant ACTIVE. */ + public static final String ACTIVE = "ACTIVE"; + + /** The Constant INACTIVE. */ + public static final String INACTIVE = "INACTIVE"; + + /** The Constant VEHICLE_DEVICE_MAPPING. */ + public static final String VEHICLE_DEVICE_MAPPING = "VEHICLE_DEVICE_MAPPING:"; + + /** The Constant RETRY_MESSAGEID. */ + public static final String RETRY_MESSAGEID = "RETRY_MESSAGEID"; + + /** The Constant RETRY_BUCKET. */ + public static final String RETRY_BUCKET = "RETRY_BUCKET"; + + /** The Constant DEVICE_STATUS_TOPIC_PREFIX. */ + public static final String DEVICE_STATUS_TOPIC_PREFIX = "device-status-"; + + /** The Constant DMA. */ + public static final String DMA = "DMA"; + + /** The Constant CONSUMER_GROUP. */ + public static final String CONSUMER_GROUP = "consumer-group"; + + /** The Constant HYPHEN. */ + public static final String HYPHEN = "-"; + + /** The Constant MESSAGEID. */ + public static final String MESSAGEID = "messageId"; + + /** The Constant MESSAGEID_AND_CORRELATIONID. */ + public static final String MESSAGEID_AND_CORRELATIONID = "messageIdAndCorrelationId"; + + /** The Constant COLON. */ + public static final String COLON = ":"; + + /** The Constant SEMI_COLON. */ + public static final String SEMI_COLON = ";"; + + /** The Constant FORWARD_SLASH. */ + public static final String FORWARD_SLASH = "/"; + + /** The Constant BIZ_TRANSACTION_ID. */ + public static final String BIZ_TRANSACTION_ID = "bizTransactionId"; + + /** The Constant DMA_SHOULDER_TAP_ENABLED_MESSAGE_PRIORITY. */ + public static final short DMA_SHOULDER_TAP_ENABLED_MESSAGE_PRIORITY = 10; + + /** The Constant SHOULDER_TAP_RETRY_BUCKET. */ + public static final String SHOULDER_TAP_RETRY_BUCKET = "SHOULDER_TAP_RETRY_BUCKET"; + + /** The Constant SHOULDER_TAP_RETRY_VEHICLEID. */ + public static final String SHOULDER_TAP_RETRY_VEHICLEID = "SHOULDER_TAP_RETRY_VEHICLEID"; + + /** The Constant TOP_RECORD_NUMBER. */ + public static final Integer TOP_RECORD_NUMBER = 1; + + /** The Constant DM_NEXT_TTL_EXPIRATION_TIMER_KEY. */ + public static final String DM_NEXT_TTL_EXPIRATION_TIMER_KEY = "nextTtlExpirationTimer"; + + /** The Constant TTL_EXPRIATION_TIME_FIELD. */ + public static final String TTL_EXPRIATION_TIME_FIELD = "ttlExpirationTime"; + + /** The Constant IS_TTL_NOTIF_PROCESSED_FIELD. */ + public static final String IS_TTL_NOTIF_PROCESSED_FIELD = "isTtlNotifProcessed"; + + /** The Constant DEFAULT_DMA_CONFIG_RESOLVER. */ + public static final String DEFAULT_DMA_CONFIG_RESOLVER = + "org.eclipse.ecsp.stream.dma.config.DefaultDMAConfigResolver"; + + /** The Constant DMA_DEFAULT_POST_DISPATCH_HANDLER_CLASS. */ + public static final String DMA_DEFAULT_POST_DISPATCH_HANDLER_CLASS = + "org.eclipse.ecsp.stream.dma.handler.DefaultPostDispatchHandler"; +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMARetryBucketDAOCacheBackedInMemoryImpl.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMARetryBucketDAOCacheBackedInMemoryImpl.java new file mode 100644 index 0000000..2ad0870 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMARetryBucketDAOCacheBackedInMemoryImpl.java @@ -0,0 +1,154 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import jakarta.annotation.PostConstruct; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.stores.CachedSortedMapStateStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.InternalCacheConstants; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.dma.RetryRecordIds; +import org.eclipse.ecsp.stream.dma.dao.key.RetryBucketKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Repository; +import java.util.Comparator; +import java.util.Optional; + + +/** + * This class is responsible for string the messageIds to be retired at a + * particult timestamp. Here key timestamp is of type long and value + * is RetryMessageIds is wrapper around a set of strings (messageIds). + */ +@Repository +@Scope("prototype") +public class DMARetryBucketDAOCacheBackedInMemoryImpl extends CachedSortedMapStateStore + implements DmaRetryBucketDao { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DMARetryBucketDAOCacheBackedInMemoryImpl.class); + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** + * Sets the service name. + * + * @param serviceName the new service name + */ + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + /** + * Update messageId in the set of strings that needs to be retried corresponding to timestamp. + * + * @param mapKey the map key + * @param key the key + * @param messageId the message id + */ + @Override + public void update(String mapKey, RetryBucketKey key, String messageId) { + putToMapIfAbsent(mapKey, key, new RetryRecordIds(Version.V1_0), Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_BUCKET); + RetryRecordIds retryMessageIds = get(key); + if (retryMessageIds == null) { + retryMessageIds = new RetryRecordIds(Version.V1_0); + } + logger.info("Got retryRecordMessageIds: {} for key: {}", retryMessageIds, key.convertToString()); + retryMessageIds.addRecordId(messageId); + putToMap(mapKey, key, retryMessageIds, Optional.empty(), InternalCacheConstants.CACHE_TYPE_RETRY_BUCKET); + logger.debug("Updated messageId {} in RetryBucketDAO for key {}", messageId, key.getTimestamp()); + } + + /** + * Delete messageId from the set of messageIds to be retried for a timestamp. + * + * @param mapKey the map key + * @param key the key + * @param messageId the message id + */ + @Override + public void deleteMessageId(String mapKey, RetryBucketKey key, String messageId) { + RetryRecordIds retryMessageIds = get(key); + if (retryMessageIds.deleteRecordId(messageId)) { + logger.debug("Deleted messageId {} in RetryBucketDAO for key {}", messageId, key); + putToMap(mapKey, key, retryMessageIds, Optional.empty(), InternalCacheConstants.CACHE_TYPE_RETRY_BUCKET); + } else { + logger.error("messageId {} is not present in RetryBucketDAO for key {}. Delete op cannot be performed.", + messageId, key.getTimestamp()); + } + } + + /** + * Initialize. + * + * @param taskId the task id + */ + @Override + public void initialize(String taskId) { + final long currentTime = System.currentTimeMillis(); + StringBuilder regexBuilder = new StringBuilder(); + regexBuilder.append(DMAConstants.RETRY_BUCKET).append(DMAConstants.COLON) + .append(serviceName).append(DMAConstants.COLON).append(taskId); + RetryBucketKey converter = new RetryBucketKey(); + //passing the taskId to CacheSortedMapStore, which in turn will pass it to setup of CacheBypass. + setTaskId(taskId); + syncWithMapCache(regexBuilder.toString(), converter, InternalCacheConstants.CACHE_TYPE_RETRY_BUCKET); + long endTime = System.currentTimeMillis(); + logger.info("Time taken to Initialize RetryBucketDAO for taskId {} is {} seconds", taskId, + (endTime - currentTime) / Constants.THOUSAND); + } + + /** + * Setup. + */ + @PostConstruct + public void setup() { + Comparator comparator = Comparator.comparingLong(RetryBucketKey::getTimestamp); + createStoreWithComparator(comparator); + } + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMARetryRecordDAO.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMARetryRecordDAO.java new file mode 100644 index 0000000..b21d25c --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMARetryRecordDAO.java @@ -0,0 +1,65 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import org.eclipse.ecsp.analytics.stream.base.stores.MutableKeyValueStore; +import org.eclipse.ecsp.analytics.stream.base.stores.SortedKeyValueStore; +import org.eclipse.ecsp.entities.dma.RetryRecord; +import org.eclipse.ecsp.stream.dma.dao.key.RetryRecordKey; + + +/** + * DMARetryEventDAO is responsible for storing Key value pairs which will be + * messageId and corresponding IgniteEvent to be retried. + * Key should be of the format : RETRY_MESSADEID_{@code <}SERVICENAME{@code >}_{@code <}MESSAGEID{@code >} + * Value will be DMARetryEvent which contains IgniteKey and IgniteEvent. + * + * @author avadakkootko + */ +public interface DMARetryRecordDAO + extends MutableKeyValueStore, SortedKeyValueStore { + + /** + * Initialize. + * + * @param taskId the task id + */ + public void initialize(String taskId); +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMARetryRecordDAOCacheBackedInMemoryImpl.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMARetryRecordDAOCacheBackedInMemoryImpl.java new file mode 100644 index 0000000..ea04fc1 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMARetryRecordDAOCacheBackedInMemoryImpl.java @@ -0,0 +1,102 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.stores.CachedMapStateStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.InternalCacheConstants; +import org.eclipse.ecsp.entities.dma.RetryRecord; +import org.eclipse.ecsp.stream.dma.dao.key.RetryRecordKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Repository; + + +/** + * DMARetryRecordDAOCacheBackedInMemoryImpl is extends CachedMapStateStore which is a + * generic concurrent hash map that periodically backs up + * data to cache (Redis). + * + * @author avadakkootko + */ +@Lazy +@Repository +public class DMARetryRecordDAOCacheBackedInMemoryImpl extends CachedMapStateStore + implements DMARetryRecordDAO { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DMARetryRecordDAOCacheBackedInMemoryImpl.class); + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** + * Sets the service name. + * + * @param serviceName the new service name + */ + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + /** + * Initialize. + * + * @param taskId the task id + */ + @Override + public void initialize(String taskId) { + StringBuilder regexBuilder = new StringBuilder(); + regexBuilder.append(DMAConstants.RETRY_MESSAGEID).append(DMAConstants.COLON) + .append(serviceName).append(DMAConstants.COLON).append(taskId); + //passing the taskId to CachedMapStore, which in turn will pass it to setup of CacheBypass. + setTaskId(taskId); + long currentTime = System.currentTimeMillis(); + syncWithMapCache(regexBuilder.toString(), new RetryRecordKey(), InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + long endTime = System.currentTimeMillis(); + logger.info("Time taken to Initialize RetryRecordDAO for taskId {} is {} seconds", taskId, + (endTime - currentTime) / Constants.THOUSAND); + } + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMCacheEntityDAOMongoImpl.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMCacheEntityDAOMongoImpl.java new file mode 100644 index 0000000..f4317f3 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMCacheEntityDAOMongoImpl.java @@ -0,0 +1,82 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.stores.CacheEntity; +import org.eclipse.ecsp.nosqldao.mongodb.IgniteBaseDAOMongoImpl; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Repository; + + +/** + * DAO class for {@link CacheEntity} Mongo collection. + */ +@SuppressWarnings("rawtypes") +@Repository +public class DMCacheEntityDAOMongoImpl extends IgniteBaseDAOMongoImpl { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DMCacheEntityDAOMongoImpl.class); + + /** The service name identifier. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceNameIdentifier; + + /** The collection. */ + private volatile String collection; + + /** + * Gets the overriding collection name. + * + * @return the overriding collection name + */ + @Override + public String getOverridingCollectionName() { + if (StringUtils.isEmpty(collection)) { + collection = new StringBuilder().append("dmCacheEntities").append(serviceNameIdentifier).toString(); + } + logger.debug("Collection {} created for service: {}", collection, serviceNameIdentifier); + return collection; + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMNextTtlExpirationTimer.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMNextTtlExpirationTimer.java new file mode 100644 index 0000000..c1438f4 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMNextTtlExpirationTimer.java @@ -0,0 +1,123 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import dev.morphia.annotations.Entity; +import dev.morphia.annotations.Id; +import org.eclipse.ecsp.entities.AbstractIgniteEntity; + + +/** + * class DMNextTtlExpirationTimer extends AbstractIgniteEntity. + */ +@Entity() +public class DMNextTtlExpirationTimer extends AbstractIgniteEntity { + + /** The id. */ + @Id + private String id; + + /** The ttl expiration timer. */ + private long ttlExpirationTimer; + + /** + * Instantiates a new DM next ttl expiration timer. + */ + public DMNextTtlExpirationTimer() { + this.id = DMAConstants.DM_NEXT_TTL_EXPIRATION_TIMER_KEY; + } + + /** + * Instantiates a new DM next ttl expiration timer. + * + * @param ttlExpirationTimer the ttl expiration timer + */ + public DMNextTtlExpirationTimer(long ttlExpirationTimer) { + this.id = DMAConstants.DM_NEXT_TTL_EXPIRATION_TIMER_KEY; + this.ttlExpirationTimer = ttlExpirationTimer; + } + + /** + * Gets the id. + * + * @return the id + */ + public String getId() { + return id; + } + + /** + * Sets the id. + * + * @param id the new id + */ + public void setId(String id) { + this.id = id; + } + + /** + * Gets the ttl expiration timer. + * + * @return the ttl expiration timer + */ + public long getTtlExpirationTimer() { + return ttlExpirationTimer; + } + + /** + * Sets the ttl expiration timer. + * + * @param ttlExpirationTimer the new ttl expiration timer + */ + public void setTtlExpirationTimer(long ttlExpirationTimer) { + this.ttlExpirationTimer = ttlExpirationTimer; + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return "DMNextTtlExpirationTimer [id=" + id + ", ttlExpirationTimer=" + ttlExpirationTimer + "]"; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMNextTtlExpirationTimerDAO.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMNextTtlExpirationTimerDAO.java new file mode 100644 index 0000000..87bd065 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMNextTtlExpirationTimerDAO.java @@ -0,0 +1,49 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import org.eclipse.ecsp.nosqldao.IgniteBaseDAO; + +/** + * interface DMNextTtlExpirationTimerDAO extends IgniteBaseDAO. + */ +public interface DMNextTtlExpirationTimerDAO extends IgniteBaseDAO { + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMNextTtlExpirationTimerDAOImpl.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMNextTtlExpirationTimerDAOImpl.java new file mode 100644 index 0000000..e731d34 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMNextTtlExpirationTimerDAOImpl.java @@ -0,0 +1,73 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.nosqldao.mongodb.IgniteBaseDAOMongoImpl; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + + +/** + * class DMNextTtlExpirationTimerDAOImpl extends IgniteBaseDAOMongoImpl. + */ +@Repository +@ConditionalOnProperty(name = PropertyNames.DMA_ENABLED, havingValue = "true") +public class DMNextTtlExpirationTimerDAOImpl extends IgniteBaseDAOMongoImpl + implements DMNextTtlExpirationTimerDAO { + + /** The collection. */ + private volatile String collection; + + /** + * Gets the overriding collection name. + * + * @return the overriding collection name + */ + @Override + public String getOverridingCollectionName() { + if (StringUtils.isEmpty(collection)) { + collection = new StringBuilder().append("dmNextTtlExpirationTimer").append(serviceName).toString(); + } + return collection; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMOfflineBufferEntry.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMOfflineBufferEntry.java new file mode 100644 index 0000000..df4f7a5 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMOfflineBufferEntry.java @@ -0,0 +1,325 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import dev.morphia.annotations.Entity; +import dev.morphia.annotations.Field; +import dev.morphia.annotations.Id; +import dev.morphia.annotations.Index; +import dev.morphia.annotations.IndexOptions; +import dev.morphia.annotations.Indexes; +import dev.morphia.utils.IndexType; +import org.eclipse.ecsp.entities.AbstractIgniteEntity; +import org.eclipse.ecsp.entities.AuditableIgniteEntity; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.key.IgniteKey; + +import java.time.LocalDateTime; +import java.util.UUID; + + +/** + * class {@link DMOfflineBufferEntry} extends {@link AbstractIgniteEntity} + * implements {@link AuditableIgniteEntity}. + */ +@Entity() +@Indexes(value = { @Index(fields = @Field(value = "eventTs"), + options = @IndexOptions(expireAfterSeconds = 31536000, background = true)), + @Index(fields = { @Field(value = "vehicleId"), + @Field(value = "priority", type = IndexType.DESC) }, options = @IndexOptions(background = true)), + @Index(fields = { @Field(value = "ttlExpirationTime"), + @Field(value = "isTtlNotifProcessed") }, options = @IndexOptions(background = true)) }) +public class DMOfflineBufferEntry extends AbstractIgniteEntity implements AuditableIgniteEntity { + + /** The id. */ + @Id + private String id; + + /** The vehicle id. */ + private String vehicleId; + + /** The device id. */ + private String deviceId; + + /** The event ts. */ + private LocalDateTime eventTs; + + /** The ignite key. */ + private IgniteKey igniteKey; + + /** The event. */ + private DeviceMessage event; + + /** The priority. */ + /* + * Priority of the message with range from 0 to 10. Default is 0. For + * DeviceMessage with isShoulderTapEnabled=true, set priority as 10. + */ + private int priority; + + /** The pending retries. */ + private int pendingRetries; + + /** The sub service. */ + /* + * RTC 355420. If sub-services exists, then ignite query to fetch documents + * from mongo should have sub-service condition as well along with VIN. + */ + private String subService; + + /** The ttl expiration time. */ + private long ttlExpirationTime; + + /** The is ttl notif processed. */ + private boolean isTtlNotifProcessed; + + /** + * Instantiates a new DM offline buffer entry. + */ + public DMOfflineBufferEntry() { + StringBuilder keyBuilder = new StringBuilder(); + id = keyBuilder.append(UUID.randomUUID().toString()).append(System.currentTimeMillis()).toString(); + } + + /** + * Gets the event. + * + * @return the event + */ + public DeviceMessage getEvent() { + return event; + } + + /** + * Sets the event. + * + * @param event the new event + */ + public void setEvent(DeviceMessage event) { + this.event = event; + } + + /** + * Gets the event ts. + * + * @return the event ts + */ + public LocalDateTime getEventTs() { + return eventTs; + } + + /** + * Sets the event ts. + * + * @param eventTs the new event ts + */ + public void setEventTs(LocalDateTime eventTs) { + this.eventTs = eventTs; + } + + /** + * Gets the id. + * + * @return the id + */ + public String getId() { + return id; + } + + /** + * Gets the ignite key. + * + * @return the ignite key + */ + public IgniteKey getIgniteKey() { + return igniteKey; + } + + /** + * Sets the ignite key. + * + * @param igniteKey the new ignite key + */ + public void setIgniteKey(IgniteKey igniteKey) { + this.igniteKey = igniteKey; + } + + /** + * Gets the priority. + * + * @return the priority + */ + public int getPriority() { + return priority; + } + + /** + * Sets the priority. + * + * @param priority the new priority + */ + public void setPriority(int priority) { + this.priority = priority; + } + + /** + * Gets the vehicle id. + * + * @return the vehicle id + */ + public String getVehicleId() { + return vehicleId; + } + + /** + * Sets the vehicle id. + * + * @param vehicleId the new vehicle id + */ + public void setVehicleId(String vehicleId) { + this.vehicleId = vehicleId; + } + + /** + * Gets the device id. + * + * @return the device id + */ + public String getDeviceId() { + return deviceId; + } + + /** + * Sets the device id. + * + * @param deviceId the new device id + */ + public void setDeviceId(String deviceId) { + this.deviceId = deviceId; + } + + /** + * Gets the pending retries. + * + * @return the pending retries + */ + public int getPendingRetries() { + return this.pendingRetries; + } + + /** + * Sets the pending retries. + * + * @param pendingRetries the new pending retries + */ + public void setPendingRetries(int pendingRetries) { + this.pendingRetries = pendingRetries; + } + + /** + * Gets the sub service. + * + * @return the sub service + */ + public String getSubService() { + return this.subService; + } + + /** + * Sets the sub service. + * + * @param subService the new sub service + */ + public void setSubService(String subService) { + this.subService = subService; + } + + /** + * Gets the ttl expiration time. + * + * @return the ttl expiration time + */ + public long getTtlExpirationTime() { + return ttlExpirationTime; + } + + /** + * Sets the ttl expiration time. + * + * @param ttlExpirationTime the new ttl expiration time + */ + public void setTtlExpirationTime(long ttlExpirationTime) { + this.ttlExpirationTime = ttlExpirationTime; + } + + /** + * Checks if is ttl notif processed. + * + * @return true, if is ttl notif processed + */ + public boolean isTtlNotifProcessed() { + return isTtlNotifProcessed; + } + + /** + * Sets the ttl notif processed. + * + * @param isTtlNotifProcessed the new ttl notif processed + */ + public void setTtlNotifProcessed(boolean isTtlNotifProcessed) { + this.isTtlNotifProcessed = isTtlNotifProcessed; + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return "DMOfflineBufferEntry [id=" + id + ", vehicleId=" + vehicleId + + ", deviceId=" + deviceId + ", eventTs=" + eventTs + + ", igniteKey=" + igniteKey + ", event=" + event + ", priority=" + + priority + ",pendingRetries=" + pendingRetries + ",subService=" + subService + + ", ttlExpirationTime=" + ttlExpirationTime + ", " + + "isTtlNotifProcessed=" + isTtlNotifProcessed + "]"; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMOfflineBufferEntryDAO.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMOfflineBufferEntryDAO.java new file mode 100644 index 0000000..0448566 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMOfflineBufferEntryDAO.java @@ -0,0 +1,98 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.nosqldao.IgniteBaseDAO; + +import java.util.List; +import java.util.Optional; + + +/** + * DMOfflineBufferEntryDAO extends {@link IgniteBaseDAO}. + */ +public interface DMOfflineBufferEntryDAO extends IgniteBaseDAO { + + /** + * Add pending ignite event for a service. + * + * @param deviceId the device id + * @param igniteKey the ignite key + * @param igniteEvent the ignite event + * @param subService the sub service + */ + public void addOfflineBufferEntry(String deviceId, IgniteKey igniteKey, + DeviceMessage igniteEvent, String subService); + + /** + * Get cached events for a particular device and service sorted based on priority. + * + * @param vehicleId vehicleId + * @param descSortOrder descSortOrder + * @param deviceId deviceId + * @param subService subService + * @return the sorted list of cached events or an empty list if there are no events + */ + public List getOfflineBufferEntriesSortedByPriority(String vehicleId, boolean descSortOrder, + Optional deviceId, Optional subService); + + /** + * Remove the given entry from offline buffer. + * + * @param offlineEntryId offlineEntryId + */ + public void removeOfflineBufferEntry(String offlineEntryId); + + /** + * get entry with earliest TTL from offline buffer. + * + * @return the offline buffer entry with earliest ttl + */ + public DMOfflineBufferEntry getOfflineBufferEntryWithEarliestTtl(); + + /** + * get offline buffer entry records with expired TTL. + * + * @return the offline buffer entries with expired ttl + */ + public List getOfflineBufferEntriesWithExpiredTtl(); +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMOfflineBufferEntryDAOMongoImpl.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMOfflineBufferEntryDAOMongoImpl.java new file mode 100644 index 0000000..f9068b0 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DMOfflineBufferEntryDAOMongoImpl.java @@ -0,0 +1,262 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.nosqldao.IgniteCriteria; +import org.eclipse.ecsp.nosqldao.IgniteCriteriaGroup; +import org.eclipse.ecsp.nosqldao.IgniteOrderBy; +import org.eclipse.ecsp.nosqldao.IgniteQuery; +import org.eclipse.ecsp.nosqldao.Operator; +import org.eclipse.ecsp.nosqldao.mongodb.IgniteBaseDAOMongoImpl; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.eclipse.ecsp.stream.dma.dao.DMAConstants.DMA_SHOULDER_TAP_ENABLED_MESSAGE_PRIORITY; +import static org.eclipse.ecsp.stream.dma.dao.DMAConstants.IS_TTL_NOTIF_PROCESSED_FIELD; +import static org.eclipse.ecsp.stream.dma.dao.DMAConstants.TTL_EXPRIATION_TIME_FIELD; + + +/** + * {@link DMOfflineBufferEntryDAOMongoImpl} extends {@link IgniteBaseDAOMongoImpl} + * implements {@link DMOfflineBufferEntryDAO} . + */ +@Repository +@ConditionalOnProperty(name = PropertyNames.DMA_ENABLED, havingValue = "true") +public class DMOfflineBufferEntryDAOMongoImpl extends IgniteBaseDAOMongoImpl + implements DMOfflineBufferEntryDAO { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DMOfflineBufferEntryDAOMongoImpl.class); + + /** The service name identifier. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceNameIdentifier; + + /** The collection. */ + private volatile String collection; + + /** + * Gets the overriding collection name. + * + * @return the overriding collection name + */ + @Override + public String getOverridingCollectionName() { + if (StringUtils.isEmpty(collection)) { + collection = new StringBuilder().append("dmOfflineBufferEntries").append(serviceNameIdentifier).toString(); + } + return collection; + } + + /** + * Adds the offline buffer entry. + * + * @param vehicleId the vehicle id + * @param igniteKey the ignite key + * @param entity the entity + * @param subService the sub service + */ + @Override + public void addOfflineBufferEntry(String vehicleId, @SuppressWarnings("rawtypes") IgniteKey igniteKey, + DeviceMessage entity, String subService) { + logger.debug("Add buffer entry for vehicle id {} and service {}", vehicleId, serviceNameIdentifier); + DeviceMessageHeader header = entity.getDeviceMessageHeader(); + LocalDateTime eventDate = LocalDateTime.ofEpochSecond(header.getTimestamp(), 0, ZoneOffset.UTC); + DMOfflineBufferEntry offlineEntry = new DMOfflineBufferEntry(); + offlineEntry.setIgniteKey(igniteKey); + offlineEntry.setVehicleId(vehicleId); + offlineEntry.setEventTs(eventDate); + offlineEntry.setEvent(entity); + offlineEntry.setPendingRetries(entity.getDeviceMessageHeader().getPendingRetries()); + offlineEntry.setTtlExpirationTime(header.getDeviceDeliveryCutoff()); + offlineEntry.setTtlNotifProcessed(false); + if (StringUtils.isNotEmpty(subService)) { + offlineEntry.setSubService(subService); + } + String targetDeviceId = header.getTargetDeviceId(); + if (StringUtils.isNotEmpty(targetDeviceId)) { + offlineEntry.setDeviceId(targetDeviceId); + } + if (header.isShoulderTapEnabled()) { + offlineEntry.setPriority(DMA_SHOULDER_TAP_ENABLED_MESSAGE_PRIORITY); + } + + /* + * RTC-404497, if a service has sharded the dma offline buffer collection + * the shard key information has to be sent to ignite dao for further use mandated from mongodb 4.4 + * */ + offlineEntry = save(offlineEntry); + logger.debug("Saved ignite event {}", offlineEntry); + } + + /** + * Removes the offline buffer entry. + * + * @param offlineEntryId the offline entry id + */ + @Override + public void removeOfflineBufferEntry(String offlineEntryId) { + deleteByIds(offlineEntryId); + logger.debug("Removed offline entry with id {}", offlineEntryId); + } + + /** + * Gets the offline buffer entries sorted by priority. + * + * @param vehicleId the vehicle id + * @param descSortOrder the desc sort order + * @param deviceId the device id + * @param subService the sub service + * @return the offline buffer entries sorted by priority + */ + @Override + public List getOfflineBufferEntriesSortedByPriority(String vehicleId, boolean descSortOrder, + Optional deviceId, Optional subService) { + logger.debug("Get offline entries for vehicle id {} and service {}", vehicleId, serviceNameIdentifier); + IgniteCriteria criteriaVehicleId = new IgniteCriteria("vehicleId", Operator.EQ, vehicleId); + + IgniteCriteriaGroup criteriaGroup = new IgniteCriteriaGroup(criteriaVehicleId); + + if (deviceId.isPresent()) { + IgniteCriteria criteriaDeviceId = new IgniteCriteria("deviceId", Operator.EQ, + deviceId.get()); + criteriaGroup.and(criteriaDeviceId); + } + + if (subService.isPresent()) { + IgniteCriteria criteriaSubService = new IgniteCriteria("subService", Operator.EQ, subService.get()); + criteriaGroup.and(criteriaSubService); + } + + IgniteQuery query = new IgniteQuery(criteriaGroup); + + IgniteOrderBy igniteOrderBy = new IgniteOrderBy().byfield("priority"); + + if (descSortOrder) { + igniteOrderBy.desc(); + } + + query.orderBy(igniteOrderBy); + + List offlineEntries = find(query); + if (!offlineEntries.isEmpty()) { + logger.debug("Got {} offline entries {}", offlineEntries.size(), offlineEntries); + return offlineEntries; + } else { + logger.debug("No entries found for vehicle {} and service {}", vehicleId, serviceNameIdentifier); + return Collections.emptyList(); + } + } + + /** + * Fetch the entry from offline buffer collection with TTL which is latest to expire. + * + * @return the offline buffer entry with earliest ttl + */ + @Override + public DMOfflineBufferEntry getOfflineBufferEntryWithEarliestTtl() { + + logger.debug("Get offline buffer entry with earliest TTL"); + IgniteCriteria ttlExpirationTimeExists = new IgniteCriteria(TTL_EXPRIATION_TIME_FIELD, Operator.GTE, 0); + IgniteCriteria ttlNotifNotProcessed = new IgniteCriteria(IS_TTL_NOTIF_PROCESSED_FIELD, Operator.EQ, false); + IgniteCriteriaGroup criteriaGroup = new IgniteCriteriaGroup(ttlExpirationTimeExists).and(ttlNotifNotProcessed); + + IgniteQuery query = new IgniteQuery(criteriaGroup); + IgniteOrderBy igniteOrderBy = new IgniteOrderBy().byfield(TTL_EXPRIATION_TIME_FIELD); + query.orderBy(igniteOrderBy); + query.setPageNumber(DMAConstants.TOP_RECORD_NUMBER); + query.setPageSize(DMAConstants.TOP_RECORD_NUMBER); + + List offlineEntries = find(query); + + if (!offlineEntries.isEmpty()) { + logger.debug("Got offline entry with earliest TTL : {}", offlineEntries); + return offlineEntries.get(0); + } else { + logger.debug("No entries found in dmOfflineBufferEntries collection"); + return null; + } + } + + /** + * Fetch all records from offline buffer for which TTL has expired, + * and TTL value is greater than or equal to 0, + * and TTL notification has not already been processed. This check to avoid + * picking entries from the collection for which TTL value is set to default value i.e "-1" + * + * @return the offline buffer entries with expired ttl + */ + @Override + public List getOfflineBufferEntriesWithExpiredTtl() { + + long currTime = System.currentTimeMillis(); + IgniteCriteria criteriaExpiredTtl = new IgniteCriteria(TTL_EXPRIATION_TIME_FIELD, Operator.LTE, currTime); + IgniteCriteria criteriaExpiredTtlNonDefault = new IgniteCriteria(TTL_EXPRIATION_TIME_FIELD, Operator.GTE, 0); + IgniteCriteria ttlNotifNotProcessed = new IgniteCriteria(IS_TTL_NOTIF_PROCESSED_FIELD, Operator.EQ, false); + IgniteCriteriaGroup criteriaGroup = new IgniteCriteriaGroup(criteriaExpiredTtl) + .and(criteriaExpiredTtlNonDefault).and(ttlNotifNotProcessed); + IgniteQuery query = new IgniteQuery(criteriaGroup); + + List offlineEntries = find(query); + if (!offlineEntries.isEmpty()) { + logger.debug("Got {} offline entries {}", offlineEntries.size(), offlineEntries); + return offlineEntries; + } else { + logger.debug("No entries found in offline buffer with expiredTTL. Queried with time : {}", + LocalDateTime.ofEpochSecond(currTime, 0, ZoneOffset.UTC).toString()); + return Collections.emptyList(); + } + } + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceConnStatusDAO.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceConnStatusDAO.java new file mode 100644 index 0000000..5c856c3 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceConnStatusDAO.java @@ -0,0 +1,70 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.OffsetMetadata; +import org.eclipse.ecsp.analytics.stream.base.stores.MutableKeyValueStore; +import org.eclipse.ecsp.analytics.stream.base.stores.SortedKeyValueStore; +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdMapping; +import org.eclipse.ecsp.stream.dma.dao.key.DeviceStatusKey; + +import java.util.Optional; + + +/** + * interface DeviceConnStatusDAO extends MutableKeyValueStore. + */ +public interface DeviceConnStatusDAO extends MutableKeyValueStore, + SortedKeyValueStore { + + /** + * Gets the offset metadata. + * + * @param serviceName the service name + * @return the offset metadata + */ + public Optional getOffsetMetadata(String serviceName); + + /** + * Initialize. + */ + public void initialize(); + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceMessagingException.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceMessagingException.java new file mode 100644 index 0000000..682ff8c --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceMessagingException.java @@ -0,0 +1,56 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + + +/** + * class {@link DeviceMessagingException} extends {@link RuntimeException}. + */ +public class DeviceMessagingException extends RuntimeException { + + /** + * Instantiates a new device messaging exception. + * + * @param message the message + */ + public DeviceMessagingException(String message) { + super(message); + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusAPIInMemoryService.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusAPIInMemoryService.java new file mode 100644 index 0000000..f641d23 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusAPIInMemoryService.java @@ -0,0 +1,79 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdStatus; + + +/** + * A DAO layer for in-memory connection status cache. + * Check {@link DeviceStatusDaoInMemoryCache} for details on the in-memory HashMap data structure. + * + * @author hbadshah + * + + */ +public interface DeviceStatusAPIInMemoryService { + + /** + * Get from in-memory cache. + * + * @param vehicleId the vehicle id + * @return the vehicle id device id status + */ + public VehicleIdDeviceIdStatus get(String vehicleId); + + /** + * Puts data in in-memory cache if the data does not exist. Else, updates the data in in-memory cache. + * + * @param vehicleId the vehicle id + * @param deviceId the device id + * @param connectionStatus the connection status + */ + public void update(String vehicleId, String deviceId, String connectionStatus); + + /** + * Force get. + * + * @param vehicleId the vehicle id + * @param deviceId the device id + */ + public void forceGet(String vehicleId, String deviceId); +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusAPIInMemoryServiceImpl.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusAPIInMemoryServiceImpl.java new file mode 100644 index 0000000..572fde4 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusAPIInMemoryServiceImpl.java @@ -0,0 +1,132 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import org.eclipse.ecsp.analytics.stream.base.utils.InternalCacheConstants; +import org.eclipse.ecsp.domain.DeviceConnStatusV1_0.ConnectionStatus; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdStatus; +import org.eclipse.ecsp.stream.dma.dao.key.DeviceStatusKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + + +/** + * A DAO layer for in-memory connection status cache. + * Check {@link DeviceStatusDaoInMemoryCache} for details on the in-memory HashMap data structure. + * + * @author hbadshah + */ +@Service +public class DeviceStatusAPIInMemoryServiceImpl implements DeviceStatusAPIInMemoryService { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DeviceStatusAPIInMemoryServiceImpl.class); + + /** The device status dao. */ + @Autowired + private DeviceStatusDaoInMemoryCache deviceStatusDao; + + /** + * Gets the. + * + * @param vehicleId the vehicle id + * @return the vehicle id device id status + */ + @Override + public VehicleIdDeviceIdStatus get(String vehicleId) { + DeviceStatusKey key = new DeviceStatusKey(vehicleId); + //Get mapping for this vehicleId from in-memory cache. + VehicleIdDeviceIdStatus mapping = deviceStatusDao.get(key); + if (mapping != null) { + logger.debug("Mapping {} found for vehicleId {}", mapping.toString(), vehicleId); + return mapping; + } + logger.debug("No mapping found for vehicleId: {} in in-memory cache.", vehicleId); + return null; + } + + /** + * In this method, both put and update operations have been handled. + * If mapping found, update the connection status of the device in the + * mapping and put the mapping in in-memory. Else, create and add a + * new mapping for this vehicleId and put it in in-memory. + * + * @param vehicleId vehicleId + * @param deviceId the device id + * @param connectionStatus the connection status + */ + @Override + public void update(String vehicleId, String deviceId, String connectionStatus) { + DeviceStatusKey key = new DeviceStatusKey(vehicleId); + //Get mapping for this vehicleId from in-memory cache. + VehicleIdDeviceIdStatus mapping = deviceStatusDao.get(key); + if (mapping != null) { + mapping.addDeviceId(deviceId, connectionStatus); + //put the mapping in in-memory cache for this vehicleId + deviceStatusDao.put(key, mapping, InternalCacheConstants.CACHE_TYPE_DEVICE_CONN_STATUS_CACHE); + } else { + ConcurrentHashMap map = new ConcurrentHashMap<>(); + map.put(deviceId, ConnectionStatus.valueOf(connectionStatus)); + deviceStatusDao.putIfAbsent(key, new VehicleIdDeviceIdStatus(Version.V1_0, map), Optional.empty(), + InternalCacheConstants.CACHE_TYPE_DEVICE_CONN_STATUS_CACHE); + } + logger.debug("Updated connection status for vehicleId {} and deviceId " + + "{} as {} in in-memory.", vehicleId, deviceId, connectionStatus); + } + + /** + * In case a requirement comes for DMA to hit the API forcefully for some scenario, + * then to handle such a case, implementation can be + * provided in this method. + * + * @param vehicleId the vehicle id + * @param deviceId the device id + */ + @Override + public void forceGet(String vehicleId, String deviceId) { + // method to force get status + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusDaoCacheBackedInMemoryImpl.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusDaoCacheBackedInMemoryImpl.java new file mode 100644 index 0000000..cc79a3a --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusDaoCacheBackedInMemoryImpl.java @@ -0,0 +1,144 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import jakarta.annotation.PostConstruct; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.dao.CacheBackedInMemoryBatchCompleteCallBack; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.OffsetMetadata; +import org.eclipse.ecsp.analytics.stream.base.stores.CachedMapStateStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdMapping; +import org.eclipse.ecsp.stream.dma.dao.key.DeviceStatusKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + + +/** + * DeviceStatusCacheBackedInMemoryDAO is an implementation of + * DeviceStatusDAO where the DAO layer is CacheBackedInMemoryDAOImpl (fusion of + * In-Memory Map store and Redis). + * Whenever querying for device status the input deviceId + * should be of the format DEVICE_STATUS_{@code <}SERVICE{@code >}_{@code <}deviceID{@code >}. + * + * @author avadakkootko + */ +@Repository +public class DeviceStatusDaoCacheBackedInMemoryImpl extends + CachedMapStateStore implements DeviceConnStatusDAO { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DeviceStatusDaoCacheBackedInMemoryImpl.class); + + /** The latest offset metadata. */ + private OffsetMetadata latestOffsetMetadata; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The sub services. */ + @Value("${" + PropertyNames.SUB_SERVICES + ":}") + private String subServices; + + /** + * Sets the service name. + * + * @param serviceName the new service name + */ + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + /** + * Get the latest TopicPartition and offset value of Kafka Consumer from Redis. + * + * @param serviceName the service name + * @return the offset metadata + */ + @Override + public Optional getOffsetMetadata(String serviceName) { + return Optional.of(latestOffsetMetadata); + } + + /** + * initialize(). + */ + @PostConstruct + public void initialize() { + long currentTime = System.currentTimeMillis(); + setCallBack(new DmaBatchCompleteCallBack()); + setPersistInIgniteCache(false); + long endTime = System.currentTimeMillis(); + logger.info("Time taken to Initialize DeviceStatusDAO is {} seconds", + (endTime - currentTime) / Constants.THOUSAND); + } + + /** + * Sets the sub services. + * + * @param subServices the new sub services + */ + public void setSubServices(String subServices) { + this.subServices = subServices; + } + + /** + * The Class DmaBatchCompleteCallBack. + */ + class DmaBatchCompleteCallBack implements CacheBackedInMemoryBatchCompleteCallBack { + + /** + * Batch completed. + * + * @param processedRecords the processed records + */ + @Override + public void batchCompleted(List processedRecords) { + // Auto-generated method stub + } + + } + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusDaoInMemoryCache.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusDaoInMemoryCache.java new file mode 100644 index 0000000..1638a12 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusDaoInMemoryCache.java @@ -0,0 +1,73 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import jakarta.annotation.PostConstruct; +import org.eclipse.ecsp.analytics.stream.base.stores.CachedMapStateStore; +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdStatus; +import org.eclipse.ecsp.stream.dma.dao.key.DeviceStatusKey; +import org.springframework.stereotype.Repository; + + +/** + * In addition to DeviceStatusDaoCacheBackedInMemoryImpl + * data structure, DMA maintains below data structure too. + * Where key of the in-memory map is vehicleId wrapped in + * DeviceStatusKey and value against each key is an instance of + * VehicleIdDeviceIdToStatusMapping which in turn contains + * a mapping of deviceId to its connection status. + * It differs from the existing in-memory DS in the sense that, + * existing stores mapping of vehicleId-VehicleIdDeviceIdMapping and + * this one stores mapping of vehicleId-VehicleIdDeviceIdStatus + * RDNG: 170506, 170507 + * + * + */ +@Repository +public class DeviceStatusDaoInMemoryCache extends CachedMapStateStore { + + /** + * Inits the. + */ + @PostConstruct + public void init() { + setPersistInIgniteCache(false); + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusService.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusService.java new file mode 100644 index 0000000..2788cde --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusService.java @@ -0,0 +1,109 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.MutationId; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.OffsetMetadata; +import org.eclipse.ecsp.utils.ConcurrentHashSet; + +import java.util.Optional; + + +/** + * {@link DeviceStatusService}. + */ +public interface DeviceStatusService { + + /** + * Gets the. + * + * @param vehicleId the vehicle id + * @param subService the sub service + * @return the concurrent hash set + */ + public ConcurrentHashSet get(String vehicleId, Optional subService); + + /** + * Put. + * + * @param vehicleId the vehicle id + * @param deviceIds the device ids + * @param mutationId the mutation id + * @param subService the sub service + */ + public void put(String vehicleId, ConcurrentHashSet deviceIds, + Optional mutationId, Optional subService); + + /** + * Delete. + * + * @param vehicleId the vehicle id + * @param deviceId the device id + * @param mutationId the mutation id + * @param subService the sub service + */ + public void delete(String vehicleId, String deviceId, Optional mutationId, Optional subService); + + /** + * Delete key. + * + * @param vehicleId the vehicle id + * @param mutationId the mutation id + */ + public void deleteKey(String vehicleId, Optional mutationId); + + /** + * Gets the offset metadata. + * + * @param serviceName the service name + * @return the offset metadata + */ + public Optional getOffsetMetadata(String serviceName); + + /** + * Bypass in-memory key store and read from cache. + * + * @param vehicleId vehicleId + * @param subService the sub service + * @return ConcurrentHashSet + */ + public ConcurrentHashSet forceGet(String vehicleId, Optional subService); + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusServiceImpl.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusServiceImpl.java new file mode 100644 index 0000000..49fabb6 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DeviceStatusServiceImpl.java @@ -0,0 +1,340 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.exception.InvalidServiceNameException; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.MutationId; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.OffsetMetadata; +import org.eclipse.ecsp.analytics.stream.base.utils.InternalCacheConstants; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdMapping; +import org.eclipse.ecsp.stream.dma.dao.key.DeviceStatusKey; +import org.eclipse.ecsp.utils.ConcurrentHashSet; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + + +/** + * DeviceStatusServiceImpl interacts with the DAO layer. + * Whenever querying for device status the input deviceId should be of the + * format DEVICE_STATUS_{@code <}SERVICE{@code >}_{@code <}deviceID{@code >} + * + * @author avadakkootko + */ +@Service +public class DeviceStatusServiceImpl implements DeviceStatusService { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DeviceStatusServiceImpl.class); + + /** The sub service to parent key mapping. */ + /* + * Below map will contain sub-service's name to its corresponding redis parent key's name. + * As part of RTC 355420. + */ + private Map subServiceToParentKeyMapping = new HashMap<>(); + + /** The map parent key. */ + /* + * In case of no sub-services, there will be just one redis parent key for device status data/map in redis. + * Below variable will hold that parent key's name. + * As part of RTC 355420. + */ + private String mapParentKey = null; + + /** The device status dao. */ + @Autowired + private DeviceConnStatusDAO deviceStatusDao; + + + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The sub services. */ + @Value("${" + PropertyNames.SUB_SERVICES + ":}") + private String subServices; + + /** + * Accepts vehicleId as an argument and returns the deviceId. + * Key should be of the format VEHICLE_DEVICE_MAPPING_service.name_vehicleId. + * If there is no value present for above format of the key, then it implies + * status is INACTIVE. + * Optional subService: RTC 355420. If there exist sub-services + * under one service, then read operation for device status should be + * performed at sub-service level rather than just on service level. + * + * @param key the key + * @param subService the sub service + * @return the concurrent hash set + */ + @Override + public ConcurrentHashSet get(String key, Optional subService) { + + DeviceStatusKey deviceStatusKey = null; + String redisMapKey = mapParentKey; + if (subService.isPresent()) { + String keyWithSubService = key + DMAConstants.SEMI_COLON + subService.get(); + deviceStatusKey = new DeviceStatusKey(keyWithSubService); + redisMapKey = StringUtils.isEmpty(redisMapKey) ? subServiceToParentKeyMapping.get(subService.get()) + : redisMapKey; + } else { + deviceStatusKey = new DeviceStatusKey(key); + } + ConcurrentHashSet deviceIds = null; + VehicleIdDeviceIdMapping mapping = deviceStatusDao.get(deviceStatusKey); + if (mapping != null) { + logger.debug("Received VehicleIdDeviceIdMapping from in-memory cache as {}", mapping.toString()); + deviceIds = mapping.getDeviceIds(); + if (deviceIds == null || deviceIds.isEmpty()) { + logger.warn("DeviceId not present in VehicleIdDeviceIdMapping hence forcing it to query from redis- " + + "mapParentKey {} , deviceStatusKey-key {} ,deviceStatusKey {}", mapParentKey, + deviceStatusKey.getKey(), deviceStatusKey); + deviceIds = forceGet(redisMapKey, new DeviceStatusKey(key)); + updateInMemoryMap(deviceStatusKey, deviceIds, mapping); + } + } else { + // Force to read from redis if vehicle Inactive + deviceIds = forceGet(redisMapKey, new DeviceStatusKey(key)); + updateInMemoryMap(deviceStatusKey, deviceIds, new VehicleIdDeviceIdMapping()); + } + logger.debug("DeviceId for VehicleId key {} is {}", key, deviceIds); + return deviceIds; + } + + /** + * Update in memory map. + * + * @param deviceStatusKey the device status key + * @param deviceIds the device ids + * @param mapping the mapping + */ + private void updateInMemoryMap(DeviceStatusKey deviceStatusKey, ConcurrentHashSet deviceIds, + VehicleIdDeviceIdMapping mapping) { + if (deviceIds != null) { + mapping.setDeviceIds(deviceIds); + // Put the data in in-memory map + deviceStatusDao.put(deviceStatusKey, mapping, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_DEVICE_CONN_STATUS_CACHE); + } + } + + /** + * Put. + * + * @param key the key + * @param deviceIds the device ids + * @param mutationId the mutation id + * @param subService the sub service + */ + @Override + public void put(String key, ConcurrentHashSet deviceIds, + Optional mutationId, Optional subService) { + DeviceStatusKey deviceStatusKey = null; + String redisMapKey = mapParentKey; + if (subService.isPresent()) { + String keyWithSubService = key + DMAConstants.SEMI_COLON + subService.get(); + deviceStatusKey = new DeviceStatusKey(keyWithSubService); + redisMapKey = StringUtils.isEmpty(redisMapKey) ? subServiceToParentKeyMapping.get(subService.get()) + : redisMapKey; + } else { + deviceStatusKey = new DeviceStatusKey(key); + } + deviceStatusDao.putIfAbsent(deviceStatusKey, new VehicleIdDeviceIdMapping(Version.V1_0, deviceIds), + Optional.empty(), InternalCacheConstants.CACHE_TYPE_DEVICE_CONN_STATUS_CACHE); + VehicleIdDeviceIdMapping mapping = deviceStatusDao.get(deviceStatusKey); + mapping.setDeviceIds(deviceIds); + deviceStatusDao.putToMap(redisMapKey, deviceStatusKey, mapping, mutationId, + InternalCacheConstants.CACHE_TYPE_DEVICE_CONN_STATUS_CACHE); + logger.info("Key {}, Value {} updated in cache", key, mapping.toString()); + } + + /** + * Delete operation can be performed at key level or for a granular level of + * deviceId by passing and optional argument deviceId. + * + * @param key the key + * @param deviceId the device id + * @param mutationId the mutation id + * @param subService the sub service + */ + @Override + public void delete(String key, String deviceId, Optional mutationId, Optional subService) { + String vehicleIdDeviceIdStatusParentKey = mapParentKey; + if (subService.isPresent()) { + key = key + DMAConstants.SEMI_COLON + subService.get(); + vehicleIdDeviceIdStatusParentKey = subServiceToParentKeyMapping.get(subService.get()); + } + DeviceStatusKey deviceStatusKey = new DeviceStatusKey(key); + logger.debug("In delete Mapping for key {}", deviceStatusKey.convertToString()); + VehicleIdDeviceIdMapping mapping = deviceStatusDao.get(deviceStatusKey); + if (mapping == null) { + logger.warn("No VehicleIdDeviceIdMapping instance found to delete for key {}", key); + return; + } + logger.debug("Attempting to delete Device Status in cache for key {}, deviceId {}, with mapping {}", + key, deviceId, mapping.toString()); + if (mapping.deleteDeviceId(deviceId)) { + logger.info("DeviceID {} deleted for key {}, from mapping instance {}", deviceId, key, mapping.toString()); + if (mapping.getDeviceIds().isEmpty()) { + deleteKey(key, mutationId); + } else { + deviceStatusDao.putToMap(vehicleIdDeviceIdStatusParentKey, deviceStatusKey, mapping, mutationId, + InternalCacheConstants.CACHE_TYPE_DEVICE_CONN_STATUS_CACHE); + } + } + } + + /** + * Get the latest TopicPartition and offset value of Kafka Consumer from Redis. + * + * @param serviceName the service name + * @return the offset metadata + */ + @Override + public Optional getOffsetMetadata(String serviceName) { + return deviceStatusDao.getOffsetMetadata(serviceName); + } + + /** + * Delete key. + * + * @param vehicleId the vehicle id + * @param mutationId the mutation id + */ + @Override + public void deleteKey(String vehicleId, Optional mutationId) { + DeviceStatusKey deviceStatusKey = new DeviceStatusKey(vehicleId); + logger.debug("Attempting to Delete Device Status in cache for key {}", vehicleId); + if (subServiceToParentKeyMapping.size() > 0) { + String[] arr = vehicleId.split(":"); + String subService = arr[arr.length - 1]; + deviceStatusDao.deleteFromMap(subServiceToParentKeyMapping.get(subService), deviceStatusKey, + mutationId, InternalCacheConstants.CACHE_TYPE_DEVICE_CONN_STATUS_CACHE); + } else { + deviceStatusDao.deleteFromMap(mapParentKey, deviceStatusKey, mutationId, + InternalCacheConstants.CACHE_TYPE_DEVICE_CONN_STATUS_CACHE); + } + } + + /** + * initKey(). + */ + @PostConstruct + public void initKey() { + if (StringUtils.isEmpty(serviceName)) { + throw new InvalidServiceNameException("Service name cannot be empty for DeviceStatusService"); + } + if (StringUtils.isNotEmpty(subServices)) { + List subServicesList = Arrays.asList(subServices.split(",")); + for (String subService : subServicesList) { + subServiceToParentKeyMapping.put(subService, + DMAConstants.VEHICLE_DEVICE_MAPPING + subService); + } + logger.info("Sub-Service to VEHICLE_DEVICE_MAPPING initialized as {}", subServiceToParentKeyMapping); + } else { + mapParentKey = DMAConstants.VEHICLE_DEVICE_MAPPING + serviceName; + } + } + + /** + * Force get. + * + * @param vehicleId the vehicle id + * @param subServiceOpt the sub service opt + * @return the concurrent hash set + */ + @Override + public ConcurrentHashSet forceGet(String vehicleId, Optional subServiceOpt) { + String key = vehicleId; + if (subServiceOpt.isPresent()) { + String subService = subServiceOpt.get(); + if (StringUtils.isEmpty(subServiceToParentKeyMapping.get(subService))) { + logger.error("No vehicleDeviceID mapping key found for subservice {} in " + + "subServiceToParentKeyMapping : {}", subService, subServiceToParentKeyMapping); + return new ConcurrentHashSet<>(); + } + String vehicleIdDeviceIdStatusParentKey = subServiceToParentKeyMapping.get(subService); + return forceGet(vehicleIdDeviceIdStatusParentKey, new DeviceStatusKey(key)); + } else { + return forceGet(mapParentKey, new DeviceStatusKey(key)); + } + } + + /** + * Force get. + * + * @param mapKey the map key + * @param mapEntryKey the map entry key + * @return the concurrent hash set + */ + private ConcurrentHashSet forceGet(String mapKey, DeviceStatusKey mapEntryKey) { + ConcurrentHashSet deviceIds = null; + VehicleIdDeviceIdMapping vehicleIdDeviceIdMapping = deviceStatusDao.forceGet(mapKey, mapEntryKey); + if (vehicleIdDeviceIdMapping != null) { + deviceIds = vehicleIdDeviceIdMapping.getDeviceIds(); + } + logger.debug("Force get for mapParentKey {}, key {} retured deviceIds {}", mapKey, + mapEntryKey.convertToString(), deviceIds); + return deviceIds; + } + + /** + * Sets the sub services. + * + * @param subServices the new sub services + */ + public void setSubServices(String subServices) { + this.subServices = subServices; + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/DmaRetryBucketDao.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DmaRetryBucketDao.java new file mode 100644 index 0000000..c9f4306 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/DmaRetryBucketDao.java @@ -0,0 +1,79 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import org.eclipse.ecsp.analytics.stream.base.stores.MutableKeyValueStore; +import org.eclipse.ecsp.analytics.stream.base.stores.SortedKeyValueStore; +import org.eclipse.ecsp.entities.dma.RetryRecordIds; +import org.eclipse.ecsp.stream.dma.dao.key.RetryBucketKey; + + +/** + * interface DMARetryBucketDAO extends MutableKeyValueStore. + */ +public interface DmaRetryBucketDao extends MutableKeyValueStore, + SortedKeyValueStore { + + /** + * Update. + * + * @param mapKey the map key + * @param key the key + * @param messageId the message id + */ + public void update(String mapKey, RetryBucketKey key, String messageId); + + /** + * Delete message id. + * + * @param mapKey the map key + * @param key the key + * @param messageId the message id + */ + public void deleteMessageId(String mapKey, RetryBucketKey key, String messageId); + + /** + * Initialize. + * + * @param taskId the task id + */ + public void initialize(String taskId); + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryBucketDAO.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryBucketDAO.java new file mode 100644 index 0000000..1c2bb77 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryBucketDAO.java @@ -0,0 +1,80 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import org.eclipse.ecsp.analytics.stream.base.stores.MutableKeyValueStore; +import org.eclipse.ecsp.analytics.stream.base.stores.SortedKeyValueStore; +import org.eclipse.ecsp.entities.dma.RetryRecordIds; +import org.eclipse.ecsp.stream.dma.dao.key.ShoulderTapRetryBucketKey; + + +/** + * interface {@link ShoulderTapRetryBucketDAO} extends {@link MutableKeyValueStore}, {@link SortedKeyValueStore}. + */ +public interface ShoulderTapRetryBucketDAO + extends MutableKeyValueStore, + SortedKeyValueStore { + + /** + * Update. + * + * @param mapKey the map key + * @param key the key + * @param vehicleId the vehicle id + */ + public void update(String mapKey, ShoulderTapRetryBucketKey key, String vehicleId); + + /** + * Delete vehicle id. + * + * @param mapKey the map key + * @param key the key + * @param vehicleId the vehicle id + */ + public void deleteVehicleId(String mapKey, ShoulderTapRetryBucketKey key, String vehicleId); + + /** + * Initialize. + * + * @param taskId the task id + */ + public void initialize(String taskId); + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryBucketDAOCacheImpl.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryBucketDAOCacheImpl.java new file mode 100644 index 0000000..4b7a67c --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryBucketDAOCacheImpl.java @@ -0,0 +1,157 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import jakarta.annotation.PostConstruct; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.stores.CachedSortedMapStateStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.InternalCacheConstants; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.dma.RetryRecordIds; +import org.eclipse.ecsp.stream.dma.dao.key.ShoulderTapRetryBucketKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Repository; + +import java.util.Comparator; +import java.util.Optional; + + +/** + * class {@link ShoulderTapRetryBucketDAOCacheImpl} + * extends {@link CachedSortedMapStateStore} + * implements {@link ShoulderTapRetryBucketDAO}. + */ +@Repository +@Scope("prototype") +public class ShoulderTapRetryBucketDAOCacheImpl extends + CachedSortedMapStateStore implements ShoulderTapRetryBucketDAO { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(ShoulderTapRetryBucketDAOCacheImpl.class); + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** + * Sets the service name. + * + * @param serviceName the new service name + */ + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + /** + * Update vehicleId in the set of strings that needs to be retried corresponding to timestamp. + * + * @param mapKey the map key + * @param key the key + * @param vehicleId the vehicle id + */ + @Override + public void update(String mapKey, ShoulderTapRetryBucketKey key, String vehicleId) { + putToMapIfAbsent(mapKey, key, new RetryRecordIds(Version.V1_0), Optional.empty(), + InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_BUCKET); + RetryRecordIds vehicleIds = get(key); + vehicleIds.addRecordId(vehicleId); + putToMap(mapKey, key, vehicleIds, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_BUCKET); + logger.debug("Updated vehicleId {} in ShoulderTapRetryBucketDAO for key {}", vehicleId, key.getTimestamp()); + + } + + /** + * Delete vehicleId from the set of messageIds to be retried for a timestamp. + * + * @param mapKey the map key + * @param key the key + * @param vehicleId the vehicle id + */ + @Override + public void deleteVehicleId(String mapKey, ShoulderTapRetryBucketKey key, String vehicleId) { + RetryRecordIds vehicleIds = get(key); + if (vehicleIds.deleteRecordId(vehicleId)) { + logger.debug("Deleted vehicleId {} in ShoulderTapRetryBucketDAO for key {}", vehicleId, key); + putToMap(mapKey, key, vehicleIds, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_BUCKET); + } else { + logger.error("vehicleId {} is not present in ShoulderTapRetryBucketDAO for key {}. " + + "Delete op cannot be performed.", vehicleId, key.getTimestamp()); + } + + } + + /** + * Initialize. + * + * @param taskId the task id + */ + @Override + public void initialize(String taskId) { + final long currentTime = System.currentTimeMillis(); + StringBuilder regexBuilder = new StringBuilder(); + regexBuilder.append(DMAConstants.SHOULDER_TAP_RETRY_BUCKET).append(DMAConstants.COLON).append(serviceName) + .append(DMAConstants.COLON).append(taskId); + ShoulderTapRetryBucketKey converter = new ShoulderTapRetryBucketKey(); + //passing the taskId to CacheSortedMapStore, which in turn will pass it to setup of CacheBypass. + setTaskId(taskId); + syncWithMapCache(regexBuilder.toString(), converter, + InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_BUCKET); + long endTime = System.currentTimeMillis(); + logger.info("Time taken to Initialize ShoulderTapRetryBucketDAO for taskId {} is {} seconds", taskId, + (endTime - currentTime) / Constants.THOUSAND); + } + + /** + * setup(). + */ + @PostConstruct + public void setup() { + Comparator comparator = + Comparator.comparingLong(ShoulderTapRetryBucketKey::getTimestamp); + createStoreWithComparator(comparator); + } + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryRecordDAO.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryRecordDAO.java new file mode 100644 index 0000000..798fb92 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryRecordDAO.java @@ -0,0 +1,65 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import org.eclipse.ecsp.analytics.stream.base.stores.MutableKeyValueStore; +import org.eclipse.ecsp.analytics.stream.base.stores.SortedKeyValueStore; +import org.eclipse.ecsp.entities.dma.RetryRecord; +import org.eclipse.ecsp.stream.dma.dao.key.RetryVehicleIdKey; + + +/** + * DMARetryEventDAO is responsible for storing Key value pairs which will be + * messageId and corresponding IgniteEvent to be retried. + * Key should be of the format : RETRY_MESSADEID_{@code <}SERVICENAME{@code >}_{@code <}MESSAGEID{@code >} + * Value will be DMARetryEvent which contains IgniteKey and IgniteEvent. + * + * @author avadakkootko + */ +public interface ShoulderTapRetryRecordDAO extends MutableKeyValueStore, + SortedKeyValueStore { + + /** + * Initialize. + * + * @param taskId the task id + */ + public void initialize(String taskId); +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryRecordDAOCacheImpl.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryRecordDAOCacheImpl.java new file mode 100644 index 0000000..16189d8 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/ShoulderTapRetryRecordDAOCacheImpl.java @@ -0,0 +1,104 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.stores.CachedMapStateStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.InternalCacheConstants; +import org.eclipse.ecsp.entities.dma.RetryRecord; +import org.eclipse.ecsp.stream.dma.dao.key.RetryVehicleIdKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Repository; + + +/** + * DMARetryRecordDAOCacheBackedInMemoryImpl is extends CachedMapStateStore + * which is a generic concurrent hash map that periodically backs up + * data to cache (Redis). + * + * @author avadakkootko + */ +@Repository +@Scope("prototype") +public class ShoulderTapRetryRecordDAOCacheImpl extends CachedMapStateStore + implements ShoulderTapRetryRecordDAO { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(ShoulderTapRetryRecordDAOCacheImpl.class); + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** + * Sets the service name. + * + * @param serviceName the new service name + */ + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + /** + * Initialize. + * + * @param taskId the task id + */ + @Override + public void initialize(String taskId) { + final long currentTime = System.currentTimeMillis(); + StringBuilder regexBuilder = new StringBuilder(); + regexBuilder.append(DMAConstants.SHOULDER_TAP_RETRY_VEHICLEID) + .append(serviceName).append(DMAConstants.COLON).append(taskId); + //passing the taskId down to CachedMapStateStore, using this taskId, + // CacheBypass's setup will be invoked for this taskId for this class. + setTaskId(taskId); + syncWithMapCache(regexBuilder.toString(), new RetryVehicleIdKey(), + InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_RECORD); + long endTime = System.currentTimeMillis(); + logger.info("Time taken to Initialize RetryRecordDAO for taskId {} is {} seconds", taskId, + (endTime - currentTime) / Constants.THOUSAND); + } + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/AbstractRetryBucketKey.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/AbstractRetryBucketKey.java new file mode 100644 index 0000000..6176ef2 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/AbstractRetryBucketKey.java @@ -0,0 +1,99 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao.key; + +import org.eclipse.ecsp.analytics.stream.base.stores.CacheKeyConverter; + + +/** + * AbstractRetryBucketKey implements {@link Comparable} and {@link CacheKeyConverter}. + * + * @param the generic type + */ + +public abstract class AbstractRetryBucketKey implements Comparable, CacheKeyConverter { + + /** The timestamp. */ + private long timestamp; + + /** + * Instantiates a new abstract retry bucket key. + */ + protected AbstractRetryBucketKey() { + } + + /** + * Instantiates a new abstract retry bucket key. + * + * @param timestamp the timestamp + */ + protected AbstractRetryBucketKey(long timestamp) { + this.timestamp = timestamp; + } + + /** + * Gets the timestamp. + * + * @return the timestamp + */ + public long getTimestamp() { + return timestamp; + } + + /** + * Sets the timestamp. + * + * @param timestamp the new timestamp + */ + public void setTimestamp(long timestamp) { + this.timestamp = timestamp; + } + + /** + * Convert to string. + * + * @return the string + */ + @Override + public String convertToString() { + return String.valueOf(getTimestamp()); + } + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/DeviceStatusKey.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/DeviceStatusKey.java new file mode 100644 index 0000000..81bf314 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/DeviceStatusKey.java @@ -0,0 +1,89 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao.key; + +import org.eclipse.ecsp.analytics.stream.base.stores.CacheKeyConverter; +import org.eclipse.ecsp.entities.dma.AbstractDeviceStatusKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; + + +/** + * DeviceStatusKey extends AbstractDeviceStatusKey implements CacheKeyConverter. + */ +public class DeviceStatusKey extends AbstractDeviceStatusKey implements CacheKeyConverter { + + /** + * Instantiates a new device status key. + */ + public DeviceStatusKey() { + } + + /** + * Instantiates a new device status key. + * + * @param key the key + */ + public DeviceStatusKey(String key) { + super(key); + } + + /** + * getMapKey(). + * + * @param serviceName serviceName + * @return String serviceName + */ + public static String getMapKey(String serviceName) { + StringBuilder regexBuilder = new StringBuilder() + .append(DMAConstants.VEHICLE_DEVICE_MAPPING).append(serviceName); + return regexBuilder.toString(); + } + + /** + * Convert from. + * + * @param key the key + * @return the device status key + */ + @Override + public DeviceStatusKey convertFrom(String key) { + return new DeviceStatusKey(key); + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/RetryBucketKey.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/RetryBucketKey.java new file mode 100644 index 0000000..f311eda --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/RetryBucketKey.java @@ -0,0 +1,110 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao.key; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; + + +/** + * It is used to denote the timestamp or bucket consisting of messageIds which will be retried. + * + * @author avadakkootko + */ +public class RetryBucketKey extends AbstractRetryBucketKey { + + /** + * Instantiates a new retry bucket key. + */ + public RetryBucketKey() { + super(); + } + + /** + * Instantiates a new retry bucket key. + * + * @param timestamp the timestamp + */ + public RetryBucketKey(long timestamp) { + super(timestamp); + } + + /** + * getMapKey(). + * + * @param serviceName serviceName + * @param taskId taskId + * @return String + */ + public static String getMapKey(String serviceName, String taskId) { + StringBuilder regexBuilder = new StringBuilder().append(DMAConstants.RETRY_BUCKET).append(DMAConstants.COLON) + .append(serviceName).append(DMAConstants.COLON).append(taskId); + return regexBuilder.toString(); + } + + /** + * Convert from. + * + * @param key the key + * @return the retry bucket key + */ + @Override + public RetryBucketKey convertFrom(String key) { + return new RetryBucketKey(Long.parseLong(key)); + } + + /** + * Compare to. + * + * @param obj the obj + * @return the int + */ + @Override + public int compareTo(Object obj) { + RetryBucketKey comparableObj = (RetryBucketKey) obj; + long x = this.getTimestamp(); + long y = comparableObj.getTimestamp(); + if (x < y) { + return Constants.NEGATIVE_ONE; + } else { + return (x == y) ? 0 : 1; + } + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/RetryRecordKey.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/RetryRecordKey.java new file mode 100644 index 0000000..3d6ef31 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/RetryRecordKey.java @@ -0,0 +1,207 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao.key; + +import org.eclipse.ecsp.analytics.stream.base.stores.CacheKeyConverter; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; + + +/** + * This key is used to identify the record that has to be retried. + * + * @author avadakkootko + */ +public class RetryRecordKey implements CacheKeyConverter { + + /** The key. */ + private String key; + + /** The task id. */ + private String taskId; + + /** + * Instantiates a new retry record key. + */ + public RetryRecordKey() { + } + + /** + * Instantiates a new retry record key. + * + * @param key the key + * @param taskId the task id + */ + public RetryRecordKey(String key, String taskId) { + this.key = key; + this.taskId = taskId; + } + + /** + * Creates the vehicle part. + * + * @param vehicleId the vehicle id + * @param messageId the message id + * @return the string + */ + public static String createVehiclePart(String vehicleId, String messageId) { + return (vehicleId + DMAConstants.SEMI_COLON + messageId); + } + + /** + * getMapKey(). + * + * @param serviceName serviceName + * @param taskId taskId + * @return String + */ + public static String getMapKey(String serviceName, String taskId) { + StringBuilder regexBuilder = new StringBuilder().append(DMAConstants.RETRY_MESSAGEID).append(DMAConstants.COLON) + .append(serviceName).append(DMAConstants.COLON).append(taskId); + return regexBuilder.toString(); + } + + /** + * Gets the key. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Sets the key. + * + * @param key the new key + */ + public void setKey(String key) { + this.key = key; + } + + /** + * Gets the task ID. + * + * @return the task ID + */ + public String getTaskID() { + return taskId; + } + + /** + * Sets the task id. + * + * @param taskId the new task id + */ + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + /** + * Convert from. + * + * @param key the key + * @return the retry record key + */ + @Override + public RetryRecordKey convertFrom(String key) { + String[] keySplit = key.split(DMAConstants.COLON); + return new RetryRecordKey(keySplit[1], keySplit[0]); + } + + /** + * Convert to string. + * + * @return the string + */ + @Override + public String convertToString() { + return taskId + DMAConstants.COLON + key; + } + + /** + * Hash code. + * + * @return the int + */ + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((key == null) ? 0 : key.hashCode()); + result = prime * result + ((taskId == null) ? 0 : taskId.hashCode()); + return result; + } + + /** + * Equals. + * + * @param obj the obj + * @return true, if successful + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + RetryRecordKey other = (RetryRecordKey) obj; + if (key == null) { + if (other.key != null) { + return false; + } + } else if (!key.equals(other.key)) { + return false; + } + if (taskId == null) { + if (other.taskId != null) { + return false; + } + } else if (!taskId.equals(other.taskId)) { + return false; + } + return true; + } + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/RetryVehicleIdKey.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/RetryVehicleIdKey.java new file mode 100644 index 0000000..a1802f5 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/RetryVehicleIdKey.java @@ -0,0 +1,165 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao.key; + +import org.eclipse.ecsp.analytics.stream.base.stores.CacheKeyConverter; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; + + +/** + * This key is used to identify the record that has to be retried. + * + * @author avadakkootko + */ +public class RetryVehicleIdKey implements CacheKeyConverter { + + /** The key. */ + private String key; + + /** + * Instantiates a new retry vehicle id key. + */ + public RetryVehicleIdKey() { + } + + /** + * Instantiates a new retry vehicle id key. + * + * @param key the key + */ + public RetryVehicleIdKey(String key) { + this.key = key; + } + + /** + * getMapKey(): to fetch the key. + * + * @param serviceName serviceName + * @param taskId taskId + * @return String + */ + public static String getMapKey(String serviceName, String taskId) { + StringBuilder regexBuilder = new StringBuilder() + .append(DMAConstants.SHOULDER_TAP_RETRY_VEHICLEID).append(DMAConstants.COLON) + .append(serviceName).append(DMAConstants.COLON).append(taskId); + return regexBuilder.toString(); + } + + /** + * Gets the key. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Sets the key. + * + * @param key the new key + */ + public void setKey(String key) { + this.key = key; + } + + /** + * Convert from. + * + * @param key the key + * @return the retry vehicle id key + */ + @Override + public RetryVehicleIdKey convertFrom(String key) { + return new RetryVehicleIdKey(key); + } + + /** + * Convert to string. + * + * @return the string + */ + @Override + public String convertToString() { + return key; + } + + /** + * Hash code. + * + * @return the int + */ + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((key == null) ? 0 : key.hashCode()); + return result; + } + + /** + * Equals. + * + * @param obj the obj + * @return true, if successful + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + RetryVehicleIdKey other = (RetryVehicleIdKey) obj; + if (key == null) { + if (other.key != null) { + return false; + } + } else if (!key.equals(other.key)) { + return false; + } + return true; + } + +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/ShoulderTapRetryBucketKey.java b/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/ShoulderTapRetryBucketKey.java new file mode 100644 index 0000000..c8286c2 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/dao/key/ShoulderTapRetryBucketKey.java @@ -0,0 +1,112 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao.key; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; + + +/** + * This class will be used as a key to retry shoulder tap while waking up a Vehicle. + * + * @author avadakkootko + */ +public class ShoulderTapRetryBucketKey extends AbstractRetryBucketKey { + + /** + * Instantiates a new shoulder tap retry bucket key. + */ + public ShoulderTapRetryBucketKey() { + super(); + } + + /** + * Instantiates a new shoulder tap retry bucket key. + * + * @param timestamp the timestamp + */ + public ShoulderTapRetryBucketKey(long timestamp) { + super(timestamp); + } + + /** + * getMapKey(): to create the key using StringBuilder. + * + * @param serviceName serviceName + * @param taskId taskId + * @return String + */ + public static String getMapKey(String serviceName, String taskId) { + StringBuilder regexBuilder = new StringBuilder() + .append(DMAConstants.SHOULDER_TAP_RETRY_BUCKET).append(DMAConstants.COLON) + .append(serviceName).append(DMAConstants.COLON).append(taskId); + return regexBuilder.toString(); + } + + /** + * Convert from. + * + * @param key the key + * @return the shoulder tap retry bucket key + */ + @Override + public ShoulderTapRetryBucketKey convertFrom(String key) { + return new ShoulderTapRetryBucketKey(Long.parseLong(key)); + } + + /** + * Compare to. + * + * @param obj the obj + * @return the int + */ + @Override + public int compareTo(Object obj) { + ShoulderTapRetryBucketKey comparableObj = (ShoulderTapRetryBucketKey) obj; + long x = this.getTimestamp(); + long y = comparableObj.getTimestamp(); + if (x < y) { + return Constants.INT_MINUS_ONE; + } else { + return (x == y) ? 0 : 1; + } + } + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/handler/DMABackdoorKafkaConsumer.java b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DMABackdoorKafkaConsumer.java new file mode 100644 index 0000000..4a39577 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DMABackdoorKafkaConsumer.java @@ -0,0 +1,84 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.apache.kafka.streams.KafkaStreams.State; +import org.eclipse.ecsp.analytics.stream.base.KafkaStateAgentListener; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + + +/** + * {@link DMABackdoorKafkaConsumer} implements {@link KafkaStateAgentListener}. + */ +@Component +public class DMABackdoorKafkaConsumer implements KafkaStateAgentListener { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DMABackdoorKafkaConsumer.class); + + /** The device status back door kafka consumer. */ + @Autowired + private DeviceStatusBackDoorKafkaConsumer deviceStatusBackDoorKafkaConsumer; + + /** + * dma.enabled flag is linked with devicestatuskafkaconsumer as both are components of dma. + */ + @Value("${" + PropertyNames.DMA_ENABLED + ":true}") + private boolean isDmaEnabled; + + /** + * On change. + * + * @param newState the new state + * @param oldState the old state + */ + @Override + public void onChange(State newState, State oldState) { + if (isDmaEnabled) { + logger.info("Attempting to Start DMABackDoorKafkaConsumer..."); + deviceStatusBackDoorKafkaConsumer.startDMABackDoorConsumer(); + } + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/handler/DefaultPostDispatchHandler.java b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DefaultPostDispatchHandler.java new file mode 100644 index 0000000..a7a30da --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DefaultPostDispatchHandler.java @@ -0,0 +1,97 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + + +/** + *DefaultPostDispatchHandler is responsible for handling the messages which have + *been successfully published to MQTT via DispatchHandler. + * + * @author karora + * + */ +@Component +@Scope("prototype") +@ConditionalOnProperty(name = PropertyNames.DMA_ENABLED, havingValue = "true") +public class DefaultPostDispatchHandler implements DeviceMessageHandler { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DefaultPostDispatchHandler.class); + + /** + * Handle. + * + * @param key the key + * @param value the value + */ + @Override + public void handle(IgniteKey key, DeviceMessage value) { + logger.debug("DefaultPostDispatchHandler invoked. Message published successfully to MQTT with key : {} and " + + "vehicleID : {}", key, value.getDeviceMessageHeader().getVehicleId()); + } + + /** + * Sets the next handler. + * + * @param handler the new next handler + */ + @Override + public void setNextHandler(DeviceMessageHandler handler) { + //Overridden method + } + + + /** + * Close. + */ + @Override + public void close() { + //Overridden method + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceConnectionStatusHandler.java b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceConnectionStatusHandler.java new file mode 100644 index 0000000..4f27c0c --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceConnectionStatusHandler.java @@ -0,0 +1,872 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.exception.InvalidVehicleIDException; +import org.eclipse.ecsp.analytics.stream.base.exception.OfflineBufferEntriesException; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.BackdoorKafkaConsumerCallback; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.OffsetMetadata; +import org.eclipse.ecsp.analytics.stream.base.platform.utils.PlatformUtils; +import org.eclipse.ecsp.analytics.stream.base.utils.ConnectionStatusRetriever; +import org.eclipse.ecsp.analytics.stream.base.utils.ObjectUtils; +import org.eclipse.ecsp.domain.DeviceConnStatusV1_0; +import org.eclipse.ecsp.domain.DeviceConnStatusV1_0.ConnectionStatus; +import org.eclipse.ecsp.domain.DeviceMessageFailureEventDataV1_0; +import org.eclipse.ecsp.entities.AbstractIgniteEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageErrorCode; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdStatus; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntry; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntryDAOMongoImpl; +import org.eclipse.ecsp.stream.dma.dao.DeviceMessagingException; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusAPIInMemoryService; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusDaoCacheBackedInMemoryImpl; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusDaoInMemoryCache; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusService; +import org.eclipse.ecsp.stream.dma.presencemanager.DeviceFetchConnectionStatusProducer; +import org.eclipse.ecsp.stream.dma.scheduler.DeviceMessagingEventScheduler; +import org.eclipse.ecsp.stream.dma.shouldertap.DeviceShoulderTapService; +import org.eclipse.ecsp.utils.ConcurrentHashSet; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.eclipse.ecsp.utils.metrics.IgniteErrorCounter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + + +/** + * DeviceConnectionStatusHandler is responsble for maintaining cache of + * DeviceStatus and taking appropriate measures based on the device + * status. + * It also has the DeviceStatusCallBack which maintains the DeviceStatus + * in Cache and triggers the processing of messages stored in + * OfflineBuffer when device is ACTIVE. + * Update TargetDeviceId before forwarding it to the next handle. + * + * @author avadakkootko + */ +@Component +@Scope("prototype") +@ConditionalOnProperty(name = PropertyNames.DMA_ENABLED, havingValue = "true") +public class DeviceConnectionStatusHandler implements DeviceMessageHandler { + + /** The ecu types. */ + private static List ecuTypes = null; + + /** The ctx. */ + @Autowired + private ApplicationContext ctx; + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DeviceConnectionStatusHandler.class); + + /** The next handler. */ + private DeviceMessageHandler nextHandler; + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** The task id. */ + private String taskId; + + /** The offline buffer DAO. */ + @Autowired + private DMOfflineBufferEntryDAOMongoImpl offlineBufferDAO; + + /** The device status back door kafka consumer. */ + @Autowired + private DeviceStatusBackDoorKafkaConsumer deviceStatusBackDoorKafkaConsumer; + + /** The device service. */ + @Autowired + private DeviceStatusService deviceService; + + /** The device service in memory. */ + @Autowired + private DeviceStatusAPIInMemoryService deviceServiceInMemory; + + /** The device status dao. */ + @Autowired + private DeviceStatusDaoCacheBackedInMemoryImpl deviceStatusDao; + + /** The device status API dao. */ + @Autowired + private DeviceStatusDaoInMemoryCache deviceStatusAPIDao; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The scheduler enabled. */ + @Value("${" + PropertyNames.SCHEDULER_ENABLED + ":true}") + private String schedulerEnabled; + + /** The ttl expiry notification enabled. */ + @Value("${" + PropertyNames.DMA_TTL_EXPIRY_NOTIFICATION_ENABLED + ":true}") + private String ttlExpiryNotificationEnabled; + + /** The offline buffer per device. */ + @Value("${" + PropertyNames.OFFLINE_BUFFER_PER_DEVICE + ":false}") + private boolean offlineBufferPerDevice; + + /** The device shoulder tap service. */ + @Autowired + private DeviceShoulderTapService deviceShoulderTapService; + + /** The device message utils. */ + @Autowired + private DeviceMessageUtils deviceMessageUtils; + + /** The error counter. */ + @Autowired + private IgniteErrorCounter errorCounter; + + /** The event scheduler. */ + @Autowired + private DeviceMessagingEventScheduler eventScheduler; + + /** The filter DM offline entry impl class. */ + @Value("${" + PropertyNames.FILTER_DM_OFFLINE_BUFFER_ENTRIES_IMPL + + ": org.eclipse.ecsp.stream.dma.handler.NoFilterDMOfflineBufferEntryImpl}") + private String filterDMOfflineEntryImplClass; + + /** The fetch conn status producer. */ + @Autowired + private DeviceFetchConnectionStatusProducer fetchConnStatusProducer; + + /** The platform utils. */ + @Autowired + private PlatformUtils platformUtils; + + /** The filtered buffer entry. */ + FilterDMOfflineBufferEntry filteredBufferEntry; + + /** The connection status retriever. */ + private ConnectionStatusRetriever connectionStatusRetriever; + + /** The events to S kip offline buffer. */ + /* + * CR-1758 property which will hold events that will not be saved to + * offline buffer in DMA + */ + @Value("${" + PropertyNames.DMA_EVENTS_SKIP_ONLINE_BUFFER + ":}") + private String eventsToSKipOfflineBuffer; + + /** The sub services. */ + /* + * RTC 355420. DMA should have the functionality to track device connection + * status at sub-service level, if configured any. + */ + @Value("${" + PropertyNames.SUB_SERVICES + ":}") + private String subServices; + + /** The conn status retriever impl class. */ + @Value("${" + PropertyNames.DMA_CONNECTION_STATUS_RETRIEVER_IMPL + ":" + + PropertyNames.DEFAULT_CONNECTION_STATUS_RETRIEVER_IMPL + "}") + private String connStatusRetrieverImplClass; + + /** The skip offline buffer events. */ + private List skipOfflineBufferEvents = new ArrayList<>(); + + /** The sub services list. */ + private List subServicesList = new ArrayList<>(); + + /** The process per sub service. */ + private boolean processPerSubService = false; + + /** + * Sets the up ecu types. + * + * @param ecuTypes the new up ecu types + */ + private static void setupEcuTypes(List ecuTypes) { + DeviceConnectionStatusHandler.ecuTypes = ecuTypes; + } + + /** + * Gets the service name. + * + * @return the service name + */ + public String getServiceName() { + return serviceName; + } + + /** + * Sets the service name. + * + * @param serviceName the new service name + */ + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + /** + * Sets the skip offline buffer events. + * + * @param skipOfflineBufferEvents the new skip offline buffer events + */ + public void setSkipOfflineBufferEvents(List skipOfflineBufferEvents) { + this.skipOfflineBufferEvents = skipOfflineBufferEvents; + } + + /** + * Validates whether certain instances have been initialized. + */ + @PostConstruct + public void validate() { + filteredBufferEntry = (FilterDMOfflineBufferEntry) + platformUtils.getInstanceByClassName(filterDMOfflineEntryImplClass); + connectionStatusRetriever = (ConnectionStatusRetriever) + platformUtils.getInstanceByClassName(connStatusRetrieverImplClass); + ObjectUtils.requireNonNull(deviceStatusBackDoorKafkaConsumer, + "Uninitialized Backdoor KafkaConsumer Factory."); + if (StringUtils.isNotEmpty(subServices)) { + subServicesList = Arrays.asList(subServices.trim().split(",")); + logger.info("Sub services configured are {}", subServicesList); + processPerSubService = true; + } + } + + /** + * Check is the Event is deviceRoutable and forward it to the next handler for further processing. + * + * @param key the key + * @param entity the entity + */ + @Override + public void handle(IgniteKey key, DeviceMessage entity) { + DeviceMessageHeader header = entity.getDeviceMessageHeader(); + if (header.isGlobalTopicNameProvided()) { + nextHandler.handle(key, entity); + return; + } + String vehicleId = header.getVehicleId(); + if (StringUtils.isEmpty(vehicleId)) { + logger.error("VehicleId not set in IgniteEvent {} for IgniteKey {}", entity, key); + throw new InvalidVehicleIDException("VehicleId not set in IgniteEvent " + entity + " for IgniteKey " + key); + } + if (isDeviceActive(key, entity)) { + logger.debug("Forwarding the request with requestId {} to next handler.", header.getRequestId()); + nextHandler.handle(key, entity); + } else { + handleDeviceInactiveState(key, entity); + } + } + + /** + * Checks if is device active. + * + * @param key the key + * @param entity the entity + * @return true, if is device active + */ + private boolean isDeviceActive(IgniteKey key, DeviceMessage entity) { + DeviceMessageHeader header = entity.getDeviceMessageHeader(); + String vehicleId = header.getVehicleId(); + if (entity.isOtherBrokerConfigured()) { + ConnectionStatus connStatus = getConnectionStatus(header); + return connStatus != null && connStatus.toString().equals(DMAConstants.ACTIVE); + } else { + Optional deviceId = getDeviceIdIfActive(key, header, vehicleId); + if (deviceId.isPresent()) { + header.withTargetDeviceId(deviceId.get()); + } + return deviceId.isPresent(); + } + } + + /** + *

    + * Searches for connection status data for a vehicleId-deviceId + * pair in the in-memory first, if not found in in-memory, then get the + * connection status data from the third party API. + * + * Connection status can be null in two cases: + * + * 1. No data exists for this VIN in in-memory map. + * + * 2. If multiple devices are associated with a VIN & data exists for + * this VIN in in-memory but not for this targetDeviceId. + * + * Eg. Suppose vehicleId = vin123 and devices associated are d1 and d2. + * Now, in in-memory, following mapping exists: vin123 = + * {d1=ACTIVE} and request comes for targetDeviceId = d2. In this case, + * + * status will be null for d2 device and it will be fetched from + * the API. Suppose API returned INACTIVE status for d2 device, + * then updated mapping for this VIN in in-memory will look like: + * + * vin123 = {d1=ACTIVE,d2=INACTIVE} + * + * Check {@link DefaultDeviceConnectionStatusRetriever#getConnectionStatusData + * (String, String, String)} for more on how it fetches data from + * the third party API. + *

    + * + * @param header header + * @return {@link ConnectionStatus} + */ + protected ConnectionStatus getConnectionStatus(DeviceMessageHeader header) { + String vehicleId = header.getVehicleId(); + String targetDeviceId = header.getTargetDeviceId(); + VehicleIdDeviceIdStatus mapping = deviceServiceInMemory.get(vehicleId); + ConnectionStatus status = null; + if (mapping != null && mapping.getDeviceIds().containsKey(targetDeviceId)) { + logger.debug("Received connection status of vehicleId {} and deviceId {} from in-memory as {}", vehicleId, + targetDeviceId, mapping.getDeviceIds().get(targetDeviceId)); + return mapping.getDeviceIds().get(targetDeviceId); + } else { + // Get the connection status from a third party API and update the + // same in in-memory cache. + logger.debug("Fetching connection status from the API for vehicleId {} and deviceId {}", + vehicleId, targetDeviceId); + mapping = connectionStatusRetriever.getConnectionStatusData(header.getRequestId(), + vehicleId, targetDeviceId); + if (mapping != null) { + // Add a new mapping in in-memory for this vehicleId + deviceServiceInMemory.update(vehicleId, targetDeviceId, + mapping.getDeviceIds().get(targetDeviceId).toString()); + // update the local variable status' value + status = mapping.getDeviceIds().get(targetDeviceId); + } + } + return status; + } + + /** + * Handle device inactive state. + * + * @param key the key + * @param entity the entity + */ + protected void handleDeviceInactiveState(IgniteKey key, DeviceMessage entity) { + DeviceMessageFailureEventDataV1_0 data = new DeviceMessageFailureEventDataV1_0(); + data.setFailedIgniteEvent(entity.getEvent()); + data.setErrorCode(DeviceMessageErrorCode.DEVICE_STATUS_INACTIVE); + data.setDeviceStatusInactive(true); + deviceMessageUtils.postFailureEvent(data, key, spc, entity.getFeedBackTopic()); + DeviceMessageHeader header = entity.getDeviceMessageHeader(); + String vehicleId = header.getVehicleId(); + String subService = processPerSubService ? entity.getDeviceMessageHeader() + .getDevMsgTopicSuffix().toLowerCase() : null; + + // check to see from application properties if we need to skip any event + // for offline buffer + // and null check is handled in case no event ID is presnt (test cases + if (entity.getEvent().getEventId() == null + || skipOfflineBufferEvents.stream().noneMatch(entity.getEvent().getEventId()::equalsIgnoreCase)) { + logger.debug("Connection status for vehicleId{} and service {} is INACTIVE. " + + "Adding to OfflineBuffer", vehicleId, serviceName); + offlineBufferDAO.addOfflineBufferEntry(vehicleId, key, entity, subService); + + // WI-374794 Create a scheduler for entry added to offline buffer if + // scheduler enabled + if (Boolean.parseBoolean(schedulerEnabled) && Boolean.parseBoolean(ttlExpiryNotificationEnabled)) { + eventScheduler.scheduleEvent(key, entity, spc); + } + } + String requestId = header.getRequestId(); + boolean shoulderTapEnabled = header.isShoulderTapEnabled(); + if (shoulderTapEnabled) { + Map extraParameters = new HashMap<>(); + String bizTransactionId = entity.getEvent().getBizTransactionId(); + extraParameters.put(DMAConstants.BIZ_TRANSACTION_ID, bizTransactionId); + deviceShoulderTapService.wakeUpDevice(requestId, vehicleId, serviceName, key, entity, extraParameters); + } + } + + /** + *

    + * Retrun deviceId is if vehicleId to deviceId mapping is present + * in cache which indicates the status was ACTIVE. + * + * First check if targetId is present, if present check the + * mapping between vehicleId to targetDeviceId. + * + * Else next chek if sourceDeviceId is set, if yes check the + * mapping between vehicleId to sourceDeviceId. + * + * If neither of the above options are available then check for the + * mapping in cache for the given vehicleID. If mappings are not + * greater than 1 then use that as the targetDeviceId. + *

    + * + * @param key the key + * @param header header + * @param vehicleId vehicleId + * @return String + * @throws DeviceMessagingException DeviceMessagingException + */ + protected Optional getDeviceIdIfActive(IgniteKey key, DeviceMessageHeader header, String vehicleId) { + String deviceId = null; + String targetDeviceId = header.getTargetDeviceId(); + ConcurrentHashSet deviceIdsInCache = null; + /* + * If a service has sub-services configured, then in the service's SP while constructing the IgniteEvent, + * before forwarding the event to DMA, it has to set the sub-service this event is intended for through + * devMsgTopicSuffix property. Otherwise DMA won't know. + */ + String subService = header.getDevMsgTopicSuffix() != null ? header.getDevMsgTopicSuffix().toLowerCase() : null; + deviceIdsInCache = getDeviceIdsInCache(vehicleId, subService); + if (CollectionUtils.isEmpty(deviceIdsInCache)) { + if (StringUtils.isNotEmpty(header.getPlatformId())) { + getDeviceStatusFromPresenceManager(key, header); + } + return Optional.empty(); + } + + if (StringUtils.isNotEmpty(targetDeviceId) && deviceIdsInCache.contains(targetDeviceId)) { + deviceId = targetDeviceId; + } else { + if (deviceIdsInCache.size() == 1) { + deviceId = deviceIdsInCache.iterator().next(); + } else { + logger.error("{} cannot be forwarded to DeviceMessaging as there are multiple vehicleId to " + + "deviceId mappings present.", header); + throw new DeviceMessagingException("Multiple forwards not allowed for key :" + vehicleId); + } + } + return Optional.ofNullable(deviceId); + } + + /** + * Gets the device status from presence manager. + * + * @param key the key + * @param header the header + * @return the device status from presence manager + */ + private void getDeviceStatusFromPresenceManager(IgniteKey key, DeviceMessageHeader header) { + fetchConnStatusProducer.pushEventToFetchConnStatus(key, header, spc); + } + + /** + * Gets the device ids in cache. + * + * @param vehicleId the vehicle id + * @param subService the sub service + * @return the device ids in cache + */ + private ConcurrentHashSet getDeviceIdsInCache(String vehicleId, String subService) { + ConcurrentHashSet deviceIdsInCache; + if (StringUtils.isNotEmpty(subService) && processPerSubService && subServicesList.contains(subService)) { + deviceIdsInCache = deviceService.get(vehicleId, Optional.of(subService)); + } else { + deviceIdsInCache = deviceService.get(vehicleId, Optional.empty()); + } + return deviceIdsInCache; + } + + /** + * DeviceStatusCallBack received an Igniteevent with event data + * DeviceConnStatusV1 from Kafka Back door Consumer. + * If Device Connection status changes from INACTIVE to ACTIVE. Messges are + * retrieved from Offline buffer and processed. + * The DeviceStatus is updated in cache. + + * @author avadakkootko + */ + public class DeviceStatusCallBack implements BackdoorKafkaConsumerCallback { + + /** + * Process. + * + * @param key the key + * @param value the value + * @param meta the meta + */ + @Override + public void process(IgniteKey key, IgniteEvent value, OffsetMetadata meta) { + logger.debug("Received in DeviceStatusCallBack with IgniteKey {} and IgniteEvent {}", key, value); + if (key != null && value.getEventData() != null) { + try { + DeviceConnStatusV1_0 deviceStatus = (DeviceConnStatusV1_0) value.getEventData(); + logger.debug("DeviceConnStatusV1 {}", deviceStatus); + String deviceId = value.getSourceDeviceId(); + String vehicleId = value.getVehicleId(); + /* + * In case sub-services are configured for the service, then + * in the connection status payload, against serviceName + * property, hivemq will tell DMA which VIN/device this + * payload is for. And, based on same sub-service name DMA + * will query events from mongo for the same sub-service + * because in case of sub-services, we need events from + * mongo based on VIN && sub-service. + */ + String subService = deviceStatus.getServiceName().toLowerCase(); + String ecuType = value.getEcuType() != null ? value.getEcuType().toLowerCase() : null; + boolean validSubService = (StringUtils.isNotEmpty(subService) + && subServicesList.contains(subService)); + boolean validEcuType = (StringUtils.isNotEmpty(ecuType) + && DeviceConnectionStatusHandler.ecuTypes.contains(ecuType)); + + if (StringUtils.isNotEmpty(deviceId) && StringUtils.isNotEmpty(vehicleId)) { + performActionAsPerConnStatus(meta, deviceStatus, deviceId, vehicleId, subService, + validSubService, validEcuType); + } else { + logger.error("DeviceId {} or VehicleId {} not set for IgniteEvent {} in device status " + + "Kafka topic with IgniteKey {}", deviceId, vehicleId, value, key); + } + } catch (Exception e) { + errorCounter.incErrorCounter(Optional.ofNullable(taskId), e.getClass()); + logger.error("Error while processing DeviceStatusCallBack {}", e); + } + } else { + logger.error("Key {} or EventData {} cannot be null", key, value.getEventData()); + } + } + + /** + * Gets the committable offset. + * + * @return the committable offset + */ + @Override + public Optional getCommittableOffset() { + return Optional.empty(); + } + + /** + * Perform action as per conn status. + * + * @param meta the meta + * @param deviceStatus the device status + * @param deviceId the device id + * @param vehicleId the vehicle id + * @param subService the sub service + * @param validSubService the valid sub service + * @param validEcuType the valid ecu type + */ + private void performActionAsPerConnStatus(OffsetMetadata meta, DeviceConnStatusV1_0 deviceStatus, + String deviceId, String vehicleId, String subService, boolean validSubService, boolean validEcuType) { + String connStatus = deviceStatus.getConnStatus().getConnectionStatus(); + logger.info("Connection status from callback for vehicleId {}, deviceId {}, for service {} is {}", + vehicleId, deviceId, subService, connStatus); + if (connStatus.equals(DMAConstants.ACTIVE)) { + performActionWhenStatusActive(vehicleId, deviceId, meta, subService, validSubService, validEcuType); + } else if (connStatus.equals(DMAConstants.INACTIVE)) { + performActionWhenStatusInactive(vehicleId, deviceId, meta, subService, validSubService, validEcuType); + } + } + } + + /** + *

    + * In cache we store a mapping of VehicleId to DeviceId. + * If mapping is present it can be intepreted as DeviceId is ACTIVE and if mapping + * is not present viceversa. Key is prefixed with a constant VEHICLE_DEVICE_MAPPING_. + * + * When status is received as INACTIVE from hiveMq first + * we check if vehicleId is present as key in cache or not. + * + * If it is present in cache then the status is ACTIVE and no operation is performed. + * + * Else if vehicleId to deviceId mapping is not present then it + * means status was INACTIVE and it has changed to AVTIVE (i.e by storing + * the mapping between vehicleId and deviceId). After that we retrieve + * the events stored in mongo for this deviceId and push it to + * mqtt. + *

    + * + * @param vehicleId the vehicle id + * @param deviceId the device id + * @param offsetMeta the offset meta + * @param subService the sub service + * @param validSubService the valid sub service + * @param validEcuType the valid ecu type + */ + + public void performActionWhenStatusActive(String vehicleId, String deviceId, OffsetMetadata offsetMeta, + String subService, boolean validSubService, boolean validEcuType) { + /* + * Provide comments on below if, else condition + */ + if (validEcuType) { + deviceServiceInMemory.update(vehicleId, deviceId, DMAConstants.ACTIVE); + logger.info("Connection status for vehicleId {} and deviceId {} updated as ACTIVE in " + + "DeviceStatusAPIInMemoryService", vehicleId, deviceId); + } else { + ConcurrentHashSet deviceIdsInCache = validSubService + ? deviceService.get(vehicleId, Optional.of(subService)) + : deviceService.get(vehicleId, Optional.empty()); + createDeviceIdsInCacheDataSet(vehicleId, deviceId, offsetMeta, subService, + validSubService, deviceIdsInCache); + } + deviceShoulderTapService.executeOnDeviceActiveStatus(vehicleId, deviceId, serviceName); + + try { + List bufferedEntries = null; + if (offlineBufferPerDevice) { + bufferedEntries = offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.ofNullable(deviceId), + (validSubService) ? Optional.of(subService) : Optional.empty()); + logger.debug("{} OfflineBuffer entries found for vehicleId {}, deviceId {}", + bufferedEntries.size(), vehicleId, deviceId); + } else { + bufferedEntries = offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.empty(), (validSubService) ? Optional.of(subService) : Optional.empty()); + logger.debug("{} OfflineBuffer entries found for vehicleId {}", bufferedEntries.size(), + vehicleId); + } + if (!bufferedEntries.isEmpty()) { + bufferedEntries = filterBufferedEntries(vehicleId, bufferedEntries); + } + bufferedEntries.forEach(entry -> createAbstractIgniteEvent(vehicleId, deviceId, entry)); + } catch (Exception e) { + throw new OfflineBufferEntriesException("Error while getting offline buffer entries from Mongo for " + + "vehicleId " + vehicleId + " ,service - " + serviceName, e); + } + } + + /** + * Filter buffered entries. + * + * @param vehicleId the vehicle id + * @param bufferedEntries the buffered entries + * @return the list + */ + private List filterBufferedEntries(String vehicleId, + List bufferedEntries) { + int initialSize = bufferedEntries.size(); + bufferedEntries = filteredBufferEntry.filterAndUpdateDmOfflineBufferEntries(bufferedEntries); + int filteredSize = bufferedEntries.size(); + logger.debug("Out of {} OfflineBuffer entries {} filtered For vehicleId {}.", initialSize, + (initialSize - filteredSize), vehicleId); + return bufferedEntries; + } + + /** + * Creates the device ids in cache data set. + * + * @param vehicleId the vehicle id + * @param deviceId the device id + * @param offsetMeta the offset meta + * @param subService the sub service + * @param validSubService the valid sub service + * @param deviceIdsInCache the device ids in cache + */ + private void createDeviceIdsInCacheDataSet(String vehicleId, String deviceId, OffsetMetadata offsetMeta, + String subService, boolean validSubService, ConcurrentHashSet deviceIdsInCache) { + if (deviceIdsInCache == null) { + deviceIdsInCache = new ConcurrentHashSet<>(); + } + deviceIdsInCache.add(deviceId); + deviceService.put(vehicleId, deviceIdsInCache, Optional.ofNullable(offsetMeta), + validSubService ? Optional.of(subService) : Optional.empty()); + logger.info("VehicleId {} to DeviceId {} mapping updated in cache.", vehicleId, deviceId); + } + + /** + * Creates the abstract ignite event. + * + * @param vehicleId the vehicle id + * @param deviceId the device id + * @param entry the entry + */ + private void createAbstractIgniteEvent(String vehicleId, String deviceId, + DMOfflineBufferEntry entry) { + String targetDeviceIdFromHeader = (entry.getEvent().getDeviceMessageHeader() != null) + ? entry.getEvent().getDeviceMessageHeader().getTargetDeviceId() + : null; + if (StringUtils.isEmpty(targetDeviceIdFromHeader) || deviceId.equals(targetDeviceIdFromHeader)) { + AbstractIgniteEvent event = entry.getEvent().getEvent(); + Optional targetDeviceId = event.getTargetDeviceId(); + if (!targetDeviceId.isPresent()) { + event.setTargetDeviceId(deviceId); + } + String id = entry.getId(); + DeviceMessageHeader deviceMessageHeader = entry.getEvent().getDeviceMessageHeader(); + offlineBufferDAO.removeOfflineBufferEntry(id); + logger.info("OfflineBuffer entry with ID : {} and vehicleId : {}, requestId : {} " + + "and messageId : {} has been removed after processing.", id, vehicleId, + deviceMessageHeader.getRequestId(), deviceMessageHeader.getMessageId()); + handle(entry.getIgniteKey(), entry.getEvent()); + } + } + + /** + *

    + * In cache we store a mapping of VehicleId to DeviceId. If mapping + * is present it can be intepreted as DeviceId is ACTIVE and if mapping + * is not present viceversa. + * + * When status is received as INACTIVE from hiveMq first we check if + * vehicleId is present as key in cache or not. + * + * If it is present in cache then the status is ACTIVE (value + * being stored is still the deviceId) and it is deleted from cache. + * Else if it is not present no operation is performed. + *

    + * + * @param vehicleId the vehicle id + * @param deviceId the device id + * @param offsetMeta the offset meta + * @param subService the sub service + * @param validSubService the valid sub service + * @param validEcuType the valid ecu type + */ + public void performActionWhenStatusInactive(String vehicleId, String deviceId, OffsetMetadata offsetMeta, + String subService, boolean validSubService, boolean validEcuType) { + if (validEcuType) { + deviceServiceInMemory.update(vehicleId, deviceId, DMAConstants.INACTIVE); + } else { + ConcurrentHashSet deviceIdsInCache = validSubService + ? deviceService.get(vehicleId, Optional.of(subService)) + : deviceService.get(vehicleId, Optional.empty()); + if (deviceIdsInCache != null && deviceIdsInCache.contains(deviceId)) { + if (validSubService) { + deviceService.delete(vehicleId, deviceId, Optional.ofNullable(offsetMeta), Optional.of(subService)); + } else { + deviceService.delete(vehicleId, deviceId, Optional.ofNullable(offsetMeta), Optional.empty()); + } + logger.info("Deleted deviceId {} from cache", deviceId); + } else { + logger.debug("VehicleId {} to DeviceId {} mapping not present, hence status was already INACTIVE. " + + "No operation will be performed", vehicleId, deviceId); + } + } + } + + /** + * Sets the next handler. + * + * @param handler the new next handler + */ + @Override + public void setNextHandler(DeviceMessageHandler handler) { + nextHandler = handler; + + } + + /** + * Sets the stream processing context. + * + * @param ctx the ctx + */ + @Override + public void setStreamProcessingContext(StreamProcessingContext, IgniteEvent> ctx) { + spc = ctx; + int partition = Integer.parseInt(spc.getTaskID().split("_")[1]); + deviceShoulderTapService.setStreamProcessingContext(spc); + deviceStatusBackDoorKafkaConsumer.addCallback(new DeviceStatusCallBack(), partition); + skipOfflineBufferEvents = Arrays.asList(eventsToSKipOfflineBuffer.trim().split(",")); + } + + /** + * setup(). + * + * @param taskId taskId + * @param ecuTypes ecuTypes + */ + + public void setup(String taskId, List ecuTypes) { + this.taskId = taskId; + deviceShoulderTapService.setup(taskId); + setupEcuTypes(ecuTypes); + deviceStatusDao.setTaskId(taskId); + deviceStatusAPIDao.setTaskId(taskId); + } + + /** + * Sets the offline buffer per device. + * + * @param flag the new offline buffer per device + */ + protected void setOfflineBufferPerDevice(boolean flag) { + offlineBufferPerDevice = flag; + } + + /** + * Sets the filtered buffer entry. + * + * @param filteredBufferEntry the new filtered buffer entry + */ + // Providing setter for unit test case + void setFilteredBufferEntry(FilterDMOfflineBufferEntry filteredBufferEntry) { + this.filteredBufferEntry = filteredBufferEntry; + } + + /** + * Sets the sub services list. + * + * @param subServicesList the new sub services list + */ + void setSubServicesList(List subServicesList) { + this.subServicesList = subServicesList; + } + + /** + * Sets the process per sub service. + * + * @param processPerSubService the new process per sub service + */ + void setProcessPerSubService(boolean processPerSubService) { + this.processPerSubService = processPerSubService; + } + + /** + * Close. + */ + @Override + public void close() { + // overridden method + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceHeaderUpdater.java b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceHeaderUpdater.java new file mode 100644 index 0000000..c7e9837 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceHeaderUpdater.java @@ -0,0 +1,183 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.exception.HeaderUpdateException; +import org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + + +/** + * class DeviceHeaderUpdater implements DeviceMessageHandler. + */ +@Component +@Scope("prototype") +@ConditionalOnProperty(name = PropertyNames.DMA_ENABLED, havingValue = "true") +public class DeviceHeaderUpdater implements DeviceMessageHandler { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DeviceHeaderUpdater.class); + + /** The next handler. */ + private DeviceMessageHandler nextHandler; + + /** The msg id generator. */ + @Autowired + private GlobalMessageIdGenerator msgIdGenerator; + + /** The event header updation. */ + @Value("${" + PropertyNames.DMA_EVENT_HEADER_UPDATION_TYPE + ":}") + private String eventHeaderUpdation; + + /** + * Handle. + * + * @param key the key + * @param entity the entity + */ + @Override + public void handle(IgniteKey key, DeviceMessage entity) { + switch (eventHeaderUpdation) { + case DMAConstants.MESSAGEID: + entity = addMessageIdIfNotPresent(entity); + break; + case DMAConstants.MESSAGEID_AND_CORRELATIONID: + entity = addMessageIdAndCorrelationIdIfNotPresent(entity); + break; + default: + throw new HeaderUpdateException("Unknown method for eventId updation"); + } + nextHandler.handle(key, entity); + } + + /** + * Sets the next handler. + * + * @param handler the new next handler + */ + @Override + public void setNextHandler(DeviceMessageHandler handler) { + nextHandler = handler; + + } + + /** + * Add messageId if no messageId is set. + * + * @param entity the entity + * @return the device message + */ + public DeviceMessage addMessageIdIfNotPresent(DeviceMessage entity) { + DeviceMessageHeader header = entity.getDeviceMessageHeader(); + String currentMsgId = header.getMessageId(); + logger.debug("DMA header updation : Current MessageId {}", currentMsgId); + if (StringUtils.isEmpty(currentMsgId)) { + String msgIdGenerated = msgIdGenerator.generateUniqueMsgId(header.getVehicleId()); + header.withMessageId(msgIdGenerated); + logger.debug("New MessageId {} added to header by DMA", msgIdGenerated); + } + return entity; + } + + /** + * Set correlation Id as messageId if messageId is present. + * Update messageId to a new value before dispatching to device. + * + * @param entity DeviceMessage + * @return DeviceMessage + */ + public DeviceMessage addMessageIdAndCorrelationIdIfNotPresent(DeviceMessage entity) { + DeviceMessageHeader header = entity.getDeviceMessageHeader(); + String currentMsgId = header.getMessageId(); + logger.debug("DMA header updation : Current MessageId {}", currentMsgId); + + // Update correlationId + if (StringUtils.isNotEmpty(currentMsgId)) { + header.withCorrelationId(currentMsgId); + logger.debug("CorrelationId updated with MessageId {} by DMA", header.getCorrelationId()); + + } + + // Update messageId + String msgIdGenerated = msgIdGenerator.generateUniqueMsgId(header.getVehicleId()); + if (StringUtils.isNotEmpty(msgIdGenerated)) { + header.withMessageId(msgIdGenerated); + logger.debug("New MessageId {} added to header by DMA", msgIdGenerated); + } else { + logger.error("Generated MessageId is null of Empty"); + throw new HeaderUpdateException("Generated MessageId is null of Empty"); + } + logger.info("DMA updated event for requestId {} with messageId {} ", + header.getRequestId(), header.getMessageId()); + return entity; + } + + /** + * validate(). + */ + @PostConstruct + public void validate() { + if (StringUtils.isEmpty(eventHeaderUpdation)) { + throw new HeaderUpdateException("Event header updation type cannot be null or Empty"); + } + } + + /** + * Close. + */ + @Override + public void close() { + // Nothing to do as of now + } + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessageHandler.java b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessageHandler.java new file mode 100644 index 0000000..5a587b9 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessageHandler.java @@ -0,0 +1,82 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.key.IgniteKey; + + +/** + * interface DeviceMessageHandler. + */ +public interface DeviceMessageHandler { + + /** + * Handle. + * + * @param key the key + * @param value the value + */ + public void handle(IgniteKey key, DeviceMessage value); + + /** + * Sets the next handler. + * + * @param handler the new next handler + */ + public void setNextHandler(DeviceMessageHandler handler); + + /** + * Sets the stream processing context. + * + * @param ctx the ctx + */ + default void setStreamProcessingContext(StreamProcessingContext, IgniteEvent> ctx) { + // empty default implementation + } + + /** + * Close. + */ + public void close(); + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessageUtils.java b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessageUtils.java new file mode 100644 index 0000000..d03b2a1 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessageUtils.java @@ -0,0 +1,101 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator; +import org.eclipse.ecsp.domain.DeviceMessageFailureEventDataV1_0; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + + +/** + * Util class for {@link org.eclipse.ecsp.entities.dma.DeviceMessage}. + */ +@Component +public class DeviceMessageUtils { + + /** The msg id generator. */ + @Autowired + private GlobalMessageIdGenerator msgIdGenerator; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DeviceMessageUtils.class); + + /** + * postFailureEvent(). + * + * @param data data + * @param key key + * @param spc spc + * @param feedBackTopic feedBackTopic + */ + + public void postFailureEvent(DeviceMessageFailureEventDataV1_0 data, IgniteKey key, + StreamProcessingContext spc, String feedBackTopic) { + String requestId = data.getFailedIgniteEvent().getRequestId(); + IgniteEventImpl failureEvent = new IgniteEventImpl(); + failureEvent.setEventId(EventID.DEVICEMESSAGEFAILURE); + failureEvent.setTimestamp(System.currentTimeMillis()); + failureEvent.setRequestId(requestId); + failureEvent.setBizTransactionId(data.getFailedIgniteEvent().getBizTransactionId()); + failureEvent.setTimezone(data.getFailedIgniteEvent().getTimezone()); + failureEvent.setMessageId(msgIdGenerator.generateUniqueMsgId(data.getFailedIgniteEvent().getVehicleId())); + failureEvent.setVersion(Version.V1_0); + failureEvent.setEventData(data); + spc.forwardDirectly(key, failureEvent, feedBackTopic); + String payloadMsgId = data.getFailedIgniteEvent().getMessageId(); + logger.debug("{} feedback forwarded to topic {} for key {} with FailedIgniteEvent messageId {} ,requestId {} " + + "and FeebBackEvent messageId {} ", data.toString(), feedBackTopic, key, payloadMsgId, + requestId, failureEvent.getMessageId()); + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessageValidator.java b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessageValidator.java new file mode 100644 index 0000000..e22e5ab --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessageValidator.java @@ -0,0 +1,107 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + + +/** + * DeviceMessageValidator it checks is message is deviceRoutable. + * Other validations can be added here in future. + * + * @author avadakkootko + */ +@Component +@Scope("prototype") +@ConditionalOnProperty(name = PropertyNames.DMA_ENABLED, havingValue = "true") +public class DeviceMessageValidator implements DeviceMessageHandler { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DeviceMessageValidator.class); + + /** The next handler. */ + private DeviceMessageHandler nextHandler; + + /** + * Validate IgniteEvent by checking is it is deviceRoutable or not. + * + * @param key the key + * @param value the value + */ + @Override + public void handle(IgniteKey key, DeviceMessage value) { + if (value.getDeviceMessageHeader().isDeviceRoutable()) { + logger.debug("Value is DeviceRoutable."); + + // forward the message to the first handler in the chain. + nextHandler.handle(key, value); + } else { + logger.debug("Value is not DeviceRoutable. DeviceMessagingAgent will not further process."); + } + + } + + /** + * Sets the next handler. + * + * @param handler the new next handler + */ + @Override + public void setNextHandler(DeviceMessageHandler handler) { + nextHandler = handler; + + } + + /** + * Close. + */ + @Override + public void close() { + // Nothing to do as of now + } + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessagingHandlerChain.java b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessagingHandlerChain.java new file mode 100644 index 0000000..f4e671e --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessagingHandlerChain.java @@ -0,0 +1,383 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.exception.InvalidKeyOrValueException; +import org.eclipse.ecsp.analytics.stream.base.exception.InvalidSourceTopicException; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.config.DMAConfigResolver; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.transform.Transformer; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + + +/** + *

    + * DeviceMessagingGateway is the entry point or gateway for processing messages + * to be sent to Device. + * + * Its responsibility includes process chaining among the different Handlers + * which enriches the DeviceMessage IgniteEvent. + *

    + * + * @author avadakkootko + */ +@Lazy +@Component +@Scope("prototype") +@ConditionalOnProperty(name = PropertyNames.DMA_ENABLED, havingValue = "true") +public class DeviceMessagingHandlerChain { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DeviceMessagingHandlerChain.class); + + /** The device message validator. */ + @Autowired + private DeviceMessageValidator deviceMessageValidator; + + /** The header updater. */ + @Autowired + private DeviceHeaderUpdater headerUpdater; + + /** The conn status handler. */ + @Autowired + private DeviceConnectionStatusHandler connStatusHandler; + + /** The retry handler. */ + @Autowired + private RetryHandler retryHandler; + + /** The dispatch handler. */ + @Autowired + private DispatchHandler dispatchHandler; + + /** The ctx. */ + @Autowired + private ApplicationContext ctx; + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** The device messaging event transformer. */ + @Value("${" + PropertyNames.DEVICE_MESSAGING_EVENT_TRANSFORMER + "}") + private String deviceMessagingEventTransformer; + + /** The device message feedback topic. */ + @Value("${" + PropertyNames.DEVICE_MESSAGE_FEEDBACK_TOPIC + ":#{null}}") + private String deviceMessageFeedbackTopic; + + /** The dma config resolver impl class. */ + @Value("${" + PropertyNames.DMA_CONFIG_RESOLVER_CLASS + ":" + DMAConstants.DEFAULT_DMA_CONFIG_RESOLVER + "}") + private String dmaConfigResolverImplClass; + + /** The dma post dispatch handler class. */ + @Value("${" + PropertyNames.DMA_POST_DISPATCH_HANDLER_CLASS + ":" + + DMAConstants.DMA_DEFAULT_POST_DISPATCH_HANDLER_CLASS + "}") + private String dmaPostDispatchHandlerClass; + + /** + *

    + * DMA should have the capability to dispatch the DeviceMessage for various ecuTypes to various brokers + * if configured so. + * By default DMA dispatches to MQTT broker but if below property is configured correctly, it will be able to + * dispatch to other brokers as well for respective ecuTypes. + * + * The @Value annotation will give us a list, at every index of which, + * we will have broker to ecuType-topicName mapping. + * For eg: at 0 index: kafka:ecuType1#kafkaTopic1,ecuType2#kafkaTopic2 + * at 1 index: rabbitMQ:ecuType3#kafkaTopic3,ecuType4#kafkaTopic4 + * + * In above example kakfa and rabbitMQ are the names + * of the brokers where DMA has to dispatch data. + * ecuType1,ecuType2 are the names of the ecuTypes for which data + * will be dispatched to kafkaTopic1 and kafkaTopic2 of the + * respective broker. + * Same goes for rabbitMQ. + *

    + */ + @Value("#{'${" + PropertyNames.DMA_DISPATCHER_ECU_TYPES + "}'.split(';')}") + private List ecuTypes; + + /** The broker to ecu types mapping. */ + private Map> brokerToEcuTypesMapping = null; + + /** The ecu types list. */ + private List ecuTypesList = null; + + /** The dm event transformer. */ + private Transformer dmEventTransformer; + + /** The config resolver. */ + private DMAConfigResolver configResolver; + + /** The dma post dispatch handler. */ + private DeviceMessageHandler dmaPostDispatchHandler; + + /** + * handle(). + * + * @param key key + * @param value value + */ + public void handle(IgniteKey key, IgniteEvent value) { + logger.info("DeviceMessagingGateway processing event with requestId {} and messageId {} ", value.getRequestId(), + value.getMessageId()); + + IgniteEventImpl event = (IgniteEventImpl) value; + if (value.isDeviceRoutable()) { + deviceMessageValidator.handle(key, getDeviceRoutableEntity(event)); + } + } + + /** + * Gets the device routable entity. + * + * @param value the value + * @return the device routable entity + */ + private DeviceMessage getDeviceRoutableEntity(IgniteEventImpl value) { + byte[] payLoad = dmEventTransformer.toBlob(value); + DeviceMessage deviceRoutableEntity; + if (StringUtils.isEmpty(spc.streamName())) { + throw new InvalidSourceTopicException( + "SourceTopic unavailable in Stream Processing Context for IgniteEvent " + value.toString() + + ". Cannot Proceed Forward"); + } + long retryIntervalAtEventLevel = configResolver.getRetryInterval(value); + + if (retryIntervalAtEventLevel <= 0) { + throw new IllegalArgumentException( + "Received retry interval from DMAConfigResolver as " + + retryIntervalAtEventLevel + ", should be greater than zero "); + } + + if (StringUtils.isEmpty(deviceMessageFeedbackTopic)) { + deviceRoutableEntity = new DeviceMessage(payLoad, Version.V1_0, + value, spc.streamName(), retryIntervalAtEventLevel); + } else { + deviceRoutableEntity = new DeviceMessage(payLoad, Version.V1_0, + value, deviceMessageFeedbackTopic, retryIntervalAtEventLevel); + } + /* + * Since we have brokerToEcuTypesMapping here in this class, hence to avoid processing in + * DeviceConnectionStatusHandler, which route to take any DeviceMessage from, one field introduced in + * DeviceMessage which will contain the information whether this DeviceMessage has to be published to + * some other broker than HiveMQ. + */ + if (brokerToEcuTypesMapping != null && !brokerToEcuTypesMapping.isEmpty()) { + String ecuType = value.getEcuType().toLowerCase(); + for (Map.Entry> entry : brokerToEcuTypesMapping.entrySet()) { + Map map = entry.getValue(); + if (map.containsKey(ecuType) && !StringUtils.isEmpty(map.get(ecuType))) { + deviceRoutableEntity.isOtherBrokerConfigured(true); + } + } + } + return deviceRoutableEntity; + } + + /** + * Gets the instance by class name. + * + * @param canonicalClassName the canonical class name + * @return the instance by class name + */ + private Object getInstanceByClassName(String canonicalClassName) { + Object instance = null; + Class classObject = null; + try { + classObject = getClass().getClassLoader().loadClass(canonicalClassName); + instance = ctx.getBean(classObject); + logger.info("Class {} loaded from spring application context", classObject.getName()); + } catch (Exception ex) { + try { + if (classObject == null) { + throw new IllegalArgumentException("Could not load the class " + canonicalClassName); + } + logger.info("Class {} could not be loaded as spring bean. Attempting to create new instance.", + canonicalClassName); + instance = classObject.getDeclaredConstructor().newInstance(); + } catch (Exception exception) { + String msg = String.format("Class %s could not be loaded. Not found on classpath.%n", + canonicalClassName); + logger.error(msg + ExceptionUtils.getStackTrace(exception)); + throw new IllegalArgumentException(msg); + } + } + return instance; + } + + + /** + * Construct process chain. + * + * @param taskId the task id + * @param spc the spc + */ + public void constructChain(String taskId, StreamProcessingContext, IgniteEvent> spc) { + if (spc == null) { + throw new InvalidKeyOrValueException("Stream Processing Context is null. Cannot Proceed Forward"); + } + this.spc = spc; + retryHandler.setStreamProcessingContext(spc); + connStatusHandler.setStreamProcessingContext(spc); + dispatchHandler.setStreamProcessingContext(spc); + dmaPostDispatchHandler.setStreamProcessingContext(spc); + connStatusHandler.setup(taskId, ecuTypesList); + retryHandler.setup(taskId); + dispatchHandler.setup(taskId, brokerToEcuTypesMapping); + retryHandler.setConnStatusHandler(connStatusHandler); + + deviceMessageValidator.setNextHandler(connStatusHandler); + connStatusHandler.setNextHandler(headerUpdater); + headerUpdater.setNextHandler(retryHandler); + retryHandler.setNextHandler(dispatchHandler); + dispatchHandler.setNextHandler(dmaPostDispatchHandler); + logger.info("Chained : DeviceMessageValidator -> DeviceIdUpdator -> ConnectionStatusHandler -> " + + "RetryHandler -> DispatchHandler -> PostDispatchHandler"); + } + + /** + * setUp(). + */ + @PostConstruct + public void setUp() { + if (StringUtils.isEmpty(deviceMessagingEventTransformer)) { + throw new IllegalArgumentException(PropertyNames + .DEVICE_MESSAGING_EVENT_TRANSFORMER + " unavailable in property file"); + } + dmEventTransformer = (Transformer) getInstanceByClassName(deviceMessagingEventTransformer); + logger.debug("Device Messaging Event Transformer initialized is {}", deviceMessagingEventTransformer); + + if (dmaConfigResolverImplClass == null) { + dmaConfigResolverImplClass = DMAConstants.DEFAULT_DMA_CONFIG_RESOLVER; + } + configResolver = (DMAConfigResolver) getInstanceByClassName(dmaConfigResolverImplClass); + dmaPostDispatchHandler = (DeviceMessageHandler) getInstanceByClassName(dmaPostDispatchHandlerClass); + populateMap(); + } + + /** + * To understand the below parsing, refer to the comment over {@link #ecuTypes} property. + * This map will be passed to the DispatchHandler where the decision as to which dispatcher to hand the + * DeviceMessage to, will be taken. + */ + private void populateMap() { + if (ecuTypes != null && !ecuTypes.isEmpty()) { + brokerToEcuTypesMapping = new HashMap<>(); + for (String pair : ecuTypes) { + String[] keysValues = pair.split(":"); + String brokerName = keysValues[0].toLowerCase(); + if (keysValues.length == 1) { + logger.info("Only broker name: {} found against dma.dispatcher.ecu.types. " + + "List of comma separated ecuType to Kafka topic mapping not found. " + + "By default messages will be dispatched to HiveMQ"); + continue; + } + String[] mappings = keysValues[1].split(Constants.COMMA); + Map ecuTypeToTopicMappings = new HashMap<>(); + for (String mapping : mappings) { + createEcuTypesList(ecuTypeToTopicMappings, mapping); + } + brokerToEcuTypesMapping.put(brokerName, ecuTypeToTopicMappings); + } + logger.info("BrokerToECUTypesMappings constructed as: {}", brokerToEcuTypesMapping.toString()); + } + } + + /** + * Creates the ecu types list. + * + * @param ecuTypeToTopicMappings the ecu type to topic mappings + * @param mapping the mapping + */ + private void createEcuTypesList(Map ecuTypeToTopicMappings, String mapping) { + String[] str = mapping.split(Constants.ECU_TYPE_BROKER_TOPIC_DELIMETER); + String ecuType = str[0].toLowerCase(); + if (str.length == 1) { + logger.info("No kafka topic name found against ecuType: {} By default messages for this ecuType " + + "will be dispatched to HiveMQ", ecuType); + return; + } + String kafkaTopic = str[1]; + ecuTypeToTopicMappings.put(ecuType, kafkaTopic); + if (ecuTypesList == null) { + ecuTypesList = new ArrayList<>(); + } + //marking this ecuType as a valid ecuType for which DeviceMessages has to be taken through + //new route in DeviceConnectionStatusHandler. + ecuTypesList.add(ecuType); + } + + /** + * close: to close the opened resources. + */ + public void close() { + deviceMessageValidator.close(); + connStatusHandler.close(); + headerUpdater.close(); + retryHandler.close(); + dispatchHandler.close(); + dmaPostDispatchHandler.close(); + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceStatusBackDoorKafkaConsumer.java b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceStatusBackDoorKafkaConsumer.java new file mode 100644 index 0000000..367404f --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DeviceStatusBackDoorKafkaConsumer.java @@ -0,0 +1,411 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.apache.kafka.streams.KafkaStreams.State; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.BackdoorKafkaConsumer; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.ThreadUtils; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.DeviceConnStatusDAO; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; + + +/** + * This is a singleton implementation responsible soley for starting back door kafka + * comsumer for DMA consuming from device-status-{@code <}service{@code >} + * topic. + * + * @author avadakkootko + */ +@Lazy +@Component +public class DeviceStatusBackDoorKafkaConsumer extends BackdoorKafkaConsumer { + + /** The Constant DEVICE_STATUS_BACKDOOR_HEALTH_MONITOR. */ + public static final String DEVICE_STATUS_BACKDOOR_HEALTH_MONITOR = "DEVICE_STATUS_BACKDOOR_HEALTH_MONITOR"; + + /** The Constant DEVICE_STATUS_BACKDOOR_HEALTH_GUAGE. */ + public static final String DEVICE_STATUS_BACKDOOR_HEALTH_GUAGE = "DEVICE_STATUS_BACKDOOR_HEALTH_GUAGE"; + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DeviceStatusBackDoorKafkaConsumer.class); + + /** The dma consumer poll. */ + @Value("${" + PropertyNames.DMA_KAFKA_CONSUMER_POLL + ":1000}") + private long dmaConsumerPoll; + + /** The dma auto offset reset. */ + @Value("${" + PropertyNames.DMA_AUTO_OFFSET_RESET_CONFIG + ":latest}") + private String dmaAutoOffsetReset; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The health monitor enabled. */ + @Value("${" + PropertyNames.HEALTH_DEVICE_STATUS_BACKDOOR_MONITOR_ENABLED + ":false}") + private boolean healthMonitorEnabled; + + /** The needs restart. */ + @Value("${" + PropertyNames.HEALTH_DEVICE_STATUS_BACKDOOR_MONITOR_RESTART_ON_FAILURE + ":true}") + private boolean needsRestart; + + /** + * dma.enabled flag is linked with devicestatuskafkaconsumer as both are components of dma. + */ + @Value("${" + PropertyNames.DMA_ENABLED + ":true}") + private boolean isDmaEnabled; + + /** The connection status dao. */ + @Autowired + private DeviceConnStatusDAO connectionStatusDao; + + /** + * Added as part of 153542: Acknowledge for policy data publish . + * This variable is added if a component would like to overwrite the default device status connection topic. + * In DMF(DataMonetizationFeed) component, we have encountered a situation where + * dmf-control-sp sends policy to vehicle but policy + * acknowledgement comes to different topic which was being listened t + * by PolicyDataStreamProcessor (another stream processor). Now if + * this PolicyDataStreamProcessor has to send the acknowledgement back to the vehicle, + * its device status handler should know the status + * of the vehicle(active or inactive), but vehicle was not publishing the status to its default-topic. + * Hence in PolicyDataStreamProcessor, we will overwrite its default device-status topic to + * device-status-dmf-control-sp and it can get + * the vehicle status + */ + + @Value("${device.status.kafka.topic:}") + private String deviceConnStatusTopic; + + /** The dma consumer group id. */ + private String dmaConsumerGroupId; + + /** The reset offsets. */ + private boolean resetOffsets = true; + + /** The stream state. */ + private volatile State streamState; + + /** + * Sets the device status topic name. + * + * @param deviceConnStatusTopic the new device status topic name + */ + public void setDeviceStatusTopicName(String deviceConnStatusTopic) { + this.deviceConnStatusTopic = deviceConnStatusTopic; + } + + /** + * Sets the dma consumer poll. + * + * @param dmaConsumerPoll the new dma consumer poll + */ + public void setDmaConsumerPoll(long dmaConsumerPoll) { + this.dmaConsumerPoll = dmaConsumerPoll; + } + + /** + * Sets the service name. + * + * @param serviceName the new service name + */ + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + /** + * Sets the dma consumer group id. + * + * @param dmaConsumerGroupId the new dma consumer group id + */ + public void setDmaConsumerGroupId(String dmaConsumerGroupId) { + this.dmaConsumerGroupId = dmaConsumerGroupId; + } + + /** + * Sets the dma auto offset reset. + * + * @param dmaAutoOffsetReset the new dma auto offset reset + */ + public void setDmaAutoOffsetReset(String dmaAutoOffsetReset) { + this.dmaAutoOffsetReset = dmaAutoOffsetReset; + } + + /** + * init(): to initialize the DMA kafka consumer group-id. + */ + @PostConstruct + public void init() { + if (StringUtils.isEmpty(serviceName)) { + throw new IllegalArgumentException(PropertyNames.SERVICE_NAME + " unavailable in property file"); + } + logger.debug("Service Name initialized is {}", serviceName); + + // Added as part of 153542: Acknowledge for policy data publish . + // check of customDeviceConnectionTopic is empty or not. if not null, + // then use this as device-status topic name + if (StringUtils.isEmpty(deviceConnStatusTopic)) { + deviceConnStatusTopic = DMAConstants.DEVICE_STATUS_TOPIC_PREFIX + serviceName; + } + logger.info("Device Connection status Topic initialized is {}", deviceConnStatusTopic); + + StringBuilder dmaConsumerGroupIdBuilder = new StringBuilder(); + dmaConsumerGroupIdBuilder.append(DMAConstants.DMA).append(DMAConstants.HYPHEN) + .append(serviceName).append(DMAConstants.HYPHEN) + .append(System.currentTimeMillis()).append(DMAConstants.HYPHEN) + .append(new SecureRandom().nextInt(Constants.THREAD_SLEEP_TIME_1000)); + dmaConsumerGroupId = dmaConsumerGroupIdBuilder.toString(); + logger.info("DMA kafka consumer group-id initialized is {}", dmaConsumerGroupId); + } + + /** + * Gets the name. + * + * @return the name + */ + @Override + public String getName() { + return (DMAConstants.DMA + serviceName); + } + + /** + * Gets the kafka consumer group id. + * + * @return the kafka consumer group id + */ + @Override + public String getKafkaConsumerGroupId() { + return dmaConsumerGroupId; + } + + /** + * Gets the kafka consumer topic. + * + * @return the kafka consumer topic + */ + @Override + public String getKafkaConsumerTopic() { + return deviceConnStatusTopic; + } + + /** + * Gets the poll. + * + * @return the poll + */ + @Override + public long getPoll() { + return dmaConsumerPoll; + } + + /** + * Checks if is offsets reset complete. + * + * @return true, if is offsets reset complete + */ + @Override + public boolean isOffsetsResetComplete() { + return this.resetOffsets; + } + + /** + * Sets the reset offsets. + * + * @param reset the new reset offsets + */ + @Override + public void setResetOffsets(boolean reset) { + this.resetOffsets = reset; + + } + + /** + * Gets the stream state. + * + * @return the stream state + */ + @Override + public State getStreamState() { + return streamState; + } + + /** + * Sets the stream state. + * + * @param state the new stream state + */ + @Override + public void setStreamState(State state) { + streamState = state; + } + + /** + * Monitor name. + * + * @return the string + */ + @Override + public String monitorName() { + return DEVICE_STATUS_BACKDOOR_HEALTH_MONITOR; + } + + /** + * Needs restart on failure. + * + * @return true, if successful + */ + @Override + public boolean needsRestartOnFailure() { + return needsRestart; + } + + /** + * Metric name. + * + * @return the string + */ + @Override + public String metricName() { + return DEVICE_STATUS_BACKDOOR_HEALTH_GUAGE; + } + + /** + * Checks if is enabled. + * + * @return true, if is enabled + */ + @Override + public boolean isEnabled() { + // If device status is disabled then the monitor should also be + // disabled. + if (!isDmaEnabled) { + return false; + } else { + return healthMonitorEnabled; + } + } + + /** + * startDMABackDoorConsumer(). + */ + public void startDMABackDoorConsumer() { + connectionStatusDao.close(); + logger.info("Cleared Device status cache as request to start Back Door Kafka Consumer was triggered"); + connectionStatusDao.initialize(); + logger.info("Synced Device status cache from redis as request to start Back Door Kafka Consumer was triggered"); + super.startBackDoorKafkaConsumer(); + } + + /** + * Shut down kafka consumer. This method is invoked when Stream closes. + * + * @param spc the spc + */ + public void shutdown(StreamProcessingContext spc) { + // RTC-192213 - Added to clear the cache held by + // IntegrationFeedCacheUpdateCallBack. More specifically added to + // ensure that the third party kafka broker producers are flushed + // before the application shuts down. This will ensure that the data + // are flushed immediately in case of kafka batching. + callBackMap.forEach((k, v) -> v.close()); + + //RTC-394242 - Added to remove only the callbacks for the partitions which went into rebalancing + // Moved out this piece of code from the shutdown consumer process cause both process are independent + // Callback has to be removed during each rebalance regardless pf the consumer shutodown process + int partition = Integer.parseInt(spc.getTaskID().split("_")[1]); + callBackMap.remove(partition); + logger.info("Cleared Callback map for partition: {} and taskID: {}", partition, spc.getTaskID()); + + if (getStartedConsumer().get() && !getClosed().get()) { + closed.set(true); + startedConsumer.set(false); + if (consumer != null) { + consumer.wakeup(); + } + ThreadUtils.shutdownExecutor(kafkaConsumerRunExecutor, Constants.THREAD_SLEEP_TIME_10000, false); + ThreadUtils.shutdownExecutor(offsetsMgmtExecutor, Constants.THREAD_SLEEP_TIME_10000, false); + removeConsumerGroup(getKafkaConsumerGroupId()); + logger.info("Closed Backdoor Kafka Consumer"); + } + } + + /** + * Sets the needs restart on failure. + * + * @param needsRestart the new needs restart on failure + */ + // Below methods are for supporting test cases + void setNeedsRestartOnFailure(boolean needsRestart) { + this.needsRestart = needsRestart; + } + + /** + * Sets the health monitor enabled. + * + * @param healthMonitorEnabled the new health monitor enabled + */ + void setHealthMonitorEnabled(boolean healthMonitorEnabled) { + this.healthMonitorEnabled = healthMonitorEnabled; + } + + /** + * Sets the checks if is dma enabled. + * + * @param isDmaEnabled the new checks if is dma enabled + */ + // below method is for testing purpose + void setIsDmaEnabled(boolean isDmaEnabled) { + this.isDmaEnabled = isDmaEnabled; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/handler/DispatchHandler.java b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DispatchHandler.java new file mode 100644 index 0000000..08f0719 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/handler/DispatchHandler.java @@ -0,0 +1,200 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.MqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.ObjectUtils; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageDispatchers; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.util.Map; + + +/** + *

    + * DispatchHandler is responsible for the following. + * + * -> Check if TTL has exceeded. + * + * -> If TTL has not exceeded Add Header (messageId/correlationId) + * + * -> Transform IgniteEvent and IgniteKey to byte[] + * + * -> Dispatch to MQTT. + * + *

    + * + * @author avadakkootko + */ +@Component +@Scope("prototype") +@ConditionalOnProperty(name = PropertyNames.DMA_ENABLED, havingValue = "true") +public class DispatchHandler implements DeviceMessageHandler { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DispatchHandler.class); + + /** The mqtt dispatcher. */ + @Autowired + private MqttDispatcher mqttDispatcher; + + // RDNG: 170507, RTC: 433337. + /** + * DMA should have the capability to dispatch data to other plaforms/brokers along with HiveMQ. + * Check {@link #handle(IgniteKey, DeviceMessage) for more} + */ + @Autowired + private KafkaDispatcher kafkaDispatcher; + + /** The broker to ecu types mapping. */ + private Map> brokerToEcuTypesMapping = null; + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + *

    + * ECU type from each IgniteEvent / DeviceMessage will be extracted and its + * presence will be checked in the map against each broker. + * If it's present and is mapped to a topic, only then the DeviceMessage will + * be dispatched to the topic of that respective + * broker/platform. + * + * Else, dispatch to MQTT.(The default behavior) + * + *

    + * + * @param key IgniteKey + * @param value DeviceMessage + */ + @Override + public void handle(IgniteKey key, DeviceMessage value) { + if (brokerToEcuTypesMapping != null && brokerToEcuTypesMapping.size() > 0) { + for (Map.Entry> entry : brokerToEcuTypesMapping.entrySet()) { + String broker = entry.getKey(); + Map ecuTypeToTopicMapping = entry.getValue(); + if (DeviceMessageDispatchers.KAFKA.equals(broker)) { + String ecuType = value.getEvent().getEcuType().toLowerCase(); + if (ecuTypeToTopicMapping.containsKey(ecuType) + && StringUtils.isNotEmpty(ecuTypeToTopicMapping.get(ecuType))) { + logger.info("Forwarding the DeviceMessage with key: {} and value: {} to " + + "KafkaDispatcher", key, value); + kafkaDispatcher.dispatch(key, value); + } else { + logger.info("No topic found for the ecuType: {} for broker: {} The DeviceMessage " + + "for this ecuType will be dispatched to MQTT by default", ecuType, broker); + mqttDispatcher.dispatch(key, value); + } + } else { + logger.error("Unknown dispatcher. Won't dispatch the message."); + } + } + } else { + logger.info("Forwarding the DeviceMessage with key: {} and value: {} to MqttDispatcher for " + + "dispatch", key, value); + mqttDispatcher.dispatch(key, value); + } + } + + /** + * Validate. + */ + @PostConstruct + public void validate() { + ObjectUtils.requireNonNull(mqttDispatcher, "Uninitialized MQTT dispatcher."); + ObjectUtils.requireNonNull(kafkaDispatcher, "Uninitialized Kafka dispatcher."); + } + + /** + * Sets the next handler. + * + * @param handler the new next handler + */ + @Override + public void setNextHandler(DeviceMessageHandler handler) { + mqttDispatcher.setNextHandler(handler); + kafkaDispatcher.setNextHandler(handler); + } + + /** + * setup(). + * + * @param taskId taskId + * @param brokerToEcuTypesMapping brokerToEcuTypesMapping + */ + public void setup(String taskId, Map> brokerToEcuTypesMapping) { + mqttDispatcher.setup(taskId); + this.brokerToEcuTypesMapping = brokerToEcuTypesMapping; + kafkaDispatcher.setup(brokerToEcuTypesMapping, this.spc); + } + + /** + * Sets the stream processing context. + * + * @param ctx the ctx + */ + @Override + public void setStreamProcessingContext(StreamProcessingContext, IgniteEvent> ctx) { + this.spc = ctx; + mqttDispatcher.setStreamProcessingContext(ctx); + } + + /** + * Close. + */ + @Override + public void close() { + mqttDispatcher.close(); + } + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/handler/FilterDMOfflineBufferEntry.java b/src/main/java/org/eclipse/ecsp/stream/dma/handler/FilterDMOfflineBufferEntry.java new file mode 100644 index 0000000..c102c58 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/handler/FilterDMOfflineBufferEntry.java @@ -0,0 +1,66 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntry; + +import java.util.List; + + +/** + * This Interface will be used to filtered offline messages based on + * different criteria and update offline messages if required. + * + * @author JDEHURY + * + * + * + */ +public interface FilterDMOfflineBufferEntry { + + /** + * Filter and update dm offline buffer entries. + * + * @param bufferedEntries the buffered entries + * @return the list + */ + public List filterAndUpdateDmOfflineBufferEntries(List + bufferedEntries); +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/handler/MaxFailuresUncaughtExceptionHandler.java b/src/main/java/org/eclipse/ecsp/stream/dma/handler/MaxFailuresUncaughtExceptionHandler.java new file mode 100644 index 0000000..b9d3220 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/handler/MaxFailuresUncaughtExceptionHandler.java @@ -0,0 +1,166 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import io.prometheus.client.Counter; +import org.apache.kafka.streams.errors.StreamsUncaughtExceptionHandler; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import static org.apache.kafka.streams.errors.StreamsUncaughtExceptionHandler.StreamThreadExceptionResponse.REPLACE_THREAD; +import static org.apache.kafka.streams.errors.StreamsUncaughtExceptionHandler.StreamThreadExceptionResponse.SHUTDOWN_CLIENT; + + +/** + * class MaxFailuresUncaughtExceptionHandler implements StreamsUncaughtExceptionHandler. + */ +public class MaxFailuresUncaughtExceptionHandler implements StreamsUncaughtExceptionHandler { + + /** The Constant LOGGER. */ + private static final IgniteLogger LOGGER = IgniteLoggerFactory.getLogger(MaxFailuresUncaughtExceptionHandler.class); + + /** The thread recovery total. */ + private static Counter threadRecoveryTotal; + + /** The client shutdown total. */ + private static Counter clientShutdownTotal; + + /** The uncaught exception total. */ + private static Counter uncaughtExceptionTotal; + + /** The max failures. */ + final int maxFailures; + + /** The max time interval millis. */ + final long maxTimeIntervalMillis; + + /** The previous error time. */ + private Instant previousErrorTime; + + /** The current failure count. */ + private int currentFailureCount; + + /** + * Instantiates a new max failures uncaught exception handler. + * + * @param maxFailures the max failures + * @param maxTimeIntervalMillis the max time interval millis + */ + public MaxFailuresUncaughtExceptionHandler(final int maxFailures, final long maxTimeIntervalMillis) { + this.maxFailures = maxFailures; + this.maxTimeIntervalMillis = maxTimeIntervalMillis; + } + + static { + threadRecoveryTotal = Counter.build() + .name("total_number_of_times_thread_recovered") + .help("Total number of times thread recovered") + .register(); + clientShutdownTotal = Counter.build() + .name("total_number_of_times_client_shuts_down") + .help("Total number of times client shuts down") + .register(); + uncaughtExceptionTotal = Counter.build() + .name("total_number_of_times_uncaught_exception_occurs") + .help("Total number of times uncaught exception occurs") + .register(); + } + + /** + * Gets the thread recovery total. + * + * @return the thread recovery total + */ + public static Counter getThreadRecoveryTotal() { + return threadRecoveryTotal; + } + + /** + * Gets the client shutdown total. + * + * @return the client shutdown total + */ + public static Counter getClientShutdownTotal() { + return clientShutdownTotal; + } + + /** + * Handle. + * + * @param throwable the throwable + * @return the stream thread exception response + */ + @Override + public StreamThreadExceptionResponse handle(final Throwable throwable) { + uncaughtExceptionTotal.inc(); + currentFailureCount++; + Instant currentErrorTime = Instant.now(); + + // Log the error for RCA. + LOGGER.error("Uncaught stream exception stacktrace ", throwable); + LOGGER.info("currentFailureCount is {}, previousErrorTime is {}, currentErrorTime is {}", currentFailureCount, + previousErrorTime, currentErrorTime); + + if (previousErrorTime == null) { + previousErrorTime = currentErrorTime; + } + + long millisBetweenFailure = ChronoUnit.MILLIS.between(previousErrorTime, currentErrorTime); + if (currentFailureCount >= maxFailures) { + if (millisBetweenFailure <= maxTimeIntervalMillis) { + // Following return value will shutdown the client. + LOGGER.info("Shutting down the client as millisBetweenFailure is less than maxTimeIntervalMillis"); + clientShutdownTotal.inc(); + return SHUTDOWN_CLIENT; + } else { + LOGGER.info("Resetting the value of currentFailureCount and previousErrorTime "); + + currentFailureCount = 0; + previousErrorTime = null; + } + } + // replaces the thread with the new one. + threadRecoveryTotal.inc(); + return REPLACE_THREAD; + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/handler/NoFilterDMOfflineBufferEntryImpl.java b/src/main/java/org/eclipse/ecsp/stream/dma/handler/NoFilterDMOfflineBufferEntryImpl.java new file mode 100644 index 0000000..198b618 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/handler/NoFilterDMOfflineBufferEntryImpl.java @@ -0,0 +1,69 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntry; +import org.springframework.stereotype.Component; + +import java.util.List; + + +/** + * This is just Dummy implementation of FilterDMOfflineBufferEntry. + * + * @author JDEHURY + */ + +@Component +public class NoFilterDMOfflineBufferEntryImpl implements FilterDMOfflineBufferEntry { + + /** + * Filter and update dm offline buffer entries. + * + * @param bufferedEntries the buffered entries + * @return the list + */ + @Override + public List filterAndUpdateDmOfflineBufferEntries(List + bufferedEntries) { + return bufferedEntries; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/handler/RetryHandler.java b/src/main/java/org/eclipse/ecsp/stream/dma/handler/RetryHandler.java new file mode 100644 index 0000000..700589f --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/handler/RetryHandler.java @@ -0,0 +1,938 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.apache.commons.lang3.StringUtils; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.utils.InternalCacheConstants; +import org.eclipse.ecsp.analytics.stream.base.utils.ThreadUtils; +import org.eclipse.ecsp.domain.DeviceConnStatusV1_0.ConnectionStatus; +import org.eclipse.ecsp.domain.DeviceMessageFailureEventDataV1_0; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageErrorCode; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.entities.dma.RetryRecord; +import org.eclipse.ecsp.entities.dma.RetryRecordIds; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.config.EventConfig; +import org.eclipse.ecsp.stream.dma.config.EventConfigProvider; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.DMARetryBucketDAOCacheBackedInMemoryImpl; +import org.eclipse.ecsp.stream.dma.dao.DMARetryRecordDAOCacheBackedInMemoryImpl; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntryDAOMongoImpl; +import org.eclipse.ecsp.stream.dma.dao.key.RetryBucketKey; +import org.eclipse.ecsp.stream.dma.dao.key.RetryRecordKey; +import org.eclipse.ecsp.stream.dma.scheduler.DeviceMessagingEventScheduler; +import org.eclipse.ecsp.utils.Constants; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.eclipse.ecsp.utils.metrics.IgniteErrorCounter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + + +/** + * RetryHandler takes care of retrying events based on the configured thresholds of retry count and TTL. + * + * @author avadakkootko + */ +@Component +@Scope("prototype") +@ConditionalOnProperty(name = PropertyNames.DMA_ENABLED, havingValue = "true") +public class RetryHandler implements DeviceMessageHandler { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(RetryHandler.class); + + /** The retry executor. */ + private ScheduledExecutorService retryExecutor = null; + + /** The next handler. */ + private DeviceMessageHandler nextHandler; + + /** The device message utils. */ + @Autowired + private DeviceMessageUtils deviceMessageUtils; + + /** The retry bucket DAO. */ + @Autowired + private DMARetryBucketDAOCacheBackedInMemoryImpl retryBucketDAO; + + /** The retry event DAO. */ + @Autowired + private DMARetryRecordDAOCacheBackedInMemoryImpl retryEventDAO; + + /** The error counter. */ + @Autowired + private IgniteErrorCounter errorCounter; + + /** The offline buffer DAO. */ + @Autowired + private DMOfflineBufferEntryDAOMongoImpl offlineBufferDAO; + + /** The event scheduler. */ + @Autowired + private DeviceMessagingEventScheduler eventScheduler; + + /** The ctx. */ + @Autowired + private ApplicationContext ctx; + + /** The task id. */ + private String taskId; + + /** The retry bucket map key. */ + private String retryBucketMapKey; + + /** The retry event map key. */ + private String retryEventMapKey; + + /** The conn status handler. */ + private DeviceConnectionStatusHandler connStatusHandler; + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** The max retry. */ + @Value("${" + PropertyNames.DMA_SERVICE_MAX_RETRY + ":3}") + private int maxRetry; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The retry interval. */ + @Value("${" + PropertyNames.DMA_SERVICE_RETRY_INTERVAL_MILLIS + ":60000}") + private long retryInterval; + + /** The scheduler enabled. */ + @Value("${" + PropertyNames.SCHEDULER_ENABLED + ":true}") + private String schedulerEnabled; + + /** The ttl expiry notification enabled. */ + @Value("${" + PropertyNames.DMA_TTL_EXPIRY_NOTIFICATION_ENABLED + ":true}") + private String ttlExpiryNotificationEnabled; + + /** The retry min threshold. */ + @Value("${" + PropertyNames.DMA_SERVICE_RETRY_MIN_THRESHOLD_MILLIS + ":1000}") + private int retryMinThreshold; + + /** The retry interval divisor. */ + @Value("${" + PropertyNames.DMA_SERVICE_RETRY_INTERVAL_DIVISOR + ":10}") + private int retryIntervalDivisor; + + /** The Constant DEFAULT_EVENT_CONFIG_PROVIDER. */ + private static final String DEFAULT_EVENT_CONFIG_PROVIDER = + "org.eclipse.ecsp.stream.dma.config.DefaultEventConfigProvider"; + + /** The event config provider impl class. */ + @Value("${" + PropertyNames.DMA_EVENT_CONFIG_PROVIDER_CLASS + ":" + DEFAULT_EVENT_CONFIG_PROVIDER + "}") + private String eventConfigProviderImplClass; + + /** The sub services. */ + @Value("${" + PropertyNames.SUB_SERVICES + ":}") + private String subServices; + + /** The config provider. */ + EventConfigProvider configProvider; + + /** The event config map. */ + private ConcurrentMap eventConfigMap = new ConcurrentHashMap<>(); + + /** The retry attempt log. */ + private static String retryAttemptLog = + "Current retry attempt is {} for messageId {}, with requestId {} and key {}"; + + /** + * Sets the retry min threshold. + * + * @param retryMinThreshold the new retry min threshold + */ + public void setRetryMinThreshold(int retryMinThreshold) { + this.retryMinThreshold = retryMinThreshold; + } + + /** + * Sets the max retry. + * + * @param maxRetry the new max retry + */ + public void setMaxRetry(int maxRetry) { + this.maxRetry = maxRetry; + } + + /** + * Sets the service name. + * + * @param serviceName the new service name + */ + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + /** + * Sets the retry interval. + * + * @param retryInterval the new retry interval + */ + public void setRetryInterval(long retryInterval) { + this.retryInterval = retryInterval; + } + + /** + * Sets the retry interval divisor. + * + * @param retryIntervalDivisor the new retry interval divisor + */ + public void setRetryIntervalDivisor(int retryIntervalDivisor) { + this.retryIntervalDivisor = retryIntervalDivisor; + } + + /** + * Sets the event config map. + * + * @param eventConfigMap the event config map + */ + public void setEventConfigMap(ConcurrentMap eventConfigMap) { + this.eventConfigMap = eventConfigMap; + } + + /** + * Gets the event config map. + * + * @return the event config map + */ + public ConcurrentMap getEventConfigMap() { + return this.eventConfigMap; + } + + /** + * Sets the event config provider impl class. + * + * @param eventConfigProviderImplClass the new event config provider impl class + */ + public void setEventConfigProviderImplClass(String eventConfigProviderImplClass) { + this.eventConfigProviderImplClass = eventConfigProviderImplClass; + } + + /** + * Gets the scheduled thread delay. + * + * @return the scheduled thread delay + */ + long getScheduledThreadDelay() { + long freq = retryInterval / retryIntervalDivisor; + return freq > retryMinThreshold ? freq : retryMinThreshold; + } + + /** + * Gets the event config. + * + * @param eventId the event id + * @return the event config + */ + private EventConfig getEventConfig(String eventId) { + EventConfig config = eventConfigMap.get(eventId); + // if no EventConfig is present in map for this eventId + if (config == null) { + config = configProvider.getEventConfig(eventId); + // if service has not created any EventConfig for this eventId + // then return default one. + if (config == null) { + config = configProvider.getDefaultEventConfig(eventId); + } + eventConfigMap.put(eventId, config); + } + return config; + } + + /** + * **********HAPPY FLOW******** + * Check TTL exceeded -> Check Device ACTIVE -> Check if AckExpected -> Check + * maxRetryExceeded -> Increment retry counter -> Add Event to retry map and add + * messageId to appropriate bucket by timestamp -> Forward to next handle. + * + * @param key the key + * @param value the value + */ + @Override + public void handle(IgniteKey key, DeviceMessage value) { + boolean fallbackToTTLOnMaxRetryExhausted = getEventConfig(value.getEvent().getEventId()) + .fallbackToTTLOnMaxRetryExhausted(); + /* + * Setting pendingRetries in device message header as maxRetry in case of first + * time arrival of event to RetryHandler Setting it in DeviceMessageHeader so + * that pendingRetries could be accessible to us while saving the event into + * mongo. The inner if check has been applied to make sure, when event comes the + * second time, pendingRetries isn't set to maxRetry for this event. + * + * The outer if is to check, whether the following retry strategy: "Keep + * retrying even when maxRetry is exhausted until TTL on event expires" has been + * enabled or not. If not, then set pendingRetries only for such events. Else + * for above retry strategy, do not set pendingRetries. + */ + if (!fallbackToTTLOnMaxRetryExhausted) { + DeviceMessageHeader header = value.getDeviceMessageHeader(); + if (!header.getIsPendingRetriesSet()) { + header.withPendingRetries(maxRetry).isPendingRetriesSet(true); + value.setDeviceMessageHeader(header); + } + } + retryHandle(key, value, true); + } + + /** + * updateEnabled will be true for happy flow. and false when it is triggered by + * the scheduled thread. This ensures that an event is not created in retry + * event map when it is invoked from the scheduled thread. + * When invoked from the scheduledthread is it is not able to find the messageId + * in retry map then it implies the event has already been retried. Hence its + * should not be retried again. + * + * @param key IgniteKey + * @param value DeviceMessage + * @param firstAttempt Whether it's the first attempt or not. + */ + private void retryHandle(IgniteKey key, DeviceMessage value, boolean firstAttempt) { + logger.debug("Received IgniteKey {} and IgniteEvent {} in DeviceMessageRetryHandler", key, value); + + // Validate IgniteEvent - by checking if TTL has been exceeded or if the + // device is still ACTIVE. + DeviceMessageHeader header = value.getDeviceMessageHeader(); + if (header.isGlobalTopicNameProvided()) { + nextHandler.handle(key, value); + return; + } + boolean cutOffNotExceeded = validateIgniteEvent(header); + String retryRecordKeyPart = RetryRecordKey.createVehiclePart(header.getVehicleId(), header.getMessageId()); + if (cutOffNotExceeded) { + if (checkDeviceInactive(key, value)) { + logger.debug("Device is inactive for ignitekey {} and value {}. Removing Retry entry Record " + + "with key {}.", key, value, retryRecordKeyPart); + RetryRecordKey retryKey = new RetryRecordKey(retryRecordKeyPart, taskId); + retryEventDAO.deleteFromMap(retryEventMapKey, retryKey, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + } else { + /* + * RTC 344443 Device Message should keep retrying events when all the retry + * attempts are exhausted AND TTL is still not expired for an event. + */ + boolean fallbackToTTLOnMaxRetryExhausted = getEventConfig(value.getEvent().getEventId()) + .fallbackToTTLOnMaxRetryExhausted(); + if (fallbackToTTLOnMaxRetryExhausted && header.isResponseExpected()) { + logger.debug("fallbackToTTLOnMaxRetryExhausted is enabled for eventId: {}. Message will be " + + "valid for retry until TTL expires.", value.getEvent().getEventId()); + RetryRecordKey retryEventKey = new RetryRecordKey(retryRecordKeyPart, taskId); + RetryRecord event = retryEventDAO.get(retryEventKey); + long currentTime = System.currentTimeMillis(); + retryFOrMaxOrAddInMap(key, value, firstAttempt, retryEventKey, event, currentTime); + } else if (header.isResponseExpected() && maxRetry > 0) { + // Check if ack is needed and maxRetry > 0, else do not add to retry + RetryRecordKey retryEventKey = new RetryRecordKey(retryRecordKeyPart, taskId); + RetryRecord event = retryEventDAO.get(retryEventKey); + long currentTime = System.currentTimeMillis(); + retryOrAddInMap(key, value, firstAttempt, retryEventKey, event, currentTime); + logger.debug("ResponseExpected is set to true"); + } + /* + * taking into account the case when either responseExpected == false for this + * event OR max retries are 0, then event should be dispatched only once. No + * retries should be attempted. + */ + handleNextKey(key, value, header); + } + } else { + RetryRecordKey retryEventKey = new RetryRecordKey(retryRecordKeyPart, taskId); + int attempts = 0; + try { + RetryRecord event = retryEventDAO.get(retryEventKey); + attempts = event.getAttempts(); + retryEventDAO.deleteFromMap(retryEventMapKey, retryEventKey, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + } catch (Exception e) { + logger.warn("Retry record unavailable in redis for key {}", retryEventKey.toString()); + } + DeviceMessageFailureEventDataV1_0 failEventData = new DeviceMessageFailureEventDataV1_0(); + failEventData.setFailedIgniteEvent(value.getEvent()); + failEventData.setErrorCode(DeviceMessageErrorCode.DEVICE_DELIVERY_CUTOFF_EXCEEDED); + failEventData.setRetryAttempts(attempts); + failEventData.setDeviceDeliveryCutoffExceeded(true); + deviceMessageUtils.postFailureEvent(failEventData, key, spc, value.getFeedBackTopic()); + logger.error("For key {} and value {} cutoff exceeded will not send it to device.", key, value); + } + } + + /** + * Handle next key. + * + * @param key the key + * @param value the value + * @param header the header + */ + private void handleNextKey(IgniteKey key, DeviceMessage value, DeviceMessageHeader header) { + if (!header.isResponseExpected() || maxRetry == 0) { + nextHandler.handle(key, value); + logger.debug("ResponseExpected is set to false or retry attempts is 0, for key {} and event {}", + key, value); + } + } + + /** + * Retry F or max or add in map. + * + * @param key the key + * @param value the value + * @param firstAttempt the first attempt + * @param retryEventKey the retry event key + * @param event the event + * @param currentTime the current time + */ + private void retryFOrMaxOrAddInMap(IgniteKey key, DeviceMessage value, boolean firstAttempt, + RetryRecordKey retryEventKey, RetryRecord event, long currentTime) { + if (event != null) { + attemptRetryForFallbackToTLLOnMaxRetryExhausted(event, currentTime, retryEventKey); + } else if (firstAttempt) { + addToRetryMap(currentTime, key, value, retryEventKey); + } + } + + /** + * Retry or add in map. + * + * @param key the key + * @param value the value + * @param firstAttempt the first attempt + * @param retryEventKey the retry event key + * @param event the event + * @param currentTime the current time + */ + private void retryOrAddInMap(IgniteKey key, DeviceMessage value, boolean firstAttempt, + RetryRecordKey retryEventKey, RetryRecord event, long currentTime) { + if (event != null) { + attemptRetry(event, currentTime, retryEventKey); + } else if (firstAttempt) { + addToRetryMap(currentTime, key, value, retryEventKey); + } + } + + /** + * Attempt retry for fallback to TLL on max retry exhausted. + * + * @param event the event + * @param currentTime the current time + * @param retryEventKey the retry event key + */ + /* + * This method is for new retry strategy + * + * @param RetryRecord : the event that will be retried + * + * @param currentTime + * + * @param RetryRecordKey : the key for which this RetryRecord will be fetched + */ + private void attemptRetryForFallbackToTLLOnMaxRetryExhausted(RetryRecord event, long currentTime, + RetryRecordKey retryEventKey) { + IgniteKey key = event.getIgniteKey(); + DeviceMessage value = event.getDeviceMessage(); + IgniteEventImpl currentEvent = value.getEvent(); + if (event.getAttempts() >= maxRetry) { + // store into offline buffer and delete RetryRecord from redis as well as from + // in-memory map + // This is to treat the event as a fresh one when device will again come active + // from inactive state. + logger.debug("Retry exceeded maxRetry {} for retry strategy: fallbackOnTTLOnMaxRetryExhausted " + + "for messageId {}, with requestId {} and key {}", maxRetry, + currentEvent.getMessageId(), currentEvent.getRequestId(), key); + saveToOfflineBufferAndDeleteFromCache(key, value); + + // WI-374794 Create a scheduler for entry added to offline buffer if scheduler enabled + if (Boolean.parseBoolean(schedulerEnabled) && Boolean.parseBoolean(ttlExpiryNotificationEnabled)) { + eventScheduler.scheduleEvent(key, value, spc); + } + } else { + // dispatch to mqtt topic + event.addAttempt(currentTime); + long retryIntervalTime = getNextRetryInterval(value, key); + long nextRetry = currentTime + retryIntervalTime; + retryEventDAO.putToMap(retryEventMapKey, retryEventKey, event, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + logger.debug("Added event {} with key {} to retry event map.", event, retryEventKey.convertToString()); + // Add messageId to set of messageIds in retry + // bucket keyed by timestamp + RetryBucketKey nextRetryKey = new RetryBucketKey(nextRetry); + String retryRecordKey = retryEventKey.getKey(); + retryBucketDAO.update(retryBucketMapKey, nextRetryKey, retryRecordKey); + logger.debug("Added entry {} with timestamp {} to retry bucket.", retryRecordKey, nextRetry); + DeviceMessageFailureEventDataV1_0 failEventData = new DeviceMessageFailureEventDataV1_0(); + failEventData.setFailedIgniteEvent(currentEvent); + failEventData.setErrorCode(DeviceMessageErrorCode.RETRYING_DEVICE_MESSAGE); + failEventData.setRetryAttempts(event.getAttempts()); + logger.debug(retryAttemptLog, + event.getAttempts(), currentEvent.getMessageId(), currentEvent.getRequestId(), key); + deviceMessageUtils.postFailureEvent(failEventData, key, spc, value.getFeedBackTopic()); + logger.debug(retryAttemptLog, + event.getAttempts(), currentEvent.getMessageId(), currentEvent.getRequestId(), key); + nextHandler.handle(key, value); + } + } + + /** + * Gets the next retry interval. + * + * @param deviceMessage the device message + * @param key the key + * @return the next retry interval + */ + private long getNextRetryInterval(DeviceMessage deviceMessage, IgniteKey key) { + logger.debug("Retry interval for event with key: {} , requestId: {} , messageId: {} is {}", + key, deviceMessage.getDeviceMessageHeader().getRequestId(), deviceMessage + .getDeviceMessageHeader().getMessageId(), deviceMessage.getEventLevelRetryInterval()); + return deviceMessage.getEventLevelRetryInterval(); + } + + /** + * Save to offline buffer and delete from cache. + * + * @param key the key + * @param value the value + */ + /* + * persist to mongo and remove data from cache so DMA doesn't retry. + * + * @param IgniteKey + * + * @param DeviceMessage : the payload to forward to device + */ + private void saveToOfflineBufferAndDeleteFromCache(IgniteKey key, DeviceMessage value) { + offlineBufferDAO.addOfflineBufferEntry(value.getDeviceMessageHeader().getVehicleId(), key, value, + (StringUtils.isNotEmpty(subServices)) + ? value.getDeviceMessageHeader().getDevMsgTopicSuffix().toLowerCase() : null); + logger.info("Saved event with key: {} and value: {} to mongo as max retries have exhausted.", + key, value); + String retryRecordKeyPart = RetryRecordKey.createVehiclePart(value.getDeviceMessageHeader().getVehicleId(), + value.getDeviceMessageHeader().getMessageId()); + RetryRecordKey retryKey = new RetryRecordKey(retryRecordKeyPart, taskId); + retryEventDAO.deleteFromMap(retryEventMapKey, retryKey, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + } + + /** + * Attempt retry. + * + * @param event the event + * @param currentTime the current time + * @param retryEventKey the retry event key + */ + private void attemptRetry(RetryRecord event, long currentTime, RetryRecordKey retryEventKey) { + IgniteKey key = event.getIgniteKey(); + DeviceMessage value = event.getDeviceMessage(); + int pendingRetries = value.getDeviceMessageHeader().getPendingRetries(); + // Check if number of retries has exeeded max retries, If + // yes delete entry from retryEvent map and return true else + // return false. + IgniteEventImpl currentEvent = value.getEvent(); + if (pendingRetries == 0) { + logger.debug("Retry exceeded maxRetry {} for messageId {}, with requestId {} and key {}", maxRetry, + currentEvent.getMessageId(), currentEvent.getRequestId(), key); + DeviceMessageFailureEventDataV1_0 failEventData = new DeviceMessageFailureEventDataV1_0(); + failEventData.setFailedIgniteEvent(currentEvent); + failEventData.setErrorCode(DeviceMessageErrorCode.RETRY_ATTEMPTS_EXCEEDED); + failEventData.setRetryAttempts(maxRetry); + deviceMessageUtils.postFailureEvent(failEventData, key, spc, value.getFeedBackTopic()); + retryEventDAO.deleteFromMap(retryEventMapKey, retryEventKey, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + logger.debug("Deleted key {} from retryEventDAO", retryEventKey.convertToString()); + } else { + event.addAttempt(currentTime); + /* + * next three lines involve: a. decrementing pendingRetries for this event by 1. + * b. updating DeviceMessage with DeviceMessageHeader with updated + * pendingRetries value. c. setting that DeviceMessage into this RetryRecord + * event.(As per RTC 285555) + */ + pendingRetries--; + logger.debug("Retries left for this event are {}", pendingRetries); + value.setDeviceMessageHeader(value.getDeviceMessageHeader().withPendingRetries(pendingRetries)); + event.setDeviceMessage(value); + long retryIntervalTime = getNextRetryInterval(value, key); + long nextRetry = currentTime + retryIntervalTime; + retryEventDAO.putToMap(retryEventMapKey, retryEventKey, event, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + logger.debug("Added event {} with key {} to retry event map.", event, retryEventKey.convertToString()); + // Add messageId to set of messageIds in retry + // bucket keyed by timestamp + RetryBucketKey nextRetryKey = new RetryBucketKey(nextRetry); + String retryRecordKey = retryEventKey.getKey(); + retryBucketDAO.update(retryBucketMapKey, nextRetryKey, retryRecordKey); + logger.debug("Added entry {} with timestamp {} to retry bucket.", retryRecordKey, nextRetry); + DeviceMessageFailureEventDataV1_0 failEventData = new DeviceMessageFailureEventDataV1_0(); + failEventData.setFailedIgniteEvent(currentEvent); + failEventData.setErrorCode(DeviceMessageErrorCode.RETRYING_DEVICE_MESSAGE); + failEventData.setRetryAttempts(maxRetry - pendingRetries); + logger.debug(retryAttemptLog, + event.getAttempts(), currentEvent.getMessageId(), currentEvent.getRequestId(), key); + deviceMessageUtils.postFailureEvent(failEventData, key, spc, value.getFeedBackTopic()); + nextHandler.handle(key, value); + } + } + + /** + * Adds the to retry map. + * + * @param currentTime the current time + * @param key the key + * @param value the value + * @param retryEventKey the retry event key + */ + private void addToRetryMap(long currentTime, IgniteKey key, DeviceMessage value, RetryRecordKey retryEventKey) { + long retryIntervalTime = getNextRetryInterval(value, key); + long nextRetry = currentTime + retryIntervalTime; + RetryRecord event = new RetryRecord(key, value, currentTime); + retryEventDAO.putToMap(retryEventMapKey, retryEventKey, event, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + logger.debug("Created event {} with key {} to retry event map.", event, retryEventKey.convertToString()); + RetryBucketKey nextRetryKey = new RetryBucketKey(nextRetry); + String retryRecordKey = retryEventKey.getKey(); + retryBucketDAO.update(retryBucketMapKey, nextRetryKey, retryRecordKey); + logger.debug("Created entry {} with timestamp {} to retry event map.", retryRecordKey, nextRetry); + nextHandler.handle(key, value); + } + + /** + * Checks if the TTL of the event has exceeded or if Device is inactive. If yes + * remove event from Retry event map. Do not process further. + * + * @param header the header + * @return Whether the event is expired or not. + */ + private boolean validateIgniteEvent(DeviceMessageHeader header) { + boolean validated = true; + if (checkTTLExceeded(header)) { + validated = false; + } + return validated; + } + + /** + * Checks if the TTL of the event has exceeded. + * If TTL is not set default value needs to be provided by DMA (should be + * discussed and Implementataion Pending). + * + * @param value the value + * @return Whether the event is exipred or not. + */ + private boolean checkTTLExceeded(DeviceMessageHeader value) { + boolean flag = true; + long cutOffTs = value.getDeviceDeliveryCutoff(); + if (cutOffTs != Constants.DEFAULT_DELIVERY_CUTOFF) { + if (cutOffTs > System.currentTimeMillis()) { + // Device cutoff not exceeded. Valid event + flag = false; + } + } else { + // Device cutoff not exceeded. Valid event + flag = false; + } + return flag; + } + + /** + * Checks if Device is inactive. + * + * @param key the key + * @param entity the entity + * @return Whether the device is inactive or not. + */ + private boolean checkDeviceInactive(IgniteKey key, DeviceMessage entity) { + boolean flag = true; + DeviceMessageHeader header = entity.getDeviceMessageHeader(); + String vehicleId = header.getVehicleId(); + if (entity.isOtherBrokerConfigured()) { + ConnectionStatus connStatus = connStatusHandler.getConnectionStatus(header); + if (connStatus.toString().equals(DMAConstants.INACTIVE)) { + connStatusHandler.handleDeviceInactiveState(key, entity); + return true; + } + return false; + } + Optional deviceId = connStatusHandler.getDeviceIdIfActive(key, header, vehicleId); + if (deviceId.isPresent()) { + flag = false; + } else { + // Device Inactive move to offlinebuffer + connStatusHandler.handleDeviceInactiveState(key, entity); + } + return flag; + } + + /** + * Sets the next handler. + * + * @param handler the new next handler + */ + @Override + public void setNextHandler(DeviceMessageHandler handler) { + nextHandler = handler; + } + + /** + * Initializes RetryHandler for the given partitionId. + * + * @param taskId The partitionId. + */ + public void setup(String taskId) { + this.taskId = taskId; + retryBucketMapKey = RetryBucketKey.getMapKey(serviceName, taskId); + retryEventMapKey = RetryRecordKey.getMapKey(serviceName, taskId); + retryBucketDAO.initialize(taskId); + retryEventDAO.initialize(taskId); + if (retryMinThreshold <= 0) { + throw new IllegalArgumentException( + "Retry Minimum threshold " + retryMinThreshold + ", should be greater than one second "); + } + if (retryInterval <= 0) { + throw new IllegalArgumentException("Retry Interval " + retryInterval + ", should be greater than zero "); + } + if (retryInterval < retryMinThreshold) { + throw new IllegalArgumentException("Retry Interval " + retryInterval + + ", should be greater than Minimum threshold " + retryMinThreshold); + } + + if (maxRetry < 0) { + logger.warn("Max retry cannot be less than 0. No retry will be attempted."); + maxRetry = 0; + } + if (eventConfigProviderImplClass == null) { + eventConfigProviderImplClass = DEFAULT_EVENT_CONFIG_PROVIDER; + } + + configProvider = getEventConfigProviderImpl(eventConfigProviderImplClass); + + long delay = getScheduledThreadDelay(); + logger.info( + "Minimum threshold for retry is {} ; Scheduled thread delay is {} ; Retry Interval for service is {}", + retryMinThreshold, delay, retryInterval); + if (retryExecutor == null || retryExecutor.isShutdown()) { + retryExecutor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = Executors.defaultThreadFactory().newThread(r); + t.setDaemon(true); + t.setUncaughtExceptionHandler(new RetryUncaughtExceptionHandler()); + t.setName(Thread.currentThread().getName() + ":" + "DMARetryHandler" + ":" + taskId); + return t; + }); + logger.info("Created retry handler executor for taskId {}", taskId); + retryExecutor.scheduleWithFixedDelay(() -> { + try { + processRetries(); + } catch (Exception e) { + logger.error("Error occured while retrying {}", e); + } + }, 0, delay, TimeUnit.MILLISECONDS); + } + } + + /** + * Gets the event config provider impl. + * + * @param eventConfigProviderImplClass the event config provider impl class + * @return the event config provider impl + */ + @SuppressWarnings({ "rawtypes", "unchecked" }) + private EventConfigProvider getEventConfigProviderImpl(String eventConfigProviderImplClass) { + EventConfigProvider eventConfigProvider = null; + Class classObject = null; + try { + classObject = getClass().getClassLoader().loadClass(eventConfigProviderImplClass); + eventConfigProvider = (EventConfigProvider) ctx.getBean(classObject); + logger.info("Class {} loaded as EventConfigProvider", eventConfigProvider.getClass().getName()); + } catch (Exception e) { + try { + if (classObject == null) { + throw new IllegalArgumentException("Could not load the class " + eventConfigProviderImplClass); + } + eventConfigProvider = (EventConfigProvider) classObject.getDeclaredConstructor().newInstance(); + } catch (Exception exception) { + String msg = String.format("Class %s could not be loaded. Not found on classpath.", + eventConfigProviderImplClass); + logger.error(msg); + throw new IllegalArgumentException(msg); + } + } + return eventConfigProvider; + } + + /** + * Process retries. + */ + private void processRetries() { + /* + * Iterate over the timestamps in map which is less than equal to current + * timestamp. + */ + KeyValueIterator headMap = retryBucketDAO + .getHead(new RetryBucketKey(System.currentTimeMillis())); + /* + * If same keys are present in two different buckets, avoid processing them + * twice which could lead to duplicate requests at the same time. This can also + * occur mainly due to 2 reasons : if redis entries were not properly cleared or + * huge delay in processing + * + */ + Set processedKeys = new HashSet<>(); + if (headMap != null) { + while (headMap.hasNext()) { + KeyValue keyValue = headMap.next(); + RetryBucketKey bucket = keyValue.key; + long timestamp = bucket.getTimestamp(); + Set retryRecordKeys = keyValue.value.getRecordIds(); + if (retryRecordKeys != null && !retryRecordKeys.isEmpty()) { + logger.debug("Processing key {} from retry bucket with size {} with service {} , taskId {}", + timestamp, retryRecordKeys.size(), serviceName, taskId); + retryRecordKeys.forEach(retryRecordKey -> + createProcessesKeySet(processedKeys, timestamp, retryRecordKey)); + } else { + logger.debug("No deviceIds found for retrying at ts {} and service {}", timestamp, serviceName); + } + retryBucketDAO.deleteFromMap(retryBucketMapKey, bucket, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_BUCKET); + logger.debug("Deleted key {} and value from retry bucket.", timestamp); + } + } + } + + /** + * Creates the processes key set. + * + * @param processedKeys the processed keys + * @param timestamp the timestamp + * @param retryRecordKey the retry record key + */ + private void createProcessesKeySet(Set processedKeys, long timestamp, String retryRecordKey) { + if (!processedKeys.contains(retryRecordKey)) { + RetryRecordKey retryEventKey = new RetryRecordKey(retryRecordKey, taskId); + RetryRecord retryRecord = retryEventDAO.get(retryEventKey); + try { + if (retryRecord != null) { + logger.debug("Retrying key {} and value {} form timestamp {} bucket.", + retryRecord.getIgniteKey(), retryRecord.getDeviceMessage(), timestamp); + retryHandle(retryRecord.getIgniteKey(), retryRecord.getDeviceMessage(), false); + processedKeys.add(retryRecordKey); + } else { + logger.debug("Record not present/deleted from eventDao for key {} for ts {}", + retryRecord, retryEventKey, timestamp); + } + } catch (Exception e) { + logger.error( + "Error occured while retrying record {} from eventDao for key {} for ts {}", + retryRecord, retryEventKey, timestamp, e); + errorCounter.incErrorCounter(Optional.ofNullable(taskId), e.getClass()); + } + } + } + + /** + * Close. + */ + @Override + public void close() { + retryBucketDAO.close(); + retryEventDAO.close(); + if (retryExecutor != null && !retryExecutor.isShutdown()) { + logger.info("Shutting the SingleThreadScheduledExecutor for retry service!"); + ThreadUtils.shutdownExecutor(retryExecutor, + org.eclipse.ecsp.analytics.stream.base.utils.Constants.THREAD_SLEEP_TIME_2000, false); + } + } + + /** + * The Class RetryUncaughtExceptionHandler. + */ + private class RetryUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler { + + /** + * Uncaught exception. + * + * @param thread the thread + * @param t the t + */ + @Override + public void uncaughtException(Thread thread, Throwable t) { + logger.error("Uncaught exception detected!. Exception is: {} ", t); + } + } + + /** + * Sets the stream processing context. + * + * @param ctx the ctx + */ + @Override + public void setStreamProcessingContext(StreamProcessingContext, IgniteEvent> ctx) { + spc = ctx; + } + + /** + * Sets the conn status handler. + * + * @param connStatusHandler the new conn status handler + */ + public void setConnStatusHandler(DeviceConnectionStatusHandler connStatusHandler) { + this.connStatusHandler = connStatusHandler; + } +} \ No newline at end of file diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/presencemanager/DeviceFetchConnectionStatusProducer.java b/src/main/java/org/eclipse/ecsp/stream/dma/presencemanager/DeviceFetchConnectionStatusProducer.java new file mode 100644 index 0000000..fa53838 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/presencemanager/DeviceFetchConnectionStatusProducer.java @@ -0,0 +1,129 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.presencemanager; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.FetchConnectionStatusEventData; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + + +/** + * DeviceFetchConnectionStatusProducer is responsible to pushing event to + * fetch connection status kafka topic. + + * @author karora + */ +@Component +@Scope("prototype") +public class DeviceFetchConnectionStatusProducer { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DeviceFetchConnectionStatusProducer.class); + + /** The fetch connection status topic. */ + @Value("${" + PropertyNames.FETCH_CONNECTION_STATUS_TOPIC_NAME + ":}") + private String fetchConnectionStatusTopic; + + /** The global message id generator. */ + @Autowired + private GlobalMessageIdGenerator globalMessageIdGenerator; + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Push event to "Fetch Connection Status" kafka topic. + * + * @param key The IgniteKey + * @param header The DeviceMessageHeader + * @param ctx The StreamProcessingContext + */ + public void pushEventToFetchConnStatus(IgniteKey key, DeviceMessageHeader header, + StreamProcessingContext, IgniteEvent> ctx) { + setContext(ctx); + IgniteEventImpl fetchConnStatusEvent = createEvent(header); + logger.info("Sending event to topic : {} to fetch connection status : {}", + fetchConnectionStatusTopic, fetchConnStatusEvent); + spc.forwardDirectly(key, fetchConnStatusEvent, fetchConnectionStatusTopic); + } + + /** + * Creates the event. + * + * @param msgHeader the msg header + * @return the ignite event impl + */ + private IgniteEventImpl createEvent(DeviceMessageHeader msgHeader) { + FetchConnectionStatusEventData fetchConnEventData = new FetchConnectionStatusEventData(); + fetchConnEventData.setVehicleId(msgHeader.getVehicleId()); + fetchConnEventData.setPlatformId(msgHeader.getPlatformId()); + IgniteEventImpl fetchConnStatusEvent = new IgniteEventImpl(); + fetchConnStatusEvent.setEventId(EventID.FETCH_CONN_STATUS); + fetchConnStatusEvent.setTimestamp(System.currentTimeMillis()); + fetchConnStatusEvent.setVersion(Version.V1_0); + fetchConnStatusEvent.setEventData(fetchConnEventData); + fetchConnStatusEvent.setTimezone(msgHeader.getTimezone()); + fetchConnStatusEvent.setMessageId(globalMessageIdGenerator.generateUniqueMsgId(msgHeader.getVehicleId())); + + return fetchConnStatusEvent; + } + + /** + * Sets the context. + * + * @param ctx the ctx + */ + private void setContext(StreamProcessingContext, IgniteEvent> ctx) { + spc = ctx; + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/scheduler/DeviceMessagingEventScheduler.java b/src/main/java/org/eclipse/ecsp/stream/dma/scheduler/DeviceMessagingEventScheduler.java new file mode 100644 index 0000000..07d4915 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/scheduler/DeviceMessagingEventScheduler.java @@ -0,0 +1,308 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.scheduler; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator; +import org.eclipse.ecsp.analytics.stream.base.utils.JsonUtils; +import org.eclipse.ecsp.domain.EventAttribute; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.events.scheduler.CreateScheduleEventData; +import org.eclipse.ecsp.events.scheduler.CreateScheduleEventData.RecurrenceType; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.stream.dma.dao.DMNextTtlExpirationTimer; +import org.eclipse.ecsp.stream.dma.dao.DMNextTtlExpirationTimerDAOImpl; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntry; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntryDAOMongoImpl; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; + +import static org.eclipse.ecsp.stream.dma.dao.DMAConstants.DM_NEXT_TTL_EXPIRATION_TIMER_KEY; + + +/** + * DeviceMessagingEventScheduler is responsible to schedule events with ignite-scheduler. + * It also updates entry in DMNextTtlExpirationTimer + * with the timer for job scheduled + * + * @author karora + */ +@Component +@Scope("prototype") +@ConditionalOnProperty(name = PropertyNames.DMA_ENABLED, havingValue = "true") +public class DeviceMessagingEventScheduler { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DeviceMessagingEventScheduler.class); + + /** The Constant FIRING_COUNT. */ + private static final int FIRING_COUNT = 1; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The source topics. */ + @Value("#{'${" + PropertyNames.SOURCE_TOPIC_NAME + "}'.split(',')}") + private List sourceTopics; + + /** The dma notification topic. */ + @Value("${" + PropertyNames.DMA_NOTIFICATION_TOPIC_NAME + ":}") + private String dmaNotificationTopic; + + /** The scheduler agent topic. */ + @Value("${" + PropertyNames.SCHEDULER_AGENT_TOPIC_NAME + "}") + private String schedulerAgentTopic; + + /** The global message id generator. */ + @Autowired + private GlobalMessageIdGenerator globalMessageIdGenerator; + + /** The offline buffer DAO. */ + @Autowired + private DMOfflineBufferEntryDAOMongoImpl offlineBufferDAO; + + /** The dm next ttl expiration timer DAO. */ + @Autowired + private DMNextTtlExpirationTimerDAOImpl dmNextTtlExpirationTimerDAO; + + /** The spc. */ + private StreamProcessingContext spc; + + + /** + * Schedule a event with ignite-scheduler based on the entry with earliest TTL expiry in offline buffer collection. + * + * @param ctx StreamProcessingContext + */ + public void scheduleEvent(StreamProcessingContext ctx) { + + setContext(ctx); + DMOfflineBufferEntry offlineEntry = getEntryWithEarliestTtl(); + if (offlineEntry == null) { + // remove entry for scheduler executed, if no latest TTL available + dmNextTtlExpirationTimerDAO.deleteById(DM_NEXT_TTL_EXPIRATION_TIMER_KEY); + return; + } + createAndSendEvent(offlineEntry.getIgniteKey(), offlineEntry.getEvent(), offlineEntry.getTtlExpirationTime()); + } + + /** + * Schedule a event with ignite-scheduler based on the device delivery cutoff time + * of entity passed as parameter. + + * @param key The IgniteKey + * @param entity The DeviceMessage + * @param ctx StreamProcessingContext instance + */ + public void scheduleEvent(@SuppressWarnings("rawtypes") IgniteKey key, DeviceMessage entity, + StreamProcessingContext ctx) { + setContext(ctx); + DeviceMessageHeader msgHeader = entity.getDeviceMessageHeader(); + long deviceDeliveryCutOff = msgHeader.getDeviceDeliveryCutoff(); + + // Event not scheduled if valid value not present for device delivery cutoff + if (deviceDeliveryCutOff < 0) { + return; + } + long currentScheduledTime = getCurrentScheduledTimer(); + if (currentScheduledTime != 0 && currentScheduledTime < deviceDeliveryCutOff) { + logger.debug("Scheduler existing with time scheduled {}. No scheduler created for message with id: {}, " + + "vehicleId: {}, deviceDeliveryCutOff : {}", currentScheduledTime, msgHeader.getMessageId(), + msgHeader.getVehicleId(), deviceDeliveryCutOff); + return; + } + createAndSendEvent(key, entity, deviceDeliveryCutOff); + } + + /** + * Creates the and send event. + * + * @param key the key + * @param event the event + * @param ttlExpirationTime the ttl expiration time + */ + private void createAndSendEvent(@SuppressWarnings("rawtypes") IgniteKey key, DeviceMessage event, + long ttlExpirationTime) { + + saveNextTtlExpirationTime(ttlExpirationTime); + IgniteEventImpl createScheduleEvent = createEvent(event, getInitialDelay(ttlExpirationTime)); + logger.info("Sending event to topic : {} to create scheduler job : {}, scheduled to run at : {}", + schedulerAgentTopic, createScheduleEvent, Instant.ofEpochSecond(ttlExpirationTime)); + spc.forwardDirectly(key, createScheduleEvent, schedulerAgentTopic); + } + + /** + * Save next ttl expiration time. + * + * @param time the time + */ + private void saveNextTtlExpirationTime(long time) { + DMNextTtlExpirationTimer timer = new DMNextTtlExpirationTimer(time); + dmNextTtlExpirationTimerDAO.update(timer); + logger.debug("Timer set in dmNextTtlExpirationTimer {}, for service : {} ", timer, serviceName); + } + + /** + * Creates the event. + * + * @param event the event + * @param initialDelay the initial delay + * @return the ignite event impl + */ + private IgniteEventImpl createEvent(DeviceMessage event, long initialDelay) { + + DeviceMessageHeader msgHeader = event.getDeviceMessageHeader(); + CreateScheduleEventData createEventData = new CreateScheduleEventData(); + if (sourceTopics.isEmpty() && !StringUtils.hasText(dmaNotificationTopic)) { + logger.error("Failed to create scheduler event for vehicleId {}, requestId {}." + + " Source topic and notification topic is empty. ", + msgHeader.getVehicleId(), msgHeader.getRequestId()); + return null; + } + if (StringUtils.hasText(dmaNotificationTopic)) { + createEventData.setNotificationTopic(dmaNotificationTopic); + } else { + createEventData.setNotificationTopic(sourceTopics.get(0)); + } + createEventData.setServiceName(serviceName); + createEventData.setRecurrenceType(RecurrenceType.CUSTOM_MS); + createEventData.setFiringCount(FIRING_COUNT); + createEventData.setInitialDelayMs(initialDelay); + createEventData.setNotificationKey(new IgniteStringKey(msgHeader.getVehicleId())); + createEventData.setNotificationPayload(getNotificationPayload(msgHeader)); + + IgniteEventImpl createScheduleEvent = new IgniteEventImpl(); + createScheduleEvent.setEventId(EventID.CREATE_SCHEDULE_EVENT); + createScheduleEvent.setTimestamp(System.currentTimeMillis()); + createScheduleEvent.setVersion(Version.V1_0); + createScheduleEvent.setRequestId(msgHeader.getRequestId()); + createScheduleEvent.setMessageId(globalMessageIdGenerator.generateUniqueMsgId(msgHeader.getVehicleId())); + createScheduleEvent.setSourceDeviceId(msgHeader.getVehicleId()); + createScheduleEvent.setVehicleId(msgHeader.getVehicleId()); + createScheduleEvent.setEventData(createEventData); + + return createScheduleEvent; + } + + /** + * query dmNextTtlExpirationTimer collection to get the current scheduled timer against service name. + * + * @return long + */ + private long getCurrentScheduledTimer() { + DMNextTtlExpirationTimer dmNextTtlExpirationTimer = + dmNextTtlExpirationTimerDAO.findById(DM_NEXT_TTL_EXPIRATION_TIMER_KEY); + if (dmNextTtlExpirationTimer != null) { + return dmNextTtlExpirationTimer.getTtlExpirationTimer(); + } else { + logger.debug("No timer set for scheduling job against key: {} in dmNextTtlExpirationTimer, for service: {}", + DM_NEXT_TTL_EXPIRATION_TIMER_KEY, serviceName); + return 0; + } + } + + /** + * get the time at which scheduler will be executed. return initial delay as 1ms if ttl already expired. + * + * @param ttlExpirationTime ttlExpirationTime + * @return long + */ + private long getInitialDelay(long ttlExpirationTime) { + long initialDelay = ttlExpirationTime - System.currentTimeMillis(); + return initialDelay > 0 ? initialDelay : 1L; + } + + /** + * Gets the entry with earliest ttl. + * + * @return the entry with earliest ttl + */ + private DMOfflineBufferEntry getEntryWithEarliestTtl() { + return offlineBufferDAO.getOfflineBufferEntryWithEarliestTtl(); + } + + /** + * Gets the notification payload. + * + * @param msgHeader the msg header + * @return the notification payload + */ + private byte[] getNotificationPayload(DeviceMessageHeader msgHeader) { + HashMap notificationPaylod = new HashMap<>(); + notificationPaylod.put(EventAttribute.REQUEST_ID, msgHeader.getRequestId()); + notificationPaylod.put(EventAttribute.MESSAGE_ID, msgHeader.getMessageId()); + notificationPaylod.put(EventAttribute.DEVICE_DELIVERY_CUTOFF, + String.valueOf(msgHeader.getDeviceDeliveryCutoff())); + notificationPaylod.put(EventAttribute.VEHICLE_ID, msgHeader.getVehicleId()); + notificationPaylod.put(PropertyNames.SERVICE_NAME, serviceName); + notificationPaylod.put(PropertyNames.SOURCE_TOPIC_NAME, sourceTopics.toString()); + notificationPaylod.put(PropertyNames.DMA_NOTIFICATION_TOPIC_NAME, dmaNotificationTopic); + logger.debug("Schedule Notification payload: {}", notificationPaylod.toString()); + + return JsonUtils.getObjectValueAsString(notificationPaylod).getBytes(StandardCharsets.UTF_8); + } + + /** + * Sets the context. + * + * @param ctx the ctx + */ + private void setContext(StreamProcessingContext ctx) { + spc = ctx; + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapInvoker.java b/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapInvoker.java new file mode 100644 index 0000000..7cb1033 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapInvoker.java @@ -0,0 +1,67 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.shouldertap; + +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; + +import java.util.Map; + + +/** + * DeviceShoulderTapInvoker handles the initiating shoulder tap request and capturing the response. + * + * @author KJalawadi + */ +interface DeviceShoulderTapInvoker { + + /** + * Send wake up message. + * + * @param requestId the request id + * @param vehicleId the vehicle id + * @param extraParameters the extra parameters + * @param spc the spc + * @return true, if successful + */ + public boolean sendWakeUpMessage(String requestId, String vehicleId, + Map extraParameters, StreamProcessingContext, IgniteEvent> spc); +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapRetryHandler.java b/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapRetryHandler.java new file mode 100644 index 0000000..ea14b68 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapRetryHandler.java @@ -0,0 +1,555 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.shouldertap; + +import jakarta.annotation.PostConstruct; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.utils.InternalCacheConstants; +import org.eclipse.ecsp.domain.DeviceMessageFailureEventDataV1_0; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageErrorCode; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.entities.dma.RetryRecord; +import org.eclipse.ecsp.entities.dma.RetryRecordIds; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.ShoulderTapRetryBucketDAO; +import org.eclipse.ecsp.stream.dma.dao.ShoulderTapRetryRecordDAOCacheImpl; +import org.eclipse.ecsp.stream.dma.dao.key.RetryVehicleIdKey; +import org.eclipse.ecsp.stream.dma.dao.key.ShoulderTapRetryBucketKey; +import org.eclipse.ecsp.stream.dma.handler.DeviceMessageUtils; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.eclipse.ecsp.utils.metrics.IgniteErrorCounter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.DMA_SHOULDER_TAP_INVOKER_IMPL_CLASS; + + +/** + * DeviceShoulderTapRetryHandler maintains a cache of retry entities and handles shoulder tap retries. + * + * @author KJalawadi + */ + +@Component +@Scope("prototype") +public class DeviceShoulderTapRetryHandler { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DeviceShoulderTapRetryHandler.class); + + /** The shouder tap retry executor. */ + private ScheduledExecutorService shouderTapRetryExecutor = null; + + /** The ctx. */ + @Autowired + private ApplicationContext ctx; + + /** The device shoulder tap invoker impl class. */ + @Value("${" + DMA_SHOULDER_TAP_INVOKER_IMPL_CLASS + + ": org.eclipse.ecsp.stream.dma.shouldertap.DummyShoulderTapInvokerImpl}") + private String deviceShoulderTapInvokerImplClass; + + /** The device shoulder tap invoker. */ + private DeviceShoulderTapInvoker deviceShoulderTapInvoker; + + /** The shoulder tap retry bucket DAO. */ + @Autowired + private ShoulderTapRetryBucketDAO shoulderTapRetryBucketDAO; + + /** The shoulder tap retry record DAO. */ + @Autowired + private ShoulderTapRetryRecordDAOCacheImpl shoulderTapRetryRecordDAO; + + /** The task id. */ + private String taskId; + + /** The shoulder tap retry bucket map key. */ + private String shoulderTapRetryBucketMapKey; + + /** The shoulder tap retry event map key. */ + private String shoulderTapRetryEventMapKey; + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** The max retry. */ + @Value("${" + PropertyNames.SHOULDER_TAP_MAX_RETRY + ":3}") + private int maxRetry; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The retry interval. */ + @Value("${" + PropertyNames.SHOULDER_TAP_RETRY_INTERVAL_MILLIS + ":60000}") + private long retryInterval; + + /** The retry min threshold. */ + @Value("${" + PropertyNames.SHOULDER_TAP_RETRY_MIN_THRESHOLD_MILLIS + ":1000}") + private int retryMinThreshold; + + /** The retry interval divisor. */ + @Value("${" + PropertyNames.SHOULDER_TAP_RETRY_INTERVAL_DIVISOR + ":10}") + private int retryIntervalDivisor; + + /** The device message utils. */ + @Autowired + private DeviceMessageUtils deviceMessageUtils; + + /** The error counter. */ + @Autowired + private IgniteErrorCounter errorCounter; + + /** + * Sets the retry min threshold. + * + * @param retryMinThreshold the new retry min threshold + */ + public void setRetryMinThreshold(int retryMinThreshold) { + this.retryMinThreshold = retryMinThreshold; + } + + /** + * Sets the max retry. + * + * @param maxRetry the new max retry + */ + public void setMaxRetry(int maxRetry) { + this.maxRetry = maxRetry; + } + + /** + * Sets the service name. + * + * @param serviceName the new service name + */ + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + /** + * Sets the retry interval. + * + * @param retryInterval the new retry interval + */ + public void setRetryInterval(long retryInterval) { + this.retryInterval = retryInterval; + } + + /** + * Sets the retry interval divisor. + * + * @param retryIntervalDivisor the new retry interval divisor + */ + public void setRetryIntervalDivisor(int retryIntervalDivisor) { + this.retryIntervalDivisor = retryIntervalDivisor; + } + + /** + * Gets the scheduled thread delay. + * + * @return the scheduled thread delay + */ + long getScheduledThreadDelay() { + long freq = retryInterval / retryIntervalDivisor; + return freq > retryMinThreshold ? freq : retryMinThreshold; + } + + /** + * Register device. + * + * @param key the key + * @param deviceMessage the device message + * @param extraParameters the extra parameters + * @return true, if successful + */ + public boolean registerDevice(IgniteKey key, DeviceMessage deviceMessage, Map extraParameters) { + return shoulderTapRetry(key, deviceMessage, true, extraParameters); + } + + /** + * Shoulder tap retry. + * + * @param key the key + * @param deviceMessage the device message + * @param firstAttempt the first attempt + * @param extraParameters the extra parameters + * @return true, if successful + */ + private boolean shoulderTapRetry(IgniteKey key, DeviceMessage deviceMessage, boolean firstAttempt, + Map extraParameters) { + DeviceMessageHeader header = deviceMessage.getDeviceMessageHeader(); + boolean registered = false; + long currentTime = System.currentTimeMillis(); + if (maxRetry > 0) { + String vehicleId = header.getVehicleId(); + RetryVehicleIdKey retryEventKey = new RetryVehicleIdKey(vehicleId); + RetryRecord event = shoulderTapRetryRecordDAO.get(retryEventKey); + if (event != null) { + if (firstAttempt) { + logger.debug("Shoulder Tap already being retried for vehicleId {}: ", vehicleId); + return true; + } + attemptRetry(event, currentTime, vehicleId, retryEventKey); + } else if (firstAttempt) { + addToRetryMap(currentTime, key, deviceMessage, vehicleId, retryEventKey, extraParameters); + registered = true; + } + } else { + RetryRecord event = new RetryRecord(key, deviceMessage, currentTime); + event.setExtraParameters(extraParameters); + event.setLastRetryTimestamp(System.currentTimeMillis()); + DeviceMessageFailureEventDataV1_0 failEventData = new DeviceMessageFailureEventDataV1_0(); + failEventData.setFailedIgniteEvent(event.getDeviceMessage().getEvent()); + failEventData.setErrorCode(DeviceMessageErrorCode.RETRYING_SHOULDER_TAP); + // In Shoulder tap unlike message retry, service needs to + // know whenever a shoulder tap is sent. Hence the attempt is zero + // in this scenario where its the first ever shoulder tap being + // attempted and actual shoulder tap retry starts from next attempt. + failEventData.setShoudlerTapRetryAttempts(0); + failEventData.setDeviceStatusInactive(true); + deviceMessageUtils.postFailureEvent(failEventData, key, spc, deviceMessage.getFeedBackTopic()); + wakeUpDevice(event); + logger.debug("Retry attempts is 0, for key {} and event {}", key, + deviceMessage); + } + return registered; + } + + /** + * Adds the to retry map. + * + * @param currentTime the current time + * @param key the key + * @param value the value + * @param vehicleId the vehicle id + * @param retryEventKey the retry event key + * @param extraParameters the extra parameters + */ + private void addToRetryMap(long currentTime, IgniteKey key, DeviceMessage value, String vehicleId, + RetryVehicleIdKey retryEventKey, Map extraParameters) { + RetryRecord event = new RetryRecord(key, value, currentTime); + event.setExtraParameters(extraParameters); + event.setLastRetryTimestamp(currentTime); + shoulderTapRetryRecordDAO.putToMap(shoulderTapRetryEventMapKey, retryEventKey, event, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_RECORD); + long nextRetry = currentTime + retryInterval; + logger.debug("Created event {} with key {} to retry event map.", event, retryEventKey.convertToString()); + ShoulderTapRetryBucketKey nextRetryKey = new ShoulderTapRetryBucketKey(nextRetry); + shoulderTapRetryBucketDAO.update(shoulderTapRetryBucketMapKey, nextRetryKey, vehicleId); + logger.debug("Created entry {} with timestamp {} to retry event map.", vehicleId, nextRetry); + DeviceMessageFailureEventDataV1_0 failEventData = new DeviceMessageFailureEventDataV1_0(); + failEventData.setFailedIgniteEvent(event.getDeviceMessage().getEvent()); + failEventData.setErrorCode(DeviceMessageErrorCode.RETRYING_SHOULDER_TAP); + failEventData.setShoudlerTapRetryAttempts(event.getAttempts()); + failEventData.setDeviceStatusInactive(true); + deviceMessageUtils.postFailureEvent(failEventData, key, spc, value.getFeedBackTopic()); + wakeUpDevice(event); + } + + /** + * Attempt retry. + * + * @param event the event + * @param currentTime the current time + * @param vehicleId the vehicle id + * @param retryEventKey the retry event key + */ + private void attemptRetry(RetryRecord event, long currentTime, String vehicleId, RetryVehicleIdKey retryEventKey) { + IgniteKey key = event.getIgniteKey(); + DeviceMessage value = event.getDeviceMessage(); + // Check if number of retries has exeeded max retries, If + // yes delete entry from retryEvent map and return true else + // return false. + if (event.getAttempts() >= maxRetry) { + logger.debug("Retry exceeded maxRetry {} for key {}", maxRetry, key); + DeviceMessageFailureEventDataV1_0 failEventData = new DeviceMessageFailureEventDataV1_0(); + failEventData.setFailedIgniteEvent(event.getDeviceMessage().getEvent()); + failEventData.setErrorCode(DeviceMessageErrorCode.SHOULDER_TAP_RETRY_ATTEMPTS_EXCEEDED); + failEventData.setShoudlerTapRetryAttempts(maxRetry); + failEventData.setDeviceStatusInactive(true); + deviceMessageUtils.postFailureEvent(failEventData, key, spc, value.getFeedBackTopic()); + shoulderTapRetryRecordDAO.deleteFromMap(shoulderTapRetryEventMapKey, retryEventKey, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_RECORD); + logger.debug("Deleted key {} from retryEventDAO", retryEventKey.convertToString()); + } else { + event.addAttempt(currentTime); + long nextRetry = currentTime + retryInterval; + shoulderTapRetryRecordDAO.putToMap(shoulderTapRetryEventMapKey, retryEventKey, event, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_RECORD); + logger.debug("Added event {} with key {} to retry event map.", event, retryEventKey.convertToString()); + // Add vehicleId to set of vehicleIds in retry + // bucket keyed by timestamp + ShoulderTapRetryBucketKey nextRetryKey = new ShoulderTapRetryBucketKey(nextRetry); + shoulderTapRetryBucketDAO.update(shoulderTapRetryBucketMapKey, nextRetryKey, vehicleId); + logger.debug("Added entry {} with timestamp {} to retry bucket.", vehicleId, nextRetry); + DeviceMessageFailureEventDataV1_0 failEventData = new DeviceMessageFailureEventDataV1_0(); + failEventData.setFailedIgniteEvent(value.getEvent()); + failEventData.setErrorCode(DeviceMessageErrorCode.RETRYING_SHOULDER_TAP); + failEventData.setShoudlerTapRetryAttempts(event.getAttempts()); + failEventData.setDeviceStatusInactive(true); + deviceMessageUtils.postFailureEvent(failEventData, key, spc, value.getFeedBackTopic()); + wakeUpDevice(event); + } + } + + /** + * Wake up device. + * + * @param retryRecord the retry record + * @return true, if successful + */ + private boolean wakeUpDevice(RetryRecord retryRecord) { + boolean wakeUpStatus = false; + DeviceMessage message = retryRecord.getDeviceMessage(); + Map extraParameters = retryRecord.getExtraParameters(); + DeviceMessageHeader header = message.getDeviceMessageHeader(); + wakeUpStatus = deviceShoulderTapInvoker.sendWakeUpMessage(header.getRequestId(), + header.getVehicleId(), extraParameters, spc); + logger.debug("Waking up device: requestId={} vehicleId={} serviceName={} wakeUpStatus={}", + header.getRequestId(), header.getVehicleId(), serviceName, wakeUpStatus); + return wakeUpStatus; + } + + /** + * When Device comes ACTIVE this method is invoked so that shoulder tap will not be invoked. + * + * @param vehicleId vehicleId + * @return boolean + */ + public boolean deregisterDevice(String vehicleId) { + RetryVehicleIdKey retryEventKey = new RetryVehicleIdKey(vehicleId); + shoulderTapRetryRecordDAO.deleteFromMap(shoulderTapRetryEventMapKey, retryEventKey, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_RECORD); + logger.debug("Deleted {} from cache", retryEventKey.convertToString()); + return true; + } + + /** + * setup(). + * + * @param taskId taskId + */ + public void setup(String taskId) { + this.taskId = taskId; + shoulderTapRetryBucketMapKey = ShoulderTapRetryBucketKey.getMapKey(serviceName, taskId); + shoulderTapRetryEventMapKey = new StringBuilder().append(DMAConstants + .SHOULDER_TAP_RETRY_VEHICLEID).append(DMAConstants.COLON) + .append(serviceName).append(DMAConstants.COLON).append(taskId).toString(); + if (retryMinThreshold <= 0) { + throw new IllegalArgumentException( + "Shoulder tap Retry Minimum threshold " + retryMinThreshold + + ", should be greater than one second "); + } + if (retryInterval <= 0) { + throw new IllegalArgumentException( + "Shoulder tap Retry Interval " + retryInterval + ", should be greater than zero "); + } + if (retryInterval < retryMinThreshold) { + throw new IllegalArgumentException("Retry Interval " + retryInterval + + ", should be greater than Minimum threshold " + retryMinThreshold); + } + + if (maxRetry < 0) { + logger.warn("Max Shoulder tap retry cannot be less than 0. No retry will be attempted."); + maxRetry = 0; + } + shoulderTapRetryBucketDAO.initialize(taskId); + shoulderTapRetryRecordDAO.initialize(taskId); + long delay = getScheduledThreadDelay(); + logger.info("Minimum threshold for shoulder tap retry is {} ; Scheduled thread delay is {}." + + " Retry Interval for service is {}", retryMinThreshold, delay, retryInterval); + shouderTapRetryExecutor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = Executors.defaultThreadFactory().newThread(r); + t.setDaemon(true); + t.setUncaughtExceptionHandler((thread, t1) -> logger.error("Uncaught exception detected!. " + + "Exception is: {}", t1)); + t.setName(Thread.currentThread().getName() + ":" + "ShoulderTapRetryHandler" + ":" + taskId); + return t; + }); + + shouderTapRetryExecutor.scheduleWithFixedDelay(() -> { + try { + processRetries(); + } catch (Exception e) { + logger.error("Error occured while retrying {}", e); + } + + }, 0, delay, TimeUnit.MILLISECONDS); + } + + /** + * processRetries(). + */ + private void processRetries() { + /* + * Iterate over the timestamps in map which is less than equal to + * current timestamp. + */ + KeyValueIterator headMap = shoulderTapRetryBucketDAO + .getHead(new ShoulderTapRetryBucketKey(System.currentTimeMillis())); + /* + * If same keys are present in two different buckets, avoid processing + * them twice which could lead to duplicate requests at the same time. + * This can also occur mainly due to 2 reasons : if redis entries were + * not properly cleared or huge delay in processing + * + */ + Set processedKeys = new HashSet<>(); + if (headMap != null) { + while (headMap.hasNext()) { + KeyValue keyValue = headMap.next(); + ShoulderTapRetryBucketKey bucket = keyValue.key; + long timestamp = bucket.getTimestamp(); + Set vehicleIds = keyValue.value.getRecordIds(); + if (vehicleIds != null && !vehicleIds.isEmpty()) { + logger.trace("Processing key {} from shoulder tap retry bucket with size {} with service {}, " + + "taskId {}", timestamp, vehicleIds.size(), serviceName, taskId); + vehicleIds.forEach(vehicleId -> + createProcessedKeysSet(processedKeys, timestamp, vehicleId) + ); + } else { + logger.debug("No deviceIds found for retrying at ts {} and service {}", timestamp, serviceName); + } + shoulderTapRetryBucketDAO.deleteFromMap(shoulderTapRetryBucketMapKey, bucket, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_BUCKET); + logger.trace("Deleted key {} and value from retry bucket.", timestamp); + } + } + + } + + /** + * Creates the processed keys set. + * + * @param processedKeys the processed keys + * @param timestamp the timestamp + * @param vehicleId the vehicle id + */ + private void createProcessedKeysSet(Set processedKeys, long timestamp, String vehicleId) { + if (!processedKeys.contains(vehicleId)) { + RetryVehicleIdKey shoulderTapKey = new RetryVehicleIdKey(vehicleId); + RetryRecord retryRecord = shoulderTapRetryRecordDAO.get(shoulderTapKey); + try { + if (retryRecord != null) { + logger.trace("Retrying key {} and value {} form timestamp {} bucket.", + retryRecord.getIgniteKey(), retryRecord.getDeviceMessage(), timestamp); + shoulderTapRetry(retryRecord.getIgniteKey(), retryRecord.getDeviceMessage(), false, + retryRecord.getExtraParameters()); + processedKeys.add(vehicleId); + } else { + logger.debug("Record {} not present/deleted from eventDao for key {} for ts {}", retryRecord, + shoulderTapKey, timestamp); + } + } catch (Exception e) { + logger.error("Error occured while retrying record {} from eventDao for key {} for ts {}", retryRecord, + shoulderTapKey, timestamp, e); + errorCounter.incErrorCounter(Optional.ofNullable(taskId), e.getClass()); + } + } + } + + /** + * close(): closes the opened resources. + */ + public void close() { + if (shouderTapRetryExecutor != null && !shouderTapRetryExecutor.isShutdown()) { + logger.info("Shutting the SingleThreadScheduledExecutor for shoulder tap retry service!"); + shouderTapRetryExecutor.shutdown(); + } + } + + /** + * Sets the stream processing context. + * + * @param ctx the ctx + */ + public void setStreamProcessingContext(StreamProcessingContext, IgniteEvent> ctx) { + spc = ctx; + } + + /** + * Inits the. + */ + @PostConstruct + public void init() { + deviceShoulderTapInvoker = createDeviceShoulderTapInvoker(); + } + + /** + * Creates the device shoulder tap invoker. + * + * @return the device shoulder tap invoker + */ + @SuppressWarnings({ "rawtypes", "unchecked" }) + private DeviceShoulderTapInvoker createDeviceShoulderTapInvoker() { + DeviceShoulderTapInvoker shoulderTapInvoker = null; + try { + Class classObject = getClass().getClassLoader().loadClass(deviceShoulderTapInvokerImplClass); + shoulderTapInvoker = (DeviceShoulderTapInvoker) ctx.getBean(classObject); + } catch (Exception e) { + String msg = String.format("Failed to initialize Shoulder tap retry handler. " + + "%s is not available on the classpath", + deviceShoulderTapInvokerImplClass); + logger.error(msg); + throw new IllegalArgumentException(msg); + } + return shoulderTapInvoker; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapService.java b/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapService.java new file mode 100644 index 0000000..199aa28 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapService.java @@ -0,0 +1,168 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.shouldertap; + +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.util.Map; + + +/** + * DeviceShoulderTapService handles device shoulder tap wake up request. + * This interacts with DeviceShoulderTapRetryHandler to + * register/deregister device for retries. + * + * @author KJalawadi + */ + +@Component +@Scope("prototype") +public class DeviceShoulderTapService { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DeviceShoulderTapService.class); + + /** The device shoulder tap retry handler. */ + @Autowired + private DeviceShoulderTapRetryHandler deviceShoulderTapRetryHandler; + + /** The shoulder tap enabled. */ + @Value("${dma.shoulder.tap.enabled:false}") + private boolean shoulderTapEnabled; + + /** + * When device is inactive and shoulder tap enabled we need to wake up a device. + * + * @param requestId requestId + * @param vehicleId vehicleId + * @param serviceName serviceName + * @param igniteKey igniteKey + * @param igniteEvent igniteEvent + * @param extraParameters extraParameters + * @return boolean + */ + public boolean wakeUpDevice(String requestId, String vehicleId, String serviceName, IgniteKey igniteKey, + DeviceMessage igniteEvent, Map extraParameters) { + if (shoulderTapEnabled) { + boolean registered = registerWithDeviceShoulderTapRetryHandler(igniteKey, igniteEvent, extraParameters); + logger.debug("Registered device with DeviceShoulderTapRetryHandler: requestId={} vehicleId={} " + + "serviceName={} registered={}", requestId, vehicleId, serviceName, registered); + return registered; + } else { + logger.trace("Shoulder tap has been disabled for this service. Unable to wakeup device"); + return false; + } + + } + + /** + * When device is active we need to stop sending shoulder tap messages. + * + * @param vehicleId vehicleId + * @param deviceId deviceId + * @param serviceName serviceName + */ + public void executeOnDeviceActiveStatus(String vehicleId, String deviceId, String serviceName) { + boolean deregistered = false; + if (shoulderTapEnabled) { + deregistered = deviceShoulderTapRetryHandler.deregisterDevice(vehicleId); + logger.debug("Deregistered device with DeviceShoulderTapRetryHandler: deviceId={} serviceName={} " + + "deregistered={}", deviceId, serviceName, deregistered); + } else { + logger.trace("Shoulder tap has been disabled for this service. No action performed"); + } + } + + /** + * Register with device shoulder tap retry handler. + * + * @param key the key + * @param deviceMessage the device message + * @param extraParameters the extra parameters + * @return true, if successful + */ + private boolean registerWithDeviceShoulderTapRetryHandler(IgniteKey key, DeviceMessage deviceMessage, + Map extraParameters) { + boolean registered = false; + registered = deviceShoulderTapRetryHandler.registerDevice(key, deviceMessage, extraParameters); + return registered; + } + + /** + * Sets the stream processing context. + * + * @param spc the spc + */ + public void setStreamProcessingContext(StreamProcessingContext, IgniteEvent> spc) { + deviceShoulderTapRetryHandler.setStreamProcessingContext(spc); + } + + /** + * setup(). + * + * @param taskId taskId + */ + public void setup(String taskId) { + if (shoulderTapEnabled) { + deviceShoulderTapRetryHandler.setup(taskId); + } else { + logger.warn("Shoulder tap has been disabled for this service. Not setting up ShoulderTapRetryHandler!!!"); + } + } + + /** + * Sets the shoulder tap enabled. + * + * @param shoulderTapEnabled the new shoulder tap enabled + */ + protected void setShoulderTapEnabled(boolean shoulderTapEnabled) { + this.shoulderTapEnabled = shoulderTapEnabled; + } + +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DummyShoulderTapInvokerImpl.java b/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DummyShoulderTapInvokerImpl.java new file mode 100644 index 0000000..dbf6e11 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/DummyShoulderTapInvokerImpl.java @@ -0,0 +1,80 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.shouldertap; + +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.Map; + + +/** + * DummyShoulderTapInvokerWAMImpl is a dummy implementation with no shoulder tap invocation. + * + * @author KJalawadi + */ + +@Component +public class DummyShoulderTapInvokerImpl implements DeviceShoulderTapInvoker { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(DummyShoulderTapInvokerImpl.class); + + /** + * Send wake up message. + * + * @param requestId the request id + * @param vehicleId the vehicle id + * @param extraParameters the extra parameters + * @param spc the spc + * @return true, if successful + */ + @Override + public boolean sendWakeUpMessage(String requestId, String vehicleId, Map extraParameters, + StreamProcessingContext, IgniteEvent> spc) { + logger.debug("DummyShoulderTapInvokerImpl does not wake up any device: requestId={} vehicleId={} " + + "extraParameters={}", requestId, vehicleId, extraParameters); + return false; + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerVehicleNotificationImpl.java b/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerVehicleNotificationImpl.java new file mode 100644 index 0000000..cf50c1b --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerVehicleNotificationImpl.java @@ -0,0 +1,70 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.shouldertap; + +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; + +import java.util.Map; + + +/** + * {@link ShoulderTapInvokerVehicleNotificationImpl} implements {@link DeviceShoulderTapInvoker}. + * + * @author KJalawadi + */ +class ShoulderTapInvokerVehicleNotificationImpl implements DeviceShoulderTapInvoker { + + /** + * Send wake up message. + * + * @param requestId the request id + * @param vehicleId the vehicle id + * @param extraParameters the extra parameters + * @param spc the spc + * @return true, if successful + */ + @Override + public boolean sendWakeUpMessage(String requestId, String vehicleId, + Map extraParameters, StreamProcessingContext, IgniteEvent> spc) { + return false; + } +} diff --git a/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerWAMImpl.java b/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerWAMImpl.java new file mode 100644 index 0000000..8ceae94 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerWAMImpl.java @@ -0,0 +1,444 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.shouldertap; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.annotation.PostConstruct; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.http.HttpClient; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.DMA_SHOULDER_TAP_INVOKER_WAM_SEND_SMS_URL; +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.DMA_SHOULDER_TAP_INVOKER_WAM_SMS_TRANSACTION_STATUS_URL; +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.DMA_SHOULDER_TAP_WAM_API_MAX_RETRY_COUNT; +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.DMA_SHOULDER_TAP_WAM_API_MAX_RETRY_INTERVAL_MS; +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.DMA_SHOULDER_TAP_WAM_SEND_SMS_SKIP_STATUS_CHECK; +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.DMA_SHOULDER_TAP_WAM_SMS_PRIORITY; +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.DMA_SHOULDER_TAP_WAM_SMS_VALIDITY_HOURS; +import static org.eclipse.ecsp.stream.dma.dao.DMAConstants.BIZ_TRANSACTION_ID; + + +/** + * ShoulderTapInvokerWAMImpl invokes device shoulder tap SMS request via WAM API. + * + * @author KJalawadi + */ + +@Component +class ShoulderTapInvokerWAMImpl implements DeviceShoulderTapInvoker { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(ShoulderTapInvokerWAMImpl.class); + + /** The api header key session id. */ + // API header + private static String apiHeaderKeySessionId = "SessionId"; + + /** The api header key client request id. */ + private static String apiHeaderKeyClientRequestId = "ClientRequestId"; + + /** The api param key vehicle id. */ + // API params + private static String apiParamKeyVehicleId = "vehicleId"; + + /** The api param key sms type. */ + private static String apiParamKeySmsType = "smsType"; + + /** The api param value shoulder tap sms type. */ + private static String apiParamValueShoulderTapSmsType = "SHOULDER_TAP"; + + /** The api param key additional. */ + private static String apiParamKeyAdditional = "additionalParameters"; + + /** The api nested param name key. */ + private static String apiNestedParamNameKey = "key"; + + /** The api nested param name value. */ + private static String apiNestedParamNameValue = "value"; + + /** The api nested param value priority. */ + private static String apiNestedParamValuePriority = "priority"; + + /** The api nested param value validity hours. */ + private static String apiNestedParamValueValidityHours = "validityHours"; + + /** The api response data key. */ + // API response + private static String apiResponseDataKey = "data"; + + /** The api response message key. */ + private static String apiResponseMessageKey = "message"; + + /** The api response message value success. */ + private static String apiResponseMessageValueSuccess = "SUCCESS"; + + /** The api response value transid. */ + private static String apiResponseValueTransid = "transactionId"; + + /** The api response status key. */ + private static String apiResponseStatusKey = "status"; + + /** The send sms api http resp code. */ + private static String sendSmsApiHttpRespCode = "202"; + + /** The trans status api http resp code. */ + private static String transStatusApiHttpRespCode = "200"; + + /** + * The Enum SmsTransactionStatus. + */ + private enum SmsTransactionStatus { + + /** The new. */ + NEW, + /** The pending. */ + PENDING, + /** The error. */ + ERROR, + /** The success. */ + SUCCESS; + } + + /** The shoulder tap sms transaction id. */ + static String shoulderTapSmsTransactionId = "shoulderTapSMSTransactionId"; + + /** The wam send SMS url. */ + @Value("${" + DMA_SHOULDER_TAP_INVOKER_WAM_SEND_SMS_URL + "}") + private String wamSendSMSUrl; + + /** The wam transaction status url. */ + @Value("${" + DMA_SHOULDER_TAP_INVOKER_WAM_SMS_TRANSACTION_STATUS_URL + "}") + private String wamTransactionStatusUrl; + + /** The shoulder tap SMS priority. */ + @Value("${" + DMA_SHOULDER_TAP_WAM_SMS_PRIORITY + "}") + private String shoulderTapSMSPriority; + + /** The shoulder tap SMS validity hours. */ + @Value("${" + DMA_SHOULDER_TAP_WAM_SMS_VALIDITY_HOURS + "}") + private String shoulderTapSMSValidityHours; + + /** The wam send SMS skip status check. */ + @Value("${" + DMA_SHOULDER_TAP_WAM_SEND_SMS_SKIP_STATUS_CHECK + "}") + private boolean wamSendSMSSkipStatusCheck; + + /** The wam API max retry count. */ + @Value("${" + DMA_SHOULDER_TAP_WAM_API_MAX_RETRY_COUNT + "}") + private int wamAPIMaxRetryCount; + + /** The wam API max retry interval ms. */ + @Value("${" + DMA_SHOULDER_TAP_WAM_API_MAX_RETRY_INTERVAL_MS + "}") + private long wamAPIMaxRetryIntervalMs; + + /** The http client. */ + @Autowired + private HttpClient httpClient; + + /** + * Inits the. + */ + @PostConstruct + public void init() { + if (StringUtils.isEmpty(wamSendSMSUrl) || StringUtils.isEmpty(wamTransactionStatusUrl)) { + String msg = String.format("Failed to initialize ShoulderTapInvokerWAMImpl." + + " Missing property configuration: %s, %s.", + DMA_SHOULDER_TAP_INVOKER_WAM_SEND_SMS_URL, DMA_SHOULDER_TAP_INVOKER_WAM_SMS_TRANSACTION_STATUS_URL); + logger.error(msg); + throw new IllegalArgumentException(msg); + } + } + + /** + * Send shoulder tap SMS request to wake up device. This saves the + * transactionId of the request in extraParameters, which is used to get + * the SMS delivery status. Send SMS is invoked if the transaction + * status of the previous request is invalid or error, or if SMS is + * delivered but device status is still inactive. + * + * @param requestId requestId + * @param vehicleId vehicleId + * @param extraParameters extraParameters + * both in and out parameters + * @param spc the spc + * @return true, if successful + */ + + @Override + public boolean sendWakeUpMessage(String requestId, String vehicleId, + Map extraParameters, StreamProcessingContext, IgniteEvent> spc) { + logger.info("Calling WAM Send SMS endpoint: requestId={} vehicleId={} extraParameters={}", + requestId, vehicleId, extraParameters); + boolean wakeUpStatus = false; + try { + boolean invokeSendSMS = true; + if (!wamSendSMSSkipStatusCheck && extraParameters.containsKey(shoulderTapSmsTransactionId)) { + String transactionId = extraParameters.get(shoulderTapSmsTransactionId).toString(); + + logger.debug("Calling WAM endpoint to get the transaction status of already sent shouldertap SMS: " + + "wamTransactionStatusUrl={} requestId={} vehicleId={} transactionId={} extraParameters={}", + wamTransactionStatusUrl, requestId, vehicleId, transactionId, extraParameters); + + SmsTransactionStatus smsTransStatus = + getSMSTransactionStatus(requestId, vehicleId, transactionId, extraParameters); + + // Don't send SMS if the transaction status is PENDING. + // Send SMS again for possible cases: + // 1) NEW (SMS created but not dispatched) + // 2) ERROR (API internal/external/network error) or + // 3) SUCCESS (delivered to device) + // For #3 above, DeviceShoulderTapRetryHandler + // retries device wake up until either DeviceStatusService has + // not signaled ACTIVE device status or max retries attempted. + if (SmsTransactionStatus.PENDING.equals(smsTransStatus)) { + invokeSendSMS = false; + } + } + + if (invokeSendSMS) { + logger.debug("Calling WAM Send SMS endpoint to for shouldertap request: wamSendSMSUrl={} requestId={} " + + "-vehicleId={} extraParameters={}", wamSendSMSUrl, requestId, vehicleId, extraParameters); + Map requestHeaders = getRequestHeaders(requestId, extraParameters); + Map requestParams = getRequestParams(vehicleId); + Map additionalParamPriority = getAddtionalParamPriority(); + + getAdditionalParamValidityHours(requestParams, additionalParamPriority); + // Invoke SMS send request + Map responseData = httpClient.invokeJsonResource( + HttpClient.HttpReqMethod.PUT, wamSendSMSUrl, + requestHeaders, requestParams, wamAPIMaxRetryCount, wamAPIMaxRetryIntervalMs); + String responseCode = (String) responseData.get(HttpClient.RESPONSE_CODE); + JsonNode responseJson = (JsonNode) responseData.get(HttpClient.RESPONSE_JSON); + + if (sendSmsApiHttpRespCode.equals(responseCode) && responseJson != null) { + logger.info("Received WAM Send SMS endpoint response: wamSendSMSUrl={} requestHeaders={} " + + "requestParams={} responseJson={}", wamSendSMSUrl, requestHeaders, requestParams, + responseJson); + String responseMsg = responseJson.findValue(apiResponseMessageKey).asText(); + String transactionId = null; + if (apiResponseMessageValueSuccess.equalsIgnoreCase(responseMsg)) { + JsonNode dataNode = responseJson.findPath(apiResponseDataKey); + wakeUpStatus = addParamForShoulderTapTxn(dataNode, wakeUpStatus, extraParameters); + } + logger.debug("WAM Send SMS endpoint called: wamSendSMSUrl={} requestId={} vehicleId={} " + + "extraParameters={} responseData={} transactionId={} wakeUpStatus={}", wamSendSMSUrl, + requestId, vehicleId, extraParameters, responseData, transactionId, wakeUpStatus); + } else { + logger.error( + "WAM Send SMS request has failed: wamSendSMSUrl={} requestId={} vehicleId={} " + + "extraParameters={} responseData={} wakeUpStatus={}", + wamSendSMSUrl, requestId, vehicleId, extraParameters, responseData, wakeUpStatus); + } + } + } catch (Exception e) { + logger.error("ShoulderTapInvokerWAMImpl has encountered an error while sending wake up message: " + + "requestId={} vehicleId={} extraParameters={} error={}", + requestId, vehicleId, extraParameters, e.getMessage()); + } + return wakeUpStatus; + } + + /** + * Gets the additional param validity hours. + * + * @param requestParams the request params + * @param additionalParamPriority the additional param priority + * @return the additional param validity hours + */ + private void getAdditionalParamValidityHours(Map requestParams, + Map additionalParamPriority) { + Map additionalParamValidityHours = new HashMap<>(); + additionalParamValidityHours.put(apiNestedParamNameKey, apiNestedParamValueValidityHours); + // VALDITY HOURS -> 72 hours + additionalParamValidityHours.put(apiNestedParamNameValue, shoulderTapSMSValidityHours); + + requestParams.put(apiParamKeyAdditional, Arrays.asList(additionalParamPriority, + additionalParamValidityHours)); + } + + /** + * Gets the addtional param priority. + * + * @return the addtional param priority + */ + private Map getAddtionalParamPriority() { + Map additionalParamPriority = new HashMap<>(); + additionalParamPriority.put(apiNestedParamNameKey, apiNestedParamValuePriority); + // PRIORITY -> HIGH + additionalParamPriority.put(apiNestedParamNameValue, shoulderTapSMSPriority); + return additionalParamPriority; + } + + /** + * Gets the request params. + * + * @param vehicleId the vehicle id + * @return the request params + */ + private Map getRequestParams(String vehicleId) { + Map requestParams = new HashMap<>(); + // SMS_TYPE -> SHOULDER_TAP + requestParams.put(apiParamKeyVehicleId, vehicleId); + requestParams.put(apiParamKeySmsType, apiParamValueShoulderTapSmsType); + return requestParams; + } + + /** + * Gets the request headers. + * + * @param requestId the request id + * @param extraParameters the extra parameters + * @return the request headers + */ + private Map getRequestHeaders(String requestId, Map extraParameters) { + Map requestHeaders = new HashMap<>(); + // ClientRequestId -> requestId + requestHeaders.put(apiHeaderKeyClientRequestId, requestId); + // SessionId -> bizTransactionId + requestHeaders.put(apiHeaderKeySessionId, extraParameters.get(BIZ_TRANSACTION_ID).toString()); + return requestHeaders; + } + + /** + * Adds the param for shoulder tap txn. + * + * @param dataNode the data node + * @param wakeUpStatus the wake up status + * @param extraParameters the extra parameters + * @return true, if successful + */ + private boolean addParamForShoulderTapTxn(JsonNode dataNode, boolean wakeUpStatus, + Map extraParameters) { + if (dataNode != null) { + JsonNode transIdNode = dataNode.findValue(apiResponseValueTransid); + + if (transIdNode != null) { + wakeUpStatus = true; + String transId = transIdNode.asText(); + extraParameters.put(shoulderTapSmsTransactionId, transId); + } + } + return wakeUpStatus; + } + + /** + * Get SMS transaction status based on the transactionId. + * + * @param requestId requestId + * @param vehicleId vehicleId + * @param transactionId transactionId + * @param extraParameters extraParameters + * @return the SMS transaction status + */ + private SmsTransactionStatus getSMSTransactionStatus(String requestId, String vehicleId, String transactionId, + Map extraParameters) { + logger.info("Calling WAM SMS Transaction Status endpoint: requestId={} vehicleId={} transactionId={} " + + "extraParameters={}", requestId, vehicleId, transactionId, extraParameters); + + SmsTransactionStatus transactionStatus = null; + try { + String transStatusUrl = wamTransactionStatusUrl + + (wamTransactionStatusUrl.endsWith("/") ? "" : "/") + transactionId; + + Map requestHeaders = new HashMap<>(); + // ClientRequestId -> requestId + requestHeaders.put(apiHeaderKeyClientRequestId, requestId); + // SessionId -> bizTransactionId + requestHeaders.put(apiHeaderKeySessionId, extraParameters.get(DMAConstants.BIZ_TRANSACTION_ID).toString()); + + Map requestBody = new HashMap<>(); + // Invoke SMS Transaction Status request + Map responseData = httpClient.invokeJsonResource( + HttpClient.HttpReqMethod.GET, transStatusUrl, requestHeaders, + requestBody, wamAPIMaxRetryCount, wamAPIMaxRetryIntervalMs); + + String responseCode = (String) responseData.get(HttpClient.RESPONSE_CODE); + JsonNode responseJson = (JsonNode) responseData.get(HttpClient.RESPONSE_JSON); + if (transStatusApiHttpRespCode.equals(responseCode) && responseJson != null) { + logger.info("Received WAM Transaction Status endpoint response: transStatusUrl={} requestHeaders={} " + + "requestBody={} responseJson={}", transStatusUrl, requestHeaders, requestBody, responseJson); + + String responseMsg = responseJson.findValue(apiResponseMessageKey).asText(); + + if (apiResponseMessageValueSuccess.equalsIgnoreCase(responseMsg)) { + JsonNode dataNode = responseJson.findPath(apiResponseDataKey); + if (dataNode != null) { + JsonNode statusNode = dataNode.findValue(apiResponseStatusKey); + + if (statusNode != null) { + String status = statusNode.asText(); + transactionStatus = SmsTransactionStatus.valueOf(status); + } + } + } + + + logger.debug( + "WAM SMS Transaction Status endpoint called: transStatusUrl={} requestId={} vehicleId={} " + + "extraParameters={} responseMsg={} transactionId={} transactionStatus={}", transStatusUrl, + requestId, vehicleId, extraParameters, responseMsg, transactionId, transactionStatus); + } else { + transactionStatus = SmsTransactionStatus.ERROR; + logger.error("WAM Transaction Status request has failed: transStatusUrl={} requestId={} vehicleId={} " + + "extraParameters={} transactionStatus={}", transStatusUrl, requestId, + vehicleId, extraParameters, transactionStatus); + } + + } catch (Exception e) { + transactionStatus = SmsTransactionStatus.ERROR; + logger.error( + "ShoulderTapInvokerWAMImpl has encountered an error while retrieving SMS transaction status: " + + "requestId={} vehicleId={} transactionId={} extraParameters={} error={}", requestId, vehicleId, + transactionId, extraParameters, e.getMessage()); + } + return transactionStatus; + } +} diff --git a/src/main/resources/application-base.properties b/src/main/resources/application-base.properties new file mode 100644 index 0000000..f844157 --- /dev/null +++ b/src/main/resources/application-base.properties @@ -0,0 +1,335 @@ +# +# /* +# +# ****************************************************************************** +# +# Copyright (c) 2023-24 Harman International +# +# +# +# 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. +# +# +# +# SPDX-License-Identifier: Apache-2.0 +# +# ******************************************************************************* +# +# */ +# + +pre.processors=org.eclipse.ecsp.analytics.stream.base.processors.TaskContextInitializer,org.eclipse.ecsp.analytics.stream.base.processors.ProtocolTranslatorPreProcessor,org.eclipse.ecsp.analytics.stream.base.processors.MsgSeqPreProcessor,org.eclipse.ecsp.analytics.stream.base.processors.DeviceMessagingAgentPreProcessor +#the sinker node will always be the last processor in the chain +#Forexample: post.processors=org.eclipse.ecsp.analytics.stream.base.processors.DeviceMessagingAgent,org.eclipse.ecsp.analytics.stream.base.processors.ProtocolTranslatorPostProcessor +#In the above the last processor is ProtocolTranslatorPostProcessor, process will be the sinker for all of the sink topics +post.processors=org.eclipse.ecsp.analytics.stream.base.processors.SchedulerAgentPostProcessor,org.eclipse.ecsp.analytics.stream.base.processors.DeviceMessagingAgentPostProcessor,org.eclipse.ecsp.analytics.stream.base.processors.ProtocolTranslatorPostProcessor +launcher.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.KafkaStreamsLauncher +#How the processors will be discovered. (SPIDiscovery, Property based discovery etc) +discovery.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl +source.topic.name=raw-events +shutdown.hook.wait.ms=60000 +log.counts=false +application.id=sample +kafka.ssl.enable=false +kafka.one.way.tls.enable=false +kafka.ssl.endpoint.identification.algorithm= +exec.shutdown.hook=true +#Replication factor for change log +replication.factor=2 +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=admin + +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp,org.eclipse.ecsp.analytics +mongodb.server.selection.timeout=30000 +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region + +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer + +mqtt.broker.url=tcp://127.0.0.1:1883 +mqtt.topic.separator=/ +mqtt.config.qos=0 +mqtt.user.name = 8146ccc47e84ac1e43de623403133d55 +mqtt.user.password = simulator16 +mqtt.topic.to.device.infix=/2d +mqtt.service.topic.name=test +mqtt.conn.retry.count=3 +mqtt.conn.retry.interval=1000 +mqtt.timeout.in.millis=60000 +mqtt.max.inflight=1000 +mqtt.global.broadcast.retention.topics= +#Should be true, if we want to enable computation of transactional latency in hivemq +wrap.dispatch.event=false +event.wrap.frequency=10 + +messageid.generator.type=org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator +sequence.block.config.maxvalue=10000 +event.transformer.classes=genericIgniteEventTransformer +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +dma.event.header.updation.type=messageId +dma.service.retry.interval.divisor=10 +dma.service.max.retry=3 +dma.service.retry.interval.millis=60000 +dma.service.retry.min.threshold.millis=1000 +shoulder.tap.retry.interval.divisor=10 +shoulder.tap.max.retry=3 +shoulder.tap.retry.interval.millis=60000 +shoulder.tap.retry.min.threshold.millis=1000 +dma.auto.offset.reset=latest +#Shoulder tap invoker implementation class. +#Possible values: +#1) Default, Dummy (no invocation) Impl - org.eclipse.ecsp.stream.dma.shouldertap.DummyShoulderTapInvokerImpl +#2) WAM API implementation - org.eclipse.ecsp.stream.dma.shouldertap.ShoulderTapInvokerWAMImpl +#3) Vehicle Notification service - org.eclipse.ecsp.stream.dma.shouldertap.ShoulderTapInvokerVehicleNotificationImpl +dma.shoulder.tap.invoker.impl.class=org.eclipse.ecsp.stream.dma.shouldertap.DummyShoulderTapInvokerImpl +# Shoulder tap WAM API Send SMS endpoint +dma.shoulder.tap.invoker.wam.send.sms.url=https://com.dummy.test.com/v1.0/m2m/sms/send/ +# Shoulder tap WAM API SMS Transaction Status endpoint +dma.shoulder.tap.invoker.wam.sms.transaction.status.url=https://com.dummy.test.com/v1.0/m2m/sim/transaction/ +# Shoulder tap WAM API SMS priority. Applicable values: HIGH, LOW. Default is HIGH. +dma.shoulder.tap.wam.sms.priority=HIGH +# Shoulder tap WAM API SMS validity hour. Value in hours: default is 72 hours. +dma.shoulder.tap.wam.sms.validity.hours=72 +# Shoulder tap WAM API SMS call: flag to skip the status check of any previous send SMS call before invoking again. +dma.shoulder.tap.wam.send.sms.skip.status.check=true +# Shoulder tap WAM API: max. retry count and interval to invoke send SMS/transaction status until a response. +dma.shoulder.tap.wam.api.max.retry.count=3 +dma.shoulder.tap.wam.api.max.retry.interval.ms=30000 +dma.shoulder.tap.enabled=false +dma.ttl.expiry.notification.enabled=false +# Default implementation of EventConfigProvider interface +dma.event.config.provider.class=org.eclipse.ecsp.stream.dma.config.DefaultEventConfigProvider + +# Default implementation of DMAConfigResolver interface in stream-base. +dma.config.resolver.class=org.eclipse.ecsp.stream.dma.config.DefaultDMAConfigResolver + +#Default implementation for DMA post dispatch handler +dma.post.dispatch.handler.class=org.eclipse.ecsp.stream.dma.handler.DefaultPostDispatchHandler +#Default implementation to fetch connection status from API. +dma.connection.status.retriever.impl=org.eclipse.ecsp.analytics.stream.base.utils.DefaultDeviceConnectionStatusRetriever + +# Configure whether event needs to be deleted from MongoDB after TTL expiration +dma.remove.on.ttl.expiry.enabled=true + +offline.buffer.per.device=false + +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=MASTER +redis.subscription.mode=MASTER +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=128 +redis.idle.conn.timeout=600000 +redis.conn.timeout=20000 +redis.timeout=10000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=20000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=128 +redis.cluster.masters= +redis.scan.interval=10000 +redis.netty.threads=32 +redis.decode.in.executor=true +redis.executor.threads=32 +redis.keep.alive=true +redis.ping.connection.interval=60000 +redis.tcp.no.delay=true +redis.transport.mode=NIO +redis.check.slots.coverage=false + +#IgniteCache Lua script scan limit +redis.scan.limit=10000 +#IgniteCache Lua script for Regex scan +redis.regex.scan.filename=scanregex.txt +#IgniteCache Async operation batch pipeline size +redis.pipeline.size=2 + +#VehicleId to DeviceId mapping impl class +device.to.vehicle.mapper.impl=org.eclipse.ecsp.analytics.stream.d2v.VehicleToDeviceSingleIdentityMapper +#Vehicle Profile Service URL +http.vp.url=http://internal-andromeda-vehicle-profile-1184319113.us-east-1.elb.amazonaws.com/v1.0/vehicleProfiles/ +http.vp.vin.url=http://vehicle-profile-api-int-svc:8080/v1.0/vehicles?clientId= +// Http client properties +http.connection.timeout.in.sec=120 +http.read.timeout.in.sec=10 +http.write.timeout.in.sec=10 +http.keep.alive.duration.in.sec=120 +http.max.idle.connections=20 +http.vp.auth.header=Authentication +http.vp.service.user=test +http.vp.service.password=pass +// Vehicle profile service URL +http.vp.retry.interval.in.millis=5000 +http.vp.max.retry.count=3 + +#Scheduler Agent Stream Processor +scheduler.agent.topic.name=scheduler +start.device.status.consumer=true +convert.backdoor.kafka.topic.tolowercase=true + +#thread status +print.threads.metadata.enabled=true +print.threads.metadata.interval.ms=30000 +stream.threads.active.states=CREATED,STARTING,PARTITIONS_REVOKED,PARTITIONS_ASSIGNED,RUNNING,PENDING_SHUTDOWN +stream.threads.dead.states=DEAD +#Message Sequencing +msg.seq.topic.name=test +msg.seq.time.interval.in.millis=0 +msg.seq.state.store.changelog.enabled=true + +msg.seq.buffer.impl.class=org.eclipse.ecsp.analytics.stream.base.SequenceBufferTreeMapImpl + +#Prometheus Configuration +prometheus.agent.port=9100 +metrics.prometheus.enabled=true +prometheus.histogram.buckets=0.005, 0.010, 0.015, 0.020, 0.025, 0.030, 0.080, 0.1, 0.2, 0.3 +prometheus.data.consumption.metric.buckets=1,5,20,50,100,250,500,1000,1500,3000 +prometheus.data.consumption.metric.enabled=false + +#rocksdb metrics configuration +rocksdb.metrics.enabled=false +rocksdb.metrics.list=size-all-mem-tables,block-cache-usage,block-cache-pinned-usage,estimate-table-readers-mem,total-sst-files-size,num-running-compactions +rocksdb.metrics.thread.initial.delay.ms=2000 +rocksdb.metrics.thread.frequency.ms=180000 +metrics.recording.level=DEBUG + +backdoor.kafka.max.poll.interval.ms=600000 +backdoor.kafka.request.timeout.ms=605000 +backdoor.kafka.session.timeout.ms=250000 +backdoor.kafka.max.restart.attempts=5 +backdoor.kafka.restart.reset.interval.min=30 +backdoor.kafka.offset.persistence.delay=60000 +backdoor.kafka.enable.auto.commit=false +backdoor.kafka.consumer.default.api.timeout.ms=606000 +kafka.streams.offset.persistence.delay=60000 +kafka.streams.offset.persistence.init.delay=10000 +kafka.streams.offset.persistence.enabled=false +kafka.max.request.size=1000012 +kafka.acks.config=1 +kafka.buffer.memory.config=33554432 +kafka.compression.type.config=none +kafka.retries.config=2147483647 +kafka.batch.size.config=16384 +kafka.delivery.timeout.ms.config=120000 +kafka.linger.ms.config=0 +kafka.request.timeout.ms.config=30000 +kafka.headers.enabled=false +connections.max.idle.ms=-1 +dma.kafka.consumer.poll=50 + +#RTC 334625 Configuration for maxFailures and maxTimeInterval to be used to recover the thread +kafka.streams.max.failures=10 +kafka.streams.max.time.millis=3600000 + +#LRUCacheMessageGenerator configuration +lru.map.threshold=100000 +message.generation.retry.counter=5 +message.generation.retry.interval=100 + +## Flag to enable usage of parameterized constructor for transformers used to accept the properties passed to the application. +## Applicable as the instance creation is via runtime classloader and not spring based. +transformer.inject.property.enable=false + +## Reprocessing in DLQ +dlq.max.retry.count=5 +dlq.reprocessing.enabled=false + +#FilterDMOfflineBufferEntry Impl class +filter.dmoffline.buffer.entry.impl=org.eclipse.ecsp.stream.dma.handler.NoFilterDMOfflineBufferEntryImpl + +#Health framework properties +health.mqtt.monitor.enabled=true +health.mqtt.monitor.restart.on.failure=true +health.mongo.monitor.enabled=true +health.mongo.needs.restart.on.failure=true +health.kafka.consumer.group.monitor.enabled=true +health.kafka.consumer.group.needs.restart.on.failure=false +health.device.status.backdoor.monitor.enabled=true +health.device.status.backdoor.monitor.restart.on.failure=false +health.kafka.topics.monitor.enabled=true +health.kafka.topics.monitor.needs.restart.on.failure=true +health.redis.monitor.enabled=false +health.redis.needs.restart.on.failure=true +ignore.bootstrap.failure.monitors=KAFKA_CONSUMER_GROUP_HEALTH_MONITOR,DEVICE_STATUS_BACKDOOR_HEALTH_MONITOR +sp.restart.on.failure=false +sp.restart.wait.time.in.millis=10000 +kafka.topics.file.path=/data/topics.txt +expected.min.isr=1 +health.service.failure.retry.thrshold=20 +health.service.failure.retry.interval.millis=500 +health.service.retry.interval.millis=100 +health.service.executor.shutdown.millis=2000 +health.service.executor.initial.delay=120000 +#Diagnostic framework properties +mongo.diagnostic.reporter.enabled=false +property.diagnostic.reporter.enabled=true +#CacheBypass's properties +cache.bypass.queue.initial.capacity=10000 +cache.bypass.threads.shutdown.wait.time=2000 +dma.num.cache.bypass.threads=1 + +############################################################################### +#SSL Configuration +key.cert.passwd=***** +trust.cert.passwd=***** + +#enabling streaming of event size to kafka for analytics dashboard RTC-301848 +kafka.data.consumption.metrics=false +data.consumption.metrics.kafka.topic=data-usage + +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=true +scheduler.enabled=true + +#retryRecordIdPatter for custom codec class in DMF +retry.record.id.pattern=recordIds\\\"\\:\\[ + +#Whether to publish internal cache metrics to prometheus or not. +internal.metrics.enabled=false + +#Name of the class which is implementing IgnitePlatform interface to provide platformID +ignite.platform.service.impl.class.name= +mqtt.topic.name.generator.impl.class.name=org.eclipse.ecsp.analytics.stream.base.utils.DefaultMqttTopicNameGeneratorImpl + +max.decompress.input.stream.size.in.bytes=1000000000 diff --git a/src/main/resources/sample_sequence_files.txt b/src/main/resources/sample_sequence_files.txt new file mode 100644 index 0000000..50d77ed --- /dev/null +++ b/src/main/resources/sample_sequence_files.txt @@ -0,0 +1,11 @@ +HUDBUJ58BM5944,haa-raw-local-dev/logs/v3/2016/09/07/0900/hash0005/2016-09-07-09-30-41-599-HUDBUJ58BM5944.seq +HUWCZDKDAV6411,haa-raw-local-dev/logs/v3/2016/09/07/0900/hash0005/2016-09-07-09-30-59-139-HUWCZDKDAV6411.seq +HUCSR8AQ5Y6361,haa-raw-local-dev/logs/v3/2016/09/07/0900/hash0005/2016-09-07-09-31-42-646-HUCSR8AQ5Y6361.seq +HUYHMMZG5X6244,haa-raw-local-dev/logs/v3/2016/09/07/0900/hash0005/2016-09-07-09-32-57-021-HUYHMMZG5X6244.seq +HUWMPMNZUZ6413,haa-raw-local-dev/logs/v3/2016/09/07/0900/hash0005/2016-09-07-09-35-11-968-HUWMPMNZUZ6413.seq +HUUYGXHGT76414,haa-raw-local-dev/logs/v3/2016/09/07/0900/hash0005/2016-09-07-09-35-12-437-HUUYGXHGT76414.seq +HU4YKK4QFJ6412,haa-raw-local-dev/logs/v3/2016/09/07/0900/hash0005/2016-09-07-09-35-19-122-HU4YKK4QFJ6412.seq +HUEL3FTSAXI626,haa-raw-local-dev/logs/v3/2016/09/07/0900/hash0005/2016-09-07-09-35-34-292-HUEL3FTSAXI626.seq +HUFT1MPWJ55552,haa-raw-local-dev/logs/v3/2016/09/07/0900/hash0005/2016-09-07-09-35-40-966-HUFT1MPWJ55552.seq +HUUYGXHGT76414,haa-raw-local-dev/logs/v3/2016/09/07/0900/hash0005/2016-09-07-09-35-46-340-HUUYGXHGT76414.seq +HUCSR8AQ5Y6361,haa-raw-local-dev/logs/v3/2016/09/07/0900/hash0005/2016-09-07-09-35-49-387-HUCSR8AQ5Y6361.seq \ No newline at end of file diff --git a/src/test/java/org/apache/kafka/test/TestUtils.java b/src/test/java/org/apache/kafka/test/TestUtils.java new file mode 100644 index 0000000..f4c445d --- /dev/null +++ b/src/test/java/org/apache/kafka/test/TestUtils.java @@ -0,0 +1,248 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.apache.kafka.test; + +import io.moquette.broker.Server; +import org.apache.kafka.common.Cluster; +import org.apache.kafka.common.Node; +import org.apache.kafka.common.PartitionInfo; +import org.apache.kafka.common.utils.Utils; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Random; + +import static java.util.Arrays.asList; + +/** + * Helper functions for writing unit tests. + */ +public class TestUtils { + + private TestUtils() { + throw new UnsupportedOperationException("This utility class does not support the object creation"); + } + + public static final File IO_TMP_DIR = new File(System.getProperty("java.io.tmpdir")); + + private static final Logger LOGGER = LoggerFactory.getLogger(TestUtils.class); + public static final String LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + public static final String DIGITS = "0123456789"; + public static final String LETTERS_AND_DIGITS = LETTERS + DIGITS; + /* A consistent random number generator to make tests repeatable */ + public static final Random SEEDED_RANDOM = new Random(192348092834L); + public static final Random RANDOM = new Random(); + private static Server embedMqttServer; + + public static Cluster singletonCluster(Map topicPartitionCounts) { + return clusterWith(1, topicPartitionCounts); + } + + public static Cluster singletonCluster(String topic, int partitions) { + return clusterWith(1, topic, partitions); + } + + /** + * Utility method to fetch the Cluster details. + * + * @param nodes nodes + * @param topicPartitionCounts count of partition + * @return org.apache.kafka.common.Cluster + **/ + public static Cluster clusterWith(int nodes, Map topicPartitionCounts) { + Node[] ns = new Node[nodes]; + for (int i = 0; i < nodes; i++) { + ns[i] = new Node(i, "localhost", TestConstants.PORT_1969); + } + List parts = new ArrayList<>(); + for (Map.Entry topicPartition : topicPartitionCounts.entrySet()) { + String topic = topicPartition.getKey(); + int partitions = topicPartition.getValue(); + for (int i = 0; i < partitions; i++) { + parts.add(new PartitionInfo(topic, i, ns[i % ns.length], ns, ns)); + } + } + return new Cluster("testClusterId", asList(ns), parts, + Collections.emptySet(), Collections.emptySet()); + + } + + public static Cluster clusterWith(int nodes, String topic, int partitions) { + return clusterWith(nodes, Collections.singletonMap(topic, partitions)); + } + + /** + * Generate an array of random bytes. + * + * @param size + * The size of the array + */ + public static byte[] randomBytes(int size) { + byte[] bytes = new byte[size]; + SEEDED_RANDOM.nextBytes(bytes); + return bytes; + } + + /** + * Generate a random string of letters and digits of the given length. + * + * @param len + * The length of the string + * @return The random string + */ + public static String randomString(int len) { + StringBuilder b = new StringBuilder(); + for (int i = 0; i < len; i++) { + b.append(LETTERS_AND_DIGITS.charAt(SEEDED_RANDOM.nextInt(LETTERS_AND_DIGITS.length()))); + } + return b.toString(); + } + + /** + * Create an empty file in the default temporary-file directory, using + * `kafka` as the prefix and `tmp` as the suffix to generate its name. + */ + public static File tempFile() throws IOException { + File file = File.createTempFile("kafka", ".tmp"); + file.deleteOnExit(); + + return file; + } + + /** + * Create a temporary relative directory in the default temporary-file + * directory with the given prefix. + * + * @param prefix + * The prefix of the temporary directory, if null using "kafka-" + * as default prefix + */ + public static File tempDirectory(String prefix) throws IOException { + return tempDirectory(null, prefix); + } + + /** + * Create a temporary relative directory in the specified parent directory + * with the given prefix. + * + * @param parent + * The parent folder path name, if null using the default + * temporary-file directory + * @param prefix + * The prefix of the temporary directory, if null using "kafka-" + * as default prefix + */ + public static File tempDirectory(Path parent, String prefix) throws IOException { + final File file = parent == null ? Files.createTempDirectory(prefix == null ? "kafka-" : prefix).toFile() + : Files.createTempDirectory(parent, prefix == null ? "kafka-" : prefix).toFile(); + file.deleteOnExit(); + + Runtime.getRuntime().addShutdownHook(new Thread() { + @Override + public void run() { + try { + Utils.delete(file); + } catch (IOException e) { + LOGGER.error("Error while deleting the file - " + file.getAbsolutePath()); + } + } + }); + + return file; + } + + /** + * Method to start the mqtt server. + * + * @throws IOException when server is unreachable + */ + public static void startMqttServer() throws IOException { + if (null != embedMqttServer) { + embedMqttServer.stopServer(); + } + embedMqttServer = new Server(); + Properties configProps = new Properties(); + readProperties(configProps); + embedMqttServer.startServer(configProps); + + } + + /** + * Reading the moquette.conf properties file for configuring embedded mqtt + * server. + * moquette.conf property file will be read from src/test/resources source + * folder + * + * @param props properties + * @throws IOException when properties couldn't be read + */ + private static void readProperties(Properties props) throws IOException { + String mqttPropertiesFile = "moquette.conf"; + InputStream inputStream = TestUtils.class.getClassLoader().getResourceAsStream(mqttPropertiesFile); + if (null != inputStream) { + props.load(inputStream); + } else { + throw new FileNotFoundException("mqtt Property file : " + mqttPropertiesFile + "not found"); + } + } + + /** + * Method to stop the mqtt server. + */ + public static void stopMqttServer() { + if (null != embedMqttServer) { + embedMqttServer.stopServer(); + } + + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/CacheMapStateStoreTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/CacheMapStateStoreTest.java new file mode 100644 index 0000000..068e84a --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/CacheMapStateStoreTest.java @@ -0,0 +1,415 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.eclipse.ecsp.analytics.stream.base.stores.CacheBypass; +import org.eclipse.ecsp.analytics.stream.base.stores.CacheEntity; +import org.eclipse.ecsp.analytics.stream.base.stores.CacheKeyConverter; +import org.eclipse.ecsp.analytics.stream.base.stores.CachedMapStateStore; +import org.eclipse.ecsp.cache.DeleteMapOfEntitiesRequest; +import org.eclipse.ecsp.cache.GetMapOfEntitiesRequest; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.cache.PutEntityRequest; +import org.eclipse.ecsp.cache.PutMapOfEntitiesRequest; +import org.eclipse.ecsp.entities.IgniteEntity; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.utils.metrics.InternalCacheGuage; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Test Class for {@link CachedMapStateStore}. + */ +public class CacheMapStateStoreTest { + + private String key = "abc"; + private StringKey stringKey; + private IgniteEventImpl igniteEvent; + + private String id = "test_id"; + + @InjectMocks + private CachedMapStateStore store; + + @Mock + private IgniteCache cache; + + @Mock + private CacheBypass bypass; + + @Mock + private InternalCacheGuage cacheGuage; + + /** + * setup method is for setting up igniteEvent just after the class initialization. + */ + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + //cache = Mockito.mock(IgniteCacheRedisImpl.class); + + igniteEvent = new IgniteEventImpl(); + igniteEvent.setEventId("test"); + stringKey = new StringKey(key); + store.setTaskId(id); + } + + @After + public void close() { + store.close(); + + } + + @Test + public void testSetTaskId() { + store.setTaskId(null); + store.setTaskId(id); + String taskId = (String) ReflectionTestUtils.getField(store, "taskId"); + Assert.assertEquals(id, taskId); + } + + @Test + public void testPutIfPersistInIgniteCache() { + store.setPersistInIgniteCache(true); + store.setCache(cache); + store.setBypass(bypass); + store.put(stringKey, igniteEvent, Optional.empty(), "dummy_cache"); + Mockito.verify(bypass, Mockito.times(1)).processEvents(Mockito.any(CacheEntity.class)); + } + + @Test + public void testDeleteIfPersistInIgniteCache() { + store.setPersistInIgniteCache(true); + store.setCache(cache); + store.setBypass(bypass); + store.delete(stringKey, Optional.empty(), "dummy_cache"); + Mockito.verify(bypass, Mockito.times(1)).processEvents(Mockito.any(CacheEntity.class)); + } + + @Test + public void testSyncWithRedis() { + Map map = new HashMap(); + map.put(key, igniteEvent); + Mockito.when(cache.getKeyValuePairsForRegex("regex", Optional.of(false))).thenReturn(map); + store.setCache(cache); + store.syncWithcache("regex", new StringKey()); + Assert.assertEquals(igniteEvent, store.get(stringKey)); + + } + + @Test + public void testPutWithoutMutationId() { + store.put(stringKey, igniteEvent, "dummy_cache"); + Assert.assertEquals(igniteEvent, store.get(stringKey)); + } + + @Test + public void testPutToCache() { + store.setPersistInIgniteCache(false); + store.put(stringKey, igniteEvent); + Assert.assertEquals(igniteEvent, store.get(stringKey)); + Mockito.verify(cache, Mockito.atMost(0)).putEntity(Mockito.any(PutEntityRequest.class)); + } + + @Test + public void testPutWithMutationId() { + store.put(stringKey, igniteEvent, Optional.empty(), "dummy_cache"); + Assert.assertEquals(igniteEvent, store.get(stringKey)); + } + + @Test + public void testDeleteWithoutMutationId() { + store.put(stringKey, igniteEvent); + store.delete(stringKey, "dummy_cache"); + Assert.assertNull(store.get(stringKey)); + } + + @Test + public void testDeleteWithMutationId() { + store.put(stringKey, igniteEvent, Optional.empty(), "dummy_cache"); + store.delete(stringKey); + Assert.assertNull(store.get(stringKey)); + } + + @Test + public void testPutToMap() { + store.setPersistInIgniteCache(true); + store.putToMap("prefix", stringKey, igniteEvent, Optional.empty(), "dummy_cache"); + store.setBypass(bypass); + Assert.assertEquals(igniteEvent, store.get(stringKey)); + Mockito.verify(bypass, Mockito.times(1)).processEvents(Mockito.any(CacheEntity.class)); + } + + @Test + public void testPutToMapIfPersistanceFalse() { + store.setPersistInIgniteCache(false); + store.putToMap("prefix", stringKey, igniteEvent, Optional.empty(), "dummy_cache"); + Mockito.verify(cache, Mockito.atMost(0)).putMapOfEntities(Mockito.any(PutMapOfEntitiesRequest.class)); + Assert.assertEquals(igniteEvent, store.get(stringKey)); + } + + @Test + public void testPutToMapIfAbsent() { + store.setPersistInIgniteCache(true); + store.putToMapIfAbsent("prefix", stringKey, igniteEvent, Optional.empty(), "dummy_cache"); + Mockito.verify(bypass, Mockito.times(1)).processEvents(Mockito.any(CacheEntity.class)); + Assert.assertEquals(igniteEvent, store.get(stringKey)); + } + + @Test + public void testPutToMapIfAbsentPersistanceFalse() { + store.setPersistInIgniteCache(false); + store.putToMapIfAbsent("prefix", stringKey, igniteEvent, Optional.empty(), "dummy_cache"); + Mockito.verify(cache, Mockito.atMost(0)).putMapOfEntities(Mockito.any(PutMapOfEntitiesRequest.class)); + Assert.assertEquals(igniteEvent, store.get(stringKey)); + } + + @Test + public void testPutToMapIfAbsentValuePresent() { + CachedMapStateStore storeMock = Mockito.mock(CachedMapStateStore.class); + IgniteCache cacheMock = Mockito.mock(IgniteCache.class); + storeMock.setPersistInIgniteCache(true); + Mockito.when(storeMock.putIfAbsent(stringKey, igniteEvent)).thenReturn(igniteEvent); + storeMock.putToMapIfAbsent("prefix", stringKey, igniteEvent, Optional.empty(), "dummy_cache"); + Mockito.verify(bypass, Mockito.atMost(0)).processEvents(Mockito.any(CacheEntity.class)); + } + + @Test + public void testDeleteFromMap() { + store.setPersistInIgniteCache(true); + store.deleteFromMap("prefix", stringKey, Optional.empty(), "dummy_cache"); + Mockito.verify(bypass, Mockito.times(1)).processEvents(Mockito.any(CacheEntity.class)); + } + + @Test + public void testDeleteFromMapIfPersistenceFalse() { + store.setPersistInIgniteCache(false); + store.deleteFromMap("prefix", stringKey, Optional.empty(), "dummy_cache"); + Mockito.verify(cache, Mockito.atMost(0)).deleteMapOfEntities(Mockito.any(DeleteMapOfEntitiesRequest.class)); + } + + @Test + public void testSyncWithMapCache() { + Map pairs = new HashMap(); + pairs.put(stringKey.convertToString(), igniteEvent); + Mockito.when(cache.getMapOfEntities(Mockito.any(GetMapOfEntitiesRequest.class))).thenReturn(pairs); + store.syncWithMapCache("prefix", stringKey, "dummy_cache"); + + Mockito.verify(cache, Mockito.atMost(1)).getMapOfEntities(Mockito.any(GetMapOfEntitiesRequest.class)); + ArgumentCaptor argument = ArgumentCaptor.forClass(GetMapOfEntitiesRequest.class); + Mockito.verify(cache).getMapOfEntities(argument.capture()); + GetMapOfEntitiesRequest req = argument.getValue(); + Assert.assertEquals("prefix", req.getKey()); + IgniteEventImpl actual = store.get(stringKey); + Assert.assertNotNull(actual); + Assert.assertEquals(igniteEvent.getEventId(), actual.getEventId()); + } + + @Test + public void testSyncWithMapCacheInCaseOfSubServices() { + ReflectionTestUtils.setField(store, "subServices", "fleet"); + Map pairs = new HashMap(); + pairs.put(stringKey.convertToString(), igniteEvent); + + Mockito.when(cache.getMapOfEntities(Mockito.any(GetMapOfEntitiesRequest.class))).thenReturn(pairs); + store.syncWithMapCache("VEHICLE_DEVICE_MAPPING:abc:fleet", stringKey, "dummy_cache"); + + Mockito.verify(cache, Mockito.atMost(1)).getMapOfEntities(Mockito.any(GetMapOfEntitiesRequest.class)); + ArgumentCaptor argument = ArgumentCaptor.forClass(GetMapOfEntitiesRequest.class); + Mockito.verify(cache).getMapOfEntities(argument.capture()); + GetMapOfEntitiesRequest req = argument.getValue(); + Assert.assertEquals("VEHICLE_DEVICE_MAPPING:abc:fleet", req.getKey()); + StringKey actualKey = new StringKey(key + ";fleet"); + IgniteEventImpl actual = store.get(actualKey); + Assert.assertNotNull(actual); + Assert.assertEquals(igniteEvent.getEventId(), actual.getEventId()); + } + + @Test + public void testSyncWithMapCacheWithoutSubServices() { + Map pairs = new HashMap(); + pairs.put(stringKey.convertToString(), igniteEvent); + + Mockito.when(cache.getMapOfEntities(Mockito.any(GetMapOfEntitiesRequest.class))).thenReturn(pairs); + store.syncWithMapCache("VEHICLE_DEVICE_MAPPING:abc", stringKey, "dummy_cache"); + + Mockito.verify(cache, Mockito.atMost(1)).getMapOfEntities(Mockito.any(GetMapOfEntitiesRequest.class)); + ArgumentCaptor argument = ArgumentCaptor.forClass(GetMapOfEntitiesRequest.class); + Mockito.verify(cache).getMapOfEntities(argument.capture()); + GetMapOfEntitiesRequest req = argument.getValue(); + Assert.assertEquals("VEHICLE_DEVICE_MAPPING:abc", req.getKey()); + IgniteEventImpl actual = store.get(stringKey); + Assert.assertNotNull(actual); + Assert.assertEquals(igniteEvent.getEventId(), actual.getEventId()); + } + + @Test + public void testForceGet() { + Mockito.when(cache.getEntity(Mockito.any(GetMapOfEntitiesRequest.class))).thenReturn(igniteEvent); + store.forceGet("prefix", stringKey); + Mockito.verify(cache, Mockito.atMost(1)).getEntity(Mockito.any(GetMapOfEntitiesRequest.class)); + ArgumentCaptor argument = ArgumentCaptor.forClass(GetMapOfEntitiesRequest.class); + Mockito.verify(cache).getMapOfEntities(argument.capture()); + GetMapOfEntitiesRequest req = argument.getValue(); + Assert.assertEquals("prefix", req.getKey()); + Assert.assertEquals(1, req.getFields().size()); + Assert.assertTrue(req.getFields().contains(key)); + } + + @Test + public void testdeleteFromMap() { + Map pairs = new HashMap(); + pairs.put(stringKey.convertToString(), igniteEvent); + Mockito.when(cache.getMapOfEntities(Mockito.any(GetMapOfEntitiesRequest.class))).thenReturn(pairs); + store.deleteFromMap("prefix", stringKey, Optional.empty(), "dummy_cache"); + ArgumentCaptor argument = ArgumentCaptor.forClass(GetMapOfEntitiesRequest.class); + IgniteEventImpl actual = store.get(stringKey); + Assert.assertNull(actual); + } + + @Test + public void testputIfAbsent() { + CachedMapStateStore storeMock = Mockito.mock(CachedMapStateStore.class); + IgniteCache cacheMock = Mockito.mock(IgniteCache.class); + storeMock.putIfAbsent(null, null); + Mockito.when(storeMock.putIfAbsent(stringKey, igniteEvent)).thenReturn(igniteEvent); + storeMock.putToMapIfAbsent("prefix", stringKey, igniteEvent, Optional.empty(), "dummy_cache"); + Mockito.verify(bypass, Mockito.atMost(0)).processEvents(Mockito.any(CacheEntity.class)); + } + + @Test + public void testPutIfAbsentIfPersistanceEnabled() { + store.setPersistInIgniteCache(true); + store.putIfAbsent(stringKey, igniteEvent, Optional.empty(), "dummy_cache"); + IgniteEventImpl igniteEvent2 = new IgniteEventImpl(); + igniteEvent2.setEventId("test2"); + IgniteEventImpl oldValue = (IgniteEventImpl) store.putIfAbsent(stringKey, + igniteEvent2, Optional.empty(), "dummy_cache"); + + Mockito.verify(bypass, Mockito.times(1)).processEvents(Mockito.any(CacheEntity.class)); + Assert.assertEquals("test", oldValue.getEventId()); + } + + /** + * Inner Class StrinkKey. + *Implements {@link CacheKeyConverter} + */ + public class StringKey implements CacheKeyConverter { + + private String key; + + public StringKey() { + } + + public StringKey(String key) { + this.key = key; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + @Override + public StringKey convertFrom(String key) { + return new StringKey(key); + } + + @Override + public String convertToString() { + return key; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + getOuterType().hashCode(); + result = prime * result + ((key == null) ? 0 : key.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + StringKey other = (StringKey) obj; + if (!getOuterType().equals(other.getOuterType())) { + return false; + } + if (key == null) { + if (other.key != null) { + return false; + } + } else if (!key.equals(other.key)) { + return false; + } + return true; + } + + private CacheMapStateStoreTest getOuterType() { + return CacheMapStateStoreTest.this; + } + + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/CachedSortedMapStateStoreTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/CachedSortedMapStateStoreTest.java new file mode 100644 index 0000000..17ca37c --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/CachedSortedMapStateStoreTest.java @@ -0,0 +1,284 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.OffsetMetadata; +import org.eclipse.ecsp.analytics.stream.base.stores.CacheBypass; +import org.eclipse.ecsp.analytics.stream.base.stores.CacheEntity; +import org.eclipse.ecsp.analytics.stream.base.stores.CachedSortedMapStateStore; +import org.eclipse.ecsp.cache.GetMapOfEntitiesRequest; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.cache.redis.IgniteCacheRedisImpl; +import org.eclipse.ecsp.entities.IgniteEntity; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.key.RetryBucketKey; +import org.eclipse.ecsp.utils.metrics.InternalCacheGuage; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.Assert.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@link CachedSortedMapStateStoreTest}. + */ +public class CachedSortedMapStateStoreTest { + private RetryBucketKey key; + private IgniteEventImpl igniteEvent; + + @InjectMocks + private CachedSortedMapStateStore store; + + @Mock + private IgniteCache cache; + + @Mock + private CacheBypass bypass; + + @Mock + private InternalCacheGuage cacheGuage; + + private String taskId = "test_id"; + + /** + * setup() to setup igniteEvent. + */ + @Before + public void setup() { + key = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_123); + MockitoAnnotations.initMocks(this); + igniteEvent = new IgniteEventImpl(); + igniteEvent.setEventId("test"); + store.setTaskId(taskId); + } + + @Test + public void testSetTaskId() { + store.setTaskId(null); + store.setTaskId(taskId); + String taskId = (String) ReflectionTestUtils.getField(store, "taskId"); + Assert.assertEquals(this.taskId, taskId); + } + + @Test + public void testSyncWithRedis() { + String prefix = DMAConstants.RETRY_BUCKET + "servicename:taskId:"; + String regex = prefix + "*"; + Map map = new HashMap(); + map.put("123", igniteEvent); + IgniteCache cache = Mockito.mock(IgniteCacheRedisImpl.class); + Mockito.when(cache.getKeyValuePairsForRegex(regex, Optional.of(false))).thenReturn(map); + store.setCache(cache); + store.syncWithcache(regex, new RetryBucketKey()); + Assert.assertEquals(igniteEvent, store.get(key)); + + } + + @Test + public void testPutWithoutMutationId() { + store.put(key, igniteEvent, "dummy_cache"); + Assert.assertEquals(igniteEvent, store.get(key)); + } + + @Test + public void testPutWithMutationId() { + OffsetMetadata meta = new OffsetMetadata(null, TestConstants.THREAD_SLEEP_TIME_1000); + store.put(key, igniteEvent, Optional.of(meta), "dummy_cache"); + Assert.assertEquals(igniteEvent, store.get(key)); + Mockito.verify(bypass, Mockito.times(1)).processEvents(Mockito.any(CacheEntity.class)); + } + + @Test + public void testPutIfAbsent() { + Object oldValue = store.putIfAbsent(key, igniteEvent, Optional.empty(), "dummy_cache"); + Assert.assertNull(oldValue); + Mockito.verify(bypass, Mockito.times(1)).processEvents(Mockito.any(CacheEntity.class)); + Assert.assertEquals(igniteEvent, store.get(key)); + } + + @Test + public void testDeleteWithoutMutationId() { + store.put(key, igniteEvent); + store.delete(key, "dummy_cache"); + Assert.assertNull(store.get(key)); + Mockito.verify(bypass, Mockito.times(1)).processEvents(Mockito.any(CacheEntity.class)); + } + + @Test + public void testHeadMap() { + RetryBucketKey key1 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_123); + RetryBucketKey key2 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_223); + RetryBucketKey key3 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_323); + RetryBucketKey key4 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_423); + RetryBucketKey key5 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_523); + + OffsetMetadata meta = new OffsetMetadata(null, TestConstants.THREAD_SLEEP_TIME_1000); + store.put(key5, igniteEvent, Optional.of(meta), "dummy_cache"); + store.put(key2, igniteEvent, Optional.of(meta), "dummy_cache"); + store.put(key3, igniteEvent, Optional.of(meta), "dummy_cache"); + store.put(key4, igniteEvent, Optional.of(meta), "dummy_cache"); + store.put(key1, igniteEvent, Optional.of(meta), "dummy_cache"); + + List expected = new LinkedList(); + expected.add(TestConstants.THREAD_SLEEP_TIME_123); + expected.add(TestConstants.THREAD_SLEEP_TIME_223); + expected.add(TestConstants.THREAD_SLEEP_TIME_323); + RetryBucketKey toKey = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_400); + KeyValueIterator itr = store.getHead(toKey); + List actual = new LinkedList(); + while (itr.hasNext()) { + actual.add(itr.next().key.getTimestamp()); + } + Assert.assertEquals(expected, actual); + + } + + @Test + public void testGet() { + RetryBucketKey key1 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_123); + RetryBucketKey key2 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_123); + + OffsetMetadata meta = new OffsetMetadata(null, TestConstants.THREAD_SLEEP_TIME_1000); + store.put(key1, igniteEvent, Optional.of(meta), "dummy_cache"); + + Assert.assertEquals(igniteEvent, store.get(key2)); + + } + + @Test + public void testDeleteWithMutationId() { + OffsetMetadata meta = new OffsetMetadata(null, TestConstants.THREAD_SLEEP_TIME_1000); + store.put(key, igniteEvent, Optional.of(meta), "dummy_cache"); + store.delete(key); + Assert.assertNull(store.get(key)); + Mockito.verify(bypass, Mockito.times(1)).processEvents(Mockito.any(CacheEntity.class)); + } + + /** + * Testing equality,hashCode and other method to increase code coverage. + */ + @Test + public void testEqualityOfOffsetMetadata() { + OffsetMetadata meta1 = new OffsetMetadata(null, TestConstants.THREAD_SLEEP_TIME_1000); + OffsetMetadata meta2 = new OffsetMetadata(null, TestConstants.THREAD_SLEEP_TIME_1000); + OffsetMetadata meta3 = new OffsetMetadata(new TopicPartition("testTopic1", TestConstants.TWELVE), + TestConstants.THREAD_SLEEP_TIME_1000); + assertNotEquals("Hashcode is not same", meta1.hashCode(), meta3.hashCode()); + assertEquals(meta1.hashCode(), meta2.hashCode()); + Assert.assertEquals(meta1, meta2); + assertEquals(meta1.getPartition(), meta2.getPartition()); + assertEquals(meta1.getOffset(), meta2.getOffset()); + assertNotEquals(null, meta1); + assertNotEquals(meta1, new Object()); + assertNotEquals(meta1, meta3); + assertNotEquals(meta3, meta1); + assertNotEquals(meta1, meta3); + Assert.assertEquals(meta1, meta1); + OffsetMetadata meta4 = new OffsetMetadata(new TopicPartition("testTopic2", TestConstants.THIRTEEN), + TestConstants.THREAD_SLEEP_TIME_100); + OffsetMetadata meta5 = new OffsetMetadata(new TopicPartition("testTopic2", TestConstants.THIRTEEN), + TestConstants.THREAD_SLEEP_TIME_100); + assertNotEquals(meta3, meta4); + Assert.assertEquals(meta4, meta5); + } + + @Test + public void testPutToMap() { + RetryBucketKey key1 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_123); + String prefix = RetryBucketKey.getMapKey("service", "task"); + store.putToMap(prefix, key1, igniteEvent, Optional.empty(), "dummy_cache"); + Assert.assertEquals(igniteEvent, store.get(key1)); + Mockito.verify(bypass, Mockito.times(1)).processEvents(Mockito.any(CacheEntity.class)); + } + + @Test + public void testPutToMapIfAbsent() { + RetryBucketKey key1 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_123); + String prefix = RetryBucketKey.getMapKey("service", "task"); + store.putToMapIfAbsent(prefix, key1, igniteEvent, Optional.empty(), "dummy_cache"); + Assert.assertEquals(igniteEvent, store.get(key1)); + Mockito.verify(bypass, Mockito.times(1)).processEvents(Mockito.any(CacheEntity.class)); + } + + @Test + public void testDeleteFromMap() { + RetryBucketKey key1 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_123); + String prefix = RetryBucketKey.getMapKey("service", "task"); + store.deleteFromMap(prefix, key1, Optional.empty(), "dummy_cache"); + Mockito.verify(bypass, Mockito.times(1)).processEvents(Mockito.any(CacheEntity.class)); + } + + @Test + public void testSyncWithMapCache() { + RetryBucketKey key1 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_123); + String prefix = RetryBucketKey.getMapKey("service", "task"); + Map pairs = new HashMap(); + pairs.put(key1.convertToString(), igniteEvent); + Mockito.when(cache.getMapOfEntities(Mockito.any(GetMapOfEntitiesRequest.class))).thenReturn(pairs); + store.syncWithMapCache(prefix, key1, "dummy_cache"); + + Mockito.verify(cache, Mockito.atMost(1)).getMapOfEntities(Mockito.any(GetMapOfEntitiesRequest.class)); + ArgumentCaptor argument = ArgumentCaptor.forClass(GetMapOfEntitiesRequest.class); + Mockito.verify(cache).getMapOfEntities(argument.capture()); + GetMapOfEntitiesRequest req = argument.getValue(); + Assert.assertEquals(req.getKey(), prefix); + IgniteEventImpl actual = store.get(key1); + Assert.assertNotNull(actual); + Assert.assertEquals(igniteEvent.getEventId(), actual.getEventId()); + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/DataUsageMetricsTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/DataUsageMetricsTest.java new file mode 100644 index 0000000..0a4f5b6 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/DataUsageMetricsTest.java @@ -0,0 +1,292 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.eclipse.ecsp.domain.DataUsageEventDataV1_0; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.SpeedV1_0; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.transform.GenericIgniteEventTransformer; +import org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.List; +import java.util.Optional; +import java.util.Properties; + +/** + * class DataUsageMetricsTest extends KafkaStreamsApplicationTestBase. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@EnableRuleMigrationSupport +@TestPropertySource("/stream-base-test2.properties") +public class DataUsageMetricsTest extends KafkaStreamsApplicationTestBase { + + private static IgniteKeyTransformerStringImpl keySer = new IgniteKeyTransformerStringImpl(); + private static String sourceTopicName; + private static String sinkTopicName; + private static int i = 0; + + @Autowired + GenericIgniteEventTransformer transformer; + + @Value("${" + PropertyNames.KAFKA_DATA_CONSUMPTION_METRICS_KAFKA_TOPIC + ":}") + private String dataUsageTestTopicName; + + @Override + @Before + public void setup() throws Exception { + super.setup(); + i++; + sourceTopicName = "sourceTopic" + i; + sinkTopicName = "sinkTopic" + i; + createTopics(sourceTopicName, sinkTopicName, dataUsageTestTopicName); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "tc-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + + ksProps.put("event.transformer.classes", "genericIgniteEventTransformer"); + ksProps.put("ignite.key.transformer.class", + "org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + ksProps.put("ingestion.serializer.class", + "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + ksProps.put("sink.topic.name", sinkTopicName); + ksProps.put(PropertyNames.PRE_PROCESSORS, + "org.eclipse.ecsp.analytics.stream.base.processors.TaskContextInitializer," + + "org.eclipse.ecsp.analytics.stream.base.processors.ProtocolTranslatorPreProcessor"); + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, StreamServiceProcessor.class.getName()); + ksProps.put(PropertyNames.POST_PROCESSORS, StreamPostProcessor.class.getName()); + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, PropBasedDiscoveryServiceImpl.class.getName()); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, sourceTopicName); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + + } + + /** + * Testing if the event size is pushed to data usage topic and it matches. + * + * @throws ExecutionException ExecutionException + * @throws InterruptedException InterruptedException + * @throws TimeoutException TimeoutException + */ + @Test + public void testDataUsageConsumptionMetricForNormalEvent() throws Exception { + + launchApplication(); + + IgniteEventImpl event = getDummyIgniteBlobEvent("dummyDeviceID", "req" + + System.currentTimeMillis(), "dummy_vid"); + + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, keySer + .toBlob(new IgniteStringKey("dummyId")), transformer.toBlob(event)); + + List messages = KafkaTestUtils.readMessages(dataUsageTestTopicName, consumerProps, 1); + for (int i = 0; i < messages.size(); i++) { + IgniteEvent igniteEvent = transformer.fromBlob(messages.get(i)[1].getBytes(), + Optional.ofNullable(null)); + DataUsageEventDataV1_0 testDataUsageEventData + = (DataUsageEventDataV1_0) igniteEvent.getEventData(); + Assert.assertEquals((double) transformer.toBlob(event).length / Constants.BYTE_1024, + testDataUsageEventData.getPayLoadSize(), 0.0); + } + + shutDownApplication(); + + } + + private IgniteEventImpl getDummyIgniteBlobEvent(String deviceID, String requestId, String vehicleId) { + + IgniteEventImpl igniteBlobEvent = new IgniteEventImpl(); + igniteBlobEvent.setSourceDeviceId(deviceID); + igniteBlobEvent.setEventId(EventID.SPEED); + igniteBlobEvent.setRequestId(requestId); + igniteBlobEvent.setSchemaVersion(Version.V1_0); + igniteBlobEvent.setTimestamp(System.currentTimeMillis()); + igniteBlobEvent.setVehicleId(vehicleId); + igniteBlobEvent.setVersion(Version.V1_0); + SpeedV1_0 speedV10 = new SpeedV1_0(); + speedV10.setValue(TestConstants.HUNDRED_DOUBLE); + igniteBlobEvent.setEventData(speedV10); + return igniteBlobEvent; + } + + /** + * inner class StreamServiceProcessor implements StreamProcessor. + */ + public static final class StreamServiceProcessor implements StreamProcessor { + private StreamProcessingContext spc; + + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + + } + + @Override + public String name() { + return "StreamServiceProcessor"; + } + + @Override + public void process(Record kafkaRecord) { + String fwdValue = new String(kafkaRecord.value()) + "_StreamServiceProcessor"; + spc.forward(new Record(kafkaRecord.key(), fwdValue.getBytes(), kafkaRecord.timestamp())); + } + + @Override + public void punctuate(long timestamp) { + + } + + @Override + public void close() { + + } + + @Override + public void configChanged(Properties props) { + + } + + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + @Override + public String[] sources() { + return new String[] { sourceTopicName }; + } + + @Override + public String[] sinks() { + return new String[] {}; + } + } + + /** + * inner class StreamPostProcessor implements StreamProcessor. + */ + public static final class StreamPostProcessor implements StreamProcessor { + private StreamProcessingContext spc; + + public StreamPostProcessor() { + + } + + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + + } + + @Override + public String name() { + return "StreamPostProcessor"; + } + + @Override + public void process(Record kafkaRecord) { + String fwdValue = new String(kafkaRecord.value()) + "StreamPostProcessor"; + spc.forward(new Record(kafkaRecord.key(), fwdValue.getBytes(), kafkaRecord.timestamp())); + } + + @Override + public void punctuate(long timestamp) { + + } + + @Override + public void close() { + + } + + @Override + public void configChanged(Properties props) { + + } + + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + @Override + public String[] sources() { + return new String[] { sourceTopicName }; + } + + @Override + public String[] sinks() { + return new String[] {}; + } + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/EmbeddedMQTTServerTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/EmbeddedMQTTServerTest.java new file mode 100644 index 0000000..47e14e5 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/EmbeddedMQTTServerTest.java @@ -0,0 +1,80 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertEquals; + +/** + * class EmbeddedMQTTServerTest extends KafkaStreamsApplicationTestBase. + */ +public class EmbeddedMQTTServerTest extends KafkaStreamsApplicationTestBase { + private static final String TOPIC = "test"; + private static final String PAYLOAD = "testPayload"; + + @Before + public void subscribeToTopic() throws MqttException { + subscibeToMqttTopic(TOPIC); + } + + @Test + public void testSingleMessage() throws MqttException, TimeoutException, InterruptedException { + publishMessageToMqttTopic(TOPIC, PAYLOAD.getBytes()); + List messages = getMessagesFromMqttTopic(TOPIC, 1, Constants.THREAD_SLEEP_TIME_10000); + assertEquals("Expected payload is different", PAYLOAD, new String(messages.get(0))); + } + + @Test + public void testMultipleMessages() throws MqttException, TimeoutException, InterruptedException { + publishMessageToMqttTopic(TOPIC, PAYLOAD.getBytes()); + publishMessageToMqttTopic(TOPIC, PAYLOAD.getBytes()); + List messages = getMessagesFromMqttTopic(TOPIC, Constants.TWO, Constants.THREAD_SLEEP_TIME_10000); + assertEquals("Expected payload is different", PAYLOAD, new String(messages.get(0))); + assertEquals("Expected payload is different", PAYLOAD, new String(messages.get(1))); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/GenericMapStateStoreTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/GenericMapStateStoreTest.java new file mode 100644 index 0000000..70de0ef --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/GenericMapStateStoreTest.java @@ -0,0 +1,160 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.eclipse.ecsp.analytics.stream.base.stores.GenericMapStateStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.junit.Assert; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * UT class {@link GenericMapStateStoreTest}. + */ +public class GenericMapStateStoreTest { + + private GenericMapStateStoreImpl mapStore = new GenericMapStateStoreImpl(); + + @Test + public void testName() { + String storeName = "GenericMapStateStoreName"; + Assert.assertEquals(storeName, mapStore.name()); + } + + @Test + public void testPersistent() { + Assert.assertFalse(mapStore.persistent()); + } + + @Test + public void testIsOpen() { + Assert.assertTrue(mapStore.isOpen()); + } + + @Test + public void testGet() { + mapStore.put("abc", "abc"); + Assert.assertEquals("abc", mapStore.get("abc")); + } + + @Test(expected = UnsupportedOperationException.class) + public void testRange() { + mapStore.range("abc", "bcd"); + } + + @Test + public void testAll() { + mapStore.put("abc", "abc"); + mapStore.put("bcd", "bcd"); + mapStore.put("cde", "cde"); + mapStore.put("def", "def"); + + Set keys = new HashSet(); + keys.add("abc"); + keys.add("bcd"); + keys.add("cde"); + keys.add("def"); + + Set actualKeys = new HashSet(); + + KeyValueIterator keyValItr = mapStore.all(); + while (keyValItr.hasNext()) { + KeyValue keyValue = keyValItr.next(); + actualKeys.add(keyValue.key); + } + Assert.assertEquals(keys.size(), actualKeys.size()); + Assert.assertTrue(actualKeys.containsAll(keys)); + + } + + @Test + public void testApproximateNumEntries() { + mapStore.put("abc", "abc"); + mapStore.put("bcd", "bcd"); + mapStore.put("cde", "cde"); + mapStore.put("def", "def"); + Assert.assertEquals(Constants.FOUR, mapStore.approximateNumEntries()); + } + + @Test + public void testPutIfAbsent() { + mapStore.put("abc", "abc"); + mapStore.putIfAbsent("abc", "def"); + Assert.assertEquals("abc", mapStore.get("abc")); + } + + @Test + public void testPutAll() { + KeyValue keyVal1 = new KeyValue("abc", "abc"); + KeyValue keyVal2 = new KeyValue("def", "def"); + List> list = new ArrayList>(); + list.add(keyVal1); + list.add(keyVal2); + mapStore.putAll(list); + KeyValueIterator keyValItr = mapStore.all(); + Set actualKeys = new HashSet(); + while (keyValItr.hasNext()) { + KeyValue keyValue = keyValItr.next(); + actualKeys.add(keyValue.key); + } + Assert.assertEquals(Constants.TWO, actualKeys.size()); + } + + @Test + public void testDelete() { + mapStore.put("abc", "abc"); + mapStore.delete("abc"); + Assert.assertNull(mapStore.get("abc")); + } + + /** + * inner class GenericMapStateStoreImpl extends GenericMapStateStore. + */ + public class GenericMapStateStoreImpl extends GenericMapStateStore { + + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/GenericSortedMapStateStoreTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/GenericSortedMapStateStoreTest.java new file mode 100644 index 0000000..9319776 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/GenericSortedMapStateStoreTest.java @@ -0,0 +1,220 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.stores.GenericSortedMapStateStore; +import org.junit.Assert; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * class {@link GenericSortedMapStateStoreTest}. + */ +public class GenericSortedMapStateStoreTest { + + private GenericSortedMapStateStoreImpl mapStore = new GenericSortedMapStateStoreImpl(); + + @Test + public void testName() { + String storeName = "GenericSortedMapStateStoreName"; + Assert.assertEquals(storeName, mapStore.name()); + } + + @Test + public void testPersistent() { + Assert.assertFalse(mapStore.persistent()); + } + + @Test + public void testIsOpen() { + Assert.assertTrue(mapStore.isOpen()); + } + + @Test + public void testGet() { + mapStore.put(TestConstants.THREAD_SLEEP_TIME_123, "abc"); + Assert.assertEquals("abc", mapStore.get(TestConstants.THREAD_SLEEP_TIME_123)); + } + + @Test + public void testRange() { + mapStore.put(TestConstants.THREAD_SLEEP_TIME_123, "abc"); + mapStore.put(TestConstants.THREAD_SLEEP_TIME_223, "bcd"); + mapStore.put(TestConstants.THREAD_SLEEP_TIME_223, "cde"); + mapStore.put(TestConstants.THREAD_SLEEP_TIME_423, "def"); + + Set keys = new HashSet(); + keys.add(TestConstants.THREAD_SLEEP_TIME_223); + keys.add(TestConstants.THREAD_SLEEP_TIME_323); + keys.add(TestConstants.THREAD_SLEEP_TIME_423); + + Set actualKeys = new HashSet(); + + KeyValueIterator keyValItr = mapStore + .range(TestConstants.THREAD_SLEEP_TIME_200, TestConstants.THREAD_SLEEP_TIME_500); + while (keyValItr.hasNext()) { + KeyValue keyValue = keyValItr.next(); + actualKeys.add(keyValue.key); + } + Assert.assertEquals(keys.size(), actualKeys.size()); + Assert.assertTrue(actualKeys.containsAll(keys)); + } + + @Test + public void testAll() { + mapStore.put(TestConstants.THREAD_SLEEP_TIME_123, "abc"); + mapStore.put(TestConstants.THREAD_SLEEP_TIME_223, "bcd"); + mapStore.put(TestConstants.THREAD_SLEEP_TIME_323, "cde"); + mapStore.put(TestConstants.THREAD_SLEEP_TIME_423, "def"); + + Set keys = new HashSet(); + keys.add(TestConstants.THREAD_SLEEP_TIME_123); + keys.add(TestConstants.THREAD_SLEEP_TIME_223); + keys.add(TestConstants.THREAD_SLEEP_TIME_323); + keys.add(TestConstants.THREAD_SLEEP_TIME_423); + + Set actualKeys = new HashSet(); + + KeyValueIterator keyValItr = mapStore.all(); + while (keyValItr.hasNext()) { + KeyValue keyValue = keyValItr.next(); + actualKeys.add(keyValue.key); + } + Assert.assertEquals(keys.size(), actualKeys.size()); + Assert.assertTrue(actualKeys.containsAll(keys)); + } + + @Test + public void testApproximateNumEntries() { + mapStore.put(TestConstants.THREAD_SLEEP_TIME_123, "abc"); + mapStore.put(TestConstants.THREAD_SLEEP_TIME_223, "bcd"); + mapStore.put(TestConstants.THREAD_SLEEP_TIME_323, "cde"); + mapStore.put(TestConstants.THREAD_SLEEP_TIME_423, "def"); + Assert.assertEquals(TestConstants.FOUR, mapStore.approximateNumEntries()); + } + + @Test + public void testPutIfAbsent() { + mapStore.put(TestConstants.THREAD_SLEEP_TIME_123, "abc"); + mapStore.putIfAbsent(TestConstants.THREAD_SLEEP_TIME_123, "def"); + Assert.assertEquals("abc", mapStore.get(TestConstants.THREAD_SLEEP_TIME_123)); + } + + @Test + public void testPutAll() { + KeyValue keyVal1 = new KeyValue(TestConstants.THREAD_SLEEP_TIME_123, "abc"); + KeyValue keyVal2 = new KeyValue(TestConstants.THREAD_SLEEP_TIME_223, "def"); + List> list = new ArrayList>(); + list.add(keyVal1); + list.add(keyVal2); + mapStore.putAll(list); + KeyValueIterator keyValItr = mapStore.all(); + Set actualKeys = new HashSet(); + while (keyValItr.hasNext()) { + KeyValue keyValue = keyValItr.next(); + actualKeys.add(keyValue.key); + } + Assert.assertEquals(TestConstants.TWO, actualKeys.size()); + } + + @Test + public void testDelete() { + mapStore.put(TestConstants.THREAD_SLEEP_TIME_123, "abc"); + mapStore.delete(TestConstants.THREAD_SLEEP_TIME_123); + Assert.assertNull(mapStore.get(TestConstants.THREAD_SLEEP_TIME_123)); + } + + @Test + public void testGetHead() { + mapStore.put(TestConstants.THREAD_SLEEP_TIME_123, "abc"); + mapStore.put(TestConstants.THREAD_SLEEP_TIME_223, "bcd"); + mapStore.put(TestConstants.THREAD_SLEEP_TIME_323, "cde"); + mapStore.put(TestConstants.THREAD_SLEEP_TIME_423, "def"); + + Set keys = new HashSet(); + keys.add(TestConstants.THREAD_SLEEP_TIME_123); + keys.add(TestConstants.THREAD_SLEEP_TIME_223); + + KeyValueIterator keyValItr = mapStore.getHead(TestConstants.THREAD_SLEEP_TIME_300); + Set actualKeys = new HashSet(); + while (keyValItr.hasNext()) { + KeyValue keyValue = keyValItr.next(); + actualKeys.add(keyValue.key); + } + Assert.assertEquals(TestConstants.TWO, actualKeys.size()); + Assert.assertTrue(actualKeys.containsAll(keys)); + } + + @Test + public void testGetTail() { + mapStore.put(TestConstants.THREAD_SLEEP_TIME_123, "abc"); + mapStore.put(TestConstants.THREAD_SLEEP_TIME_223, "bcd"); + mapStore.put(TestConstants.THREAD_SLEEP_TIME_323, "cde"); + mapStore.put(TestConstants.THREAD_SLEEP_TIME_423, "def"); + + Set keys = new HashSet(); + keys.add(TestConstants.THREAD_SLEEP_TIME_323); + keys.add(TestConstants.THREAD_SLEEP_TIME_423); + + KeyValueIterator keyValItr = mapStore.getTail(TestConstants.THREAD_SLEEP_TIME_300); + Set actualKeys = new HashSet(); + while (keyValItr.hasNext()) { + KeyValue keyValue = keyValItr.next(); + actualKeys.add(keyValue.key); + } + Assert.assertEquals(TestConstants.TWO, actualKeys.size()); + Assert.assertTrue(actualKeys.containsAll(keys)); + } + + /** + * class GenericSortedMapStateStoreImpl extends GenericSortedMapStateStore. + */ + public class GenericSortedMapStateStoreImpl extends GenericSortedMapStateStore { + + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/HarmanPersistentPrimitiveMapValueStoreTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/HarmanPersistentPrimitiveMapValueStoreTest.java new file mode 100644 index 0000000..6e3c739 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/HarmanPersistentPrimitiveMapValueStoreTest.java @@ -0,0 +1,132 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.streams.KeyValue; +import org.eclipse.ecsp.analytics.stream.base.kafka.SingleNodeKafkaCluster; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentPrimitiveMapValueStore; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +/** + * test class {@link HarmanPersistentPrimitiveMapValueStoreTest}. + */ +@RunWith(MockitoJUnitRunner.class) +public class HarmanPersistentPrimitiveMapValueStoreTest { + + @ClassRule + public static final SingleNodeKafkaCluster KAFKA_CLUSTER = new SingleNodeKafkaCluster(); + protected Properties ksProps; + protected Properties consumerProps; + protected Properties producerProps; + + @Mock + private HarmanPersistentPrimitiveMapValueStore harmanPersistentPrimitiveMapValueStore; + + @Before + public void setup() throws Exception { + MockitoAnnotations.initMocks(this); + harmanPersistentPrimitiveMapValueStore = Mockito.spy(new HarmanPersistentPrimitiveMapValueStore("test", true)); + } + + @Test + public void testName() { + String storeName = "test"; + Assert.assertEquals(storeName, harmanPersistentPrimitiveMapValueStore.name()); + } + + @Test + public void testPersistant() { + Assert.assertTrue(harmanPersistentPrimitiveMapValueStore.persistent()); + } + + @Test + public void testClose() { + harmanPersistentPrimitiveMapValueStore = new HarmanPersistentPrimitiveMapValueStore("test", false); + harmanPersistentPrimitiveMapValueStore.close(); + Assert.assertFalse(harmanPersistentPrimitiveMapValueStore.isOpen()); + } + + @Test + public void testIsOpen() { + harmanPersistentPrimitiveMapValueStore = new HarmanPersistentPrimitiveMapValueStore("test", false); + harmanPersistentPrimitiveMapValueStore.flush(); + Assert.assertFalse(harmanPersistentPrimitiveMapValueStore.isOpen()); + } + + @Test + public void testPutAll() { + Map myMap = new HashMap(); + myMap.put("1", 1); + KeyValue> keyVal1 = new KeyValue<>("abc", myMap); + List>> list = new ArrayList<>(); + list.add(keyVal1); + Assert.assertThrows(NullPointerException.class, + () -> harmanPersistentPrimitiveMapValueStore.putAll(list)); + Assert.assertThrows(NullPointerException.class, + () -> harmanPersistentPrimitiveMapValueStore.get("abc")); + + } + + @Test() + public void testApproximateNumEntries() { + Map myMap = new HashMap(); + myMap.put("1", 1); + KeyValue keyVal1 = new KeyValue("abc", myMap); + List> list = new ArrayList>(); + list.add(keyVal1); + Assert.assertThrows(NullPointerException.class, + () -> harmanPersistentPrimitiveMapValueStore.approximateNumEntries()); + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaProducerInstanceTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaProducerInstanceTest.java new file mode 100644 index 0000000..6800bdb --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaProducerInstanceTest.java @@ -0,0 +1,146 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.clients.producer.KafkaProducer; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaSslUtils; +import org.junit.Test; +import org.junit.jupiter.api.Assertions; + +import java.util.Properties; + +/** + * UT class {@link KafkaProducerInstanceTest}. + */ +public class KafkaProducerInstanceTest { + + @Test + public void testProducerInstanceWithSslEnabled() { + + Properties kafkaConfig = new Properties(); + String kafkaBootstrapServers = "localhost:9092"; + String kafkaSslEnable = "true"; + String keystore = "src/test/resources/kafka.client.keystore.jks"; + String keystorePwd = "password"; + String keyPwd = "password"; + String truststore = "src/test/resources/kafka.client.truststore.jks"; + String truststorePwd = "password"; + String sslClientAuth = "required"; + String maxRequestSize = "1000012"; + String acksConfig = "1"; + String retriesConfig = "2147483647"; + String batchSizeConfig = "16384"; + String lingerMsConfig = "0"; + String bufferMemoryConfig = "33554432"; + String requestTimeoutMsConfig = "30000"; + String deliveryTimeoutMsConfig = "120000"; + String compressionTypeConfig = "none"; + + kafkaConfig.put(PropertyNames.BOOTSTRAP_SERVERS, kafkaBootstrapServers); + kafkaConfig.put(PropertyNames.KAFKA_SSL_ENABLE, kafkaSslEnable); + kafkaConfig.put(PropertyNames.KAFKA_CLIENT_KEYSTORE, keystore); + kafkaConfig.put(PropertyNames.KAFKA_CLIENT_KEYSTORE_PASSWORD, keystorePwd); + kafkaConfig.put(PropertyNames.KAFKA_CLIENT_KEY_PASSWORD, keyPwd); + kafkaConfig.put(PropertyNames.KAFKA_CLIENT_TRUSTSTORE, truststore); + kafkaConfig.put(PropertyNames.KAFKA_CLIENT_TRUSTSTORE_PASSWORD, truststorePwd); + kafkaConfig.put(PropertyNames.KAFKA_SSL_CLIENT_AUTH, sslClientAuth); + kafkaConfig.put(PropertyNames.KAFKA_MAX_REQUEST_SIZE, maxRequestSize); + kafkaConfig.put(PropertyNames.KAFKA_ACKS_CONFIG, acksConfig); + kafkaConfig.put(PropertyNames.KAFKA_RETRIES_CONFIG, retriesConfig); + kafkaConfig.put(PropertyNames.KAFKA_BATCH_SIZE_CONFIG, batchSizeConfig); + kafkaConfig.put(PropertyNames.KAFKA_LINGER_MS_CONFIG, lingerMsConfig); + kafkaConfig.put(PropertyNames.KAFKA_BUFFER_MEMORY_CONFIG, bufferMemoryConfig); + kafkaConfig.put(PropertyNames.KAFKA_REQUEST_TIMEOUT_MS_CONFIG, requestTimeoutMsConfig); + kafkaConfig.put(PropertyNames.KAFKA_DELIVERY_TIMEOUT_MS_CONFIG, deliveryTimeoutMsConfig); + kafkaConfig.put(PropertyNames.KAFKA_COMPRESSION_TYPE_CONFIG, compressionTypeConfig); + KafkaSslUtils.checkAndApplySslProperties(kafkaConfig); + KafkaProducer kafkaProducer = KafkaProducerInstance.getProducerInstance(kafkaConfig); + + Assertions.assertNotNull(kafkaProducer); + } + + @Test + public void testProducerInstanceWithOneWayTlsEnabled() { + + Properties kafkaConfig = new Properties(); + String kafkaBootstrapServers = "localhost:9092"; + String kafkaSslEnable = "false"; + String kafkaOneWayTlsEnable = "true"; + String truststore = "src/test/resources/kafka.client.truststore.jks"; + String truststorePwd = "password"; + String sslClientAuth = "none"; + String maxRequestSize = "1000012"; + String acksConfig = "1"; + String retriesConfig = "2147483647"; + String batchSizeConfig = "16384"; + String lingerMsConfig = "0"; + String bufferMemoryConfig = "33554432"; + String requestTimeoutMsConfig = "30000"; + String deliveryTimeoutMsConfig = "120000"; + String compressionTypeConfig = "none"; + String kafkaSaslMechanism = "PLAIN"; + String kafkaSaslJaasConfig = + "org.apache.kafka.common.security.plain.PlainLoginModule required username=\"admin\" " + + "password=\"password\";"; + kafkaConfig.put(PropertyNames.BOOTSTRAP_SERVERS, kafkaBootstrapServers); + kafkaConfig.put(PropertyNames.KAFKA_SSL_ENABLE, kafkaSslEnable); + kafkaConfig.put(PropertyNames.KAFKA_ONE_WAY_TLS_ENABLE, kafkaOneWayTlsEnable); + kafkaConfig.put(PropertyNames.KAFKA_SASL_MECHANISM, kafkaSaslMechanism); + kafkaConfig.put(PropertyNames.KAFKA_SASL_JAAS_CONFIG, kafkaSaslJaasConfig); + kafkaConfig.put(PropertyNames.KAFKA_CLIENT_TRUSTSTORE, truststore); + kafkaConfig.put(PropertyNames.KAFKA_CLIENT_TRUSTSTORE_PASSWORD, truststorePwd); + kafkaConfig.put(PropertyNames.KAFKA_SSL_CLIENT_AUTH, sslClientAuth); + kafkaConfig.put(PropertyNames.KAFKA_MAX_REQUEST_SIZE, maxRequestSize); + kafkaConfig.put(PropertyNames.KAFKA_ACKS_CONFIG, acksConfig); + kafkaConfig.put(PropertyNames.KAFKA_RETRIES_CONFIG, retriesConfig); + kafkaConfig.put(PropertyNames.KAFKA_BATCH_SIZE_CONFIG, batchSizeConfig); + kafkaConfig.put(PropertyNames.KAFKA_LINGER_MS_CONFIG, lingerMsConfig); + kafkaConfig.put(PropertyNames.KAFKA_BUFFER_MEMORY_CONFIG, bufferMemoryConfig); + kafkaConfig.put(PropertyNames.KAFKA_REQUEST_TIMEOUT_MS_CONFIG, requestTimeoutMsConfig); + kafkaConfig.put(PropertyNames.KAFKA_DELIVERY_TIMEOUT_MS_CONFIG, deliveryTimeoutMsConfig); + kafkaConfig.put(PropertyNames.KAFKA_COMPRESSION_TYPE_CONFIG, compressionTypeConfig); + + KafkaSslUtils.checkAndApplySslProperties(kafkaConfig); + KafkaProducer kafkaProducer = KafkaProducerInstance.getProducerInstance(kafkaConfig); + + Assertions.assertNotNull(kafkaProducer); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStateListnerHealthMonitorTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStateListnerHealthMonitorTest.java new file mode 100644 index 0000000..9e10178 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStateListnerHealthMonitorTest.java @@ -0,0 +1,66 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.streams.KafkaStreams.State; +import org.junit.Assert; +import org.junit.Test; + +import java.util.Collections; + +/** + * test class for KafkaStateListnerHealthMonitor. + */ +public class KafkaStateListnerHealthMonitorTest { + + /** + * It should be healthy only if Current State is RUNNING . + */ + @Test + public void testKafkaStateListenerHealthMonitor() { + KafkaStateListener kafkaStateListener = new KafkaStateListener(); + kafkaStateListener.setBackdoorConsumers(Collections.EMPTY_LIST); + kafkaStateListener.onChange(State.RUNNING, State.CREATED); + Assert.assertTrue(kafkaStateListener.isHealthy(true)); + kafkaStateListener.onChange(State.PENDING_SHUTDOWN, State.CREATED); + Assert.assertFalse(kafkaStateListener.isHealthy(true)); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsLauncherMockTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsLauncherMockTest.java new file mode 100644 index 0000000..6e53abe --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsLauncherMockTest.java @@ -0,0 +1,139 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.streams.KafkaStreams; +import org.eclipse.ecsp.analytics.stream.base.kafka.support.KafkaStreamsThreadStatusPrinter; +import org.eclipse.ecsp.healthcheck.HealthMonitor; +import org.eclipse.ecsp.healthcheck.HealthService; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +/** + * class {@link KafkaStreamsLauncherMockTest}. + */ +public class KafkaStreamsLauncherMockTest { + + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + @InjectMocks + private KafkaStreamsLauncher launcher; + + @Mock + private HealthService healthService; + + @Mock + private KafkaStateListener kafkaStateListenerHealthMonitor; + + @Mock + private KafkaStreams streams; + + @Mock + private KafkaStreamsThreadStatusPrinter threadStatusPrinter; + + @Mock + private Properties props; + + /** + * setUp(). + */ + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + List ignoredMonitorNames = new ArrayList(); + ignoredMonitorNames.add("KAFKA_CONSUMER_GROUP_HEALTH_MONITOR"); + ignoredMonitorNames.add("DEVICE_STATUS_BACKDOOR_HEALTH_MONITOR"); + launcher.setBootstrapIgnoredMonitors(ignoredMonitorNames); + launcher.setHealthService(healthService); + } + + @Test + public void testHealthCheck() { + List failedMonitors = new ArrayList(); + failedMonitors.add(kafkaStateListenerHealthMonitor); + Mockito.when(healthService.triggerInitialCheck()).thenReturn(failedMonitors); + Mockito.when(kafkaStateListenerHealthMonitor.monitorName()) + .thenReturn("KAFKA_CONSUMER_GROUP_HEALTH_MONITOR"); + Assert.assertFalse(launcher.bootstrapHealthCheck()); + + Mockito.when(kafkaStateListenerHealthMonitor.monitorName()) + .thenReturn("NOT_KAFKA_CONSUMER_GROUP_HEALTH_MONITOR"); + Assert.assertTrue(launcher.bootstrapHealthCheck()); + } + + @Test + public void testTerminate() { + ReflectionTestUtils.setField(launcher, "streams", streams); + launcher.terminate(); + Mockito.verify(threadStatusPrinter, Mockito.times(1)).close(); + Mockito.verify(streams, Mockito.times(1)).close(); + } + + @Test(expected = IllegalArgumentException.class) + public void testCheckMergeDefaultsIfApplicationIDNull() { + Mockito.when(props.getProperty(Mockito.anyString())).thenReturn(null); + ReflectionTestUtils.invokeMethod(launcher, "checkMergeDefaults", props); + } + + /* + * Commented below test case scenario as it calls System.exit() internally + * which leads jvm to exit and also affects jacoco maven plugin execution. + * + * @Test public void testBootstrapHealthCheckException() { + * + * Mockito.when(launcher.bootstrapHealthCheck()).thenThrow(new + * IllegalStateException()); launcher.doLaunch(new Properties()); } + * + */ +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsLauncherTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsLauncherTest.java new file mode 100644 index 0000000..ca10cf3 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsLauncherTest.java @@ -0,0 +1,925 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.Metric; +import org.apache.kafka.common.MetricName; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.KafkaStreams; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.StreamsConfig; +import org.apache.kafka.streams.Topology; +import org.apache.kafka.streams.processor.PunctuationType; +import org.apache.kafka.streams.processor.Punctuator; +import org.apache.kafka.streams.processor.api.Processor; +import org.apache.kafka.streams.processor.api.ProcessorContext; +import org.apache.kafka.streams.processor.api.ProcessorSupplier; +import org.apache.kafka.streams.processor.api.Record; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.apache.kafka.streams.state.KeyValueStore; +import org.apache.kafka.streams.state.Stores; +import org.eclipse.ecsp.analytics.stream.base.dao.GenericDAO; +import org.eclipse.ecsp.analytics.stream.base.discovery.StreamProcessorDiscoveryService; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.stores.MapObjectStateStore; +import org.eclipse.ecsp.analytics.stream.base.stores.ObjectStateStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.cache.PutStringRequest; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.redisson.api.RedissonClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +/** + * class KafkaStreamsLauncherTest extends KafkaStreamsApplicationTestBase. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@EnableRuleMigrationSupport +@TestPropertySource("/stream-base-test.properties") +public class KafkaStreamsLauncherTest extends KafkaStreamsApplicationTestBase { + + private static final Logger LOGGER = LoggerFactory.getLogger(KafkaStreamsLauncherTest.class); + private static final String[] TOPIC_NAMES = new String[] { null, null, null }; + private static int i = 0; + private String inTopicName; + private String outTopicName; + private String additionalInTopicName; + @Autowired + private RedissonClient redissonClient; + + @Override + @Before + public void setup() throws Exception { + super.setup(); + i++; + inTopicName = "raw-events-" + i; + outTopicName = "output-topic-" + i; + additionalInTopicName = "input-alerts-" + i; + createTopics(inTopicName, additionalInTopicName, outTopicName); + TOPIC_NAMES[0] = inTopicName; + TOPIC_NAMES[1] = outTopicName; + TOPIC_NAMES[Constants.TWO] = additionalInTopicName; + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "tc-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.String().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.String().serializer().getClass().getName()); + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, SimpleTestServiceDiscoveryImpl.class.getName()); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, inTopicName); + ksProps.put(PropertyNames.APPLICATION_ID, "pt"); + } + + @Test + public void testSetup() throws TimeoutException, ExecutionException, InterruptedException { + KafkaTestUtils.sendMessages(inTopicName, producerProps, "key1", "value1"); + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + List messages = KafkaTestUtils.readMessages(inTopicName, consumerProps, + Constants.THREAD_SLEEP_TIME_100); + Assert.assertEquals(1, messages.size()); + } + + @Test + public void testSingleProcessor() throws Exception { + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, SimpleTestServiceDiscoveryImpl.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "pt"); + launchApplication(); + KafkaTestUtils.sendMessages(inTopicName, producerProps, "key1", "value1"); + List messages = KafkaTestUtils.getMessages(outTopicName, + consumerProps, 1, Constants.THREAD_SLEEP_TIME_10000); + Assert.assertEquals("key1", messages.get(0)[0]); + Assert.assertEquals("value1", messages.get(0)[1]); + shutDown(); + } + + @Test + public void testSingleProcessorUsingIgniteCache() throws Exception { + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, IgniteCacheTestServiceDiscoveryImpl.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "ptRedis" + System.currentTimeMillis()); + launchApplication(); + String key = "key" + System.currentTimeMillis(); + String value = "value1"; + String actualValue = (String) redissonClient.getBucket(key).get(); + Assert.assertNull(actualValue); + KafkaTestUtils.sendMessages(inTopicName, producerProps, key, value); + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + actualValue = retryWithException(Constants.TEN, (v) -> { + return (String) redissonClient.getBucket(key).get(); + }); + Assert.assertNotNull(actualValue); + Assert.assertEquals(value, actualValue); + shutDown(); + } + + @Test + public void testSingleProcessorWithState() throws Exception { + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, StateTestServiceDiscoveryImpl.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "pts" + System.currentTimeMillis()); + launchApplication(); + Thread.sleep(Constants.THREAD_SLEEP_TIME_10000); + KafkaTestUtils.sendMessages(inTopicName, producerProps, "key1", "value1"); + Thread.sleep(Constants.THREAD_SLEEP_TIME_10000); + List messages = KafkaTestUtils.getMessages(outTopicName, consumerProps, 1, + Constants.THREAD_SLEEP_TIME_10000); + Assert.assertEquals("key1", messages.get(0)[0]); + Assert.assertEquals("value1", messages.get(0)[1]); + shutDown(); + } + + /** + * The test case is same as testSingleProcessorWithState, only difference is + * that in this case Streamprocessor is using hashmap as its + * state store instead of rocksDB. + * + * @throws TimeoutException TimeoutException + * @throws IOException IOException + * @throws InterruptedException InterruptedException + * @throws ExecutionException ExecutionException + */ + @Test + public void testSingleProcessorWithHashMapAsState() throws Exception, InterruptedException, ExecutionException { + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, HashMapStateTestServiceDiscoveryImpl.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "pts" + System.currentTimeMillis()); + // set the state.store.type property to map + ksProps.put(PropertyNames.STATE_STORE_TYPE, "map"); + launchApplication(); + KafkaTestUtils.sendMessages(inTopicName, producerProps, "key1", "value1"); + List messages = KafkaTestUtils.getMessages(outTopicName, + consumerProps, 1, Constants.THREAD_SLEEP_TIME_10000); + Assert.assertEquals("key1", messages.get(0)[0]); + Assert.assertEquals("value1", messages.get(0)[1]); + shutDown(); + } + + /* + * 2 input topics. 2 output topics. Message from first topic goes to output + * topic + '-1'. Message from second topic goes to output topic+'-2' + */ + @Test + public void testSingleProcessorMultipleSourcesMultipleSinks() + throws Exception { + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, MultipleSourcesServiceDiscoveryImpl.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "pts" + System.currentTimeMillis()); + launchApplication(); + KafkaTestUtils.sendMessages(inTopicName, producerProps, "key1", "value1"); + KafkaTestUtils.sendMessages(TOPIC_NAMES[Constants.TWO], producerProps, "key2", "value2"); + Thread.sleep(Constants.TWO_THOUSAND); + List messages = KafkaTestUtils.getMessages(TOPIC_NAMES[1] + "-1", consumerProps, 1, + Constants.THREAD_SLEEP_TIME_10000); + Assert.assertEquals(1, messages.size()); + Assert.assertEquals("key1", messages.get(0)[0]); + Assert.assertEquals("value1", messages.get(0)[1]); + Thread.sleep(Constants.TWO_THOUSAND); + messages = KafkaTestUtils.getMessages(TOPIC_NAMES[1] + "-2", consumerProps, 1, + Constants.THREAD_SLEEP_TIME_10000); + Assert.assertEquals(1, messages.size()); + Assert.assertEquals("key2", messages.get(0)[0]); + Assert.assertEquals("value2", messages.get(0)[1]); + shutDown(); + } + + /** + * Simple test case to verify the initialization of Map state store and map iterator. + */ + // @Test + public void testMapStateStore() { + MapObjectStateStore mapStore = new MapObjectStateStore(); + + String key1 = "key1"; + String val1 = "val1"; + + String key2 = "key2"; + String val2 = "val2"; + + // Push the key value to map store + mapStore.put(key1, val1); + mapStore.put(key2, val2); + + // initialize the result map + Map resultMap = new HashMap(); + + // get the iterator + KeyValueIterator iter = null; + iter = mapStore.all(); + while (iter.hasNext()) { + KeyValue keyValue = iter.next(); + resultMap.put(keyValue.key, keyValue.value); + } + + Assert.assertEquals("The size of key value pairs doesn't match.", Constants.TWO, resultMap.size()); + Assert.assertEquals("Key1 value doesn't match", "val1", resultMap.get(key1)); + Assert.assertEquals("Key2 value doesn't match", "val2", resultMap.get(key2)); + + // clear the result map + resultMap.clear(); + + // Remove key1 + mapStore.delete("key1"); + + // Now iterate + iter = mapStore.all(); + while (iter.hasNext()) { + KeyValue keyValue = iter.next(); + resultMap.put(keyValue.key, keyValue.value); + } + + Assert.assertEquals("The size of key value pairs doesn't match.", 1, resultMap.size()); + Assert.assertEquals("Key2 value doesn't match", "val2", resultMap.get(key2)); + + } + + /** + * testSingleProcessorUsingKafkaStreams(). + * + * @throws TimeoutException TimeoutException + * @throws IOException IOException + * @throws InterruptedException InterruptedException + * @throws ExecutionException ExecutionException + */ + public void testSingleProcessorUsingKafkaStreams() throws TimeoutException, + IOException, InterruptedException, ExecutionException { + Properties props = new Properties(); + props.put(PropertyNames.APPLICATION_ID, "passthrough-test-" + System.currentTimeMillis()); + props.put(PropertyNames.BOOTSTRAP_SERVERS, KAFKA_CLUSTER.bootstrapServers()); + props.put(PropertyNames.ZOOKEEPER_CONNECT, KAFKA_CLUSTER.zkconnectstring()); + props.put(PropertyNames.NUM_STREAM_THREADS, "1"); + props.put(PropertyNames.REPLICATION_FACTOR, "1"); + props.put(StreamsConfig.STATE_DIR_CONFIG, "/tmp/kafka-streams"); + KafkaTestUtils.purgeLocalStreamsState(props); + try (var streams = new KafkaStreams(new Topology().addSource("source", inTopicName) + .addProcessor("pass-through", new ProcessorSupplier() { + @Override + public Processor get() { + return new Processor() { + private ProcessorContext ctx; + + @Override + public void init(ProcessorContext context) { + this.ctx = context; + } + + @Override + public void process(Record kafkaRecord) { + + ctx.forward(new Record<>(new String(kafkaRecord.key(), StandardCharsets.UTF_8), + new String(kafkaRecord.value(), StandardCharsets.UTF_8), + System.currentTimeMillis())); + } + + public void punctuate(long timestamp) { + // + } + + @Override + public void close() { + // + } + }; + } + + }, "source") + .addSink("sink", outTopicName, "pass-through"), props)) { + streams.start(); + KafkaTestUtils.sendMessages(inTopicName, producerProps, "key1", "value1"); + Thread.sleep(Constants.THREAD_SLEEP_TIME_4000); + List messages = KafkaTestUtils.getMessages(outTopicName, consumerProps, 1, + Constants.THREAD_SLEEP_TIME_10000); + Assert.assertEquals("key1", messages.get(0)[0]); + Assert.assertEquals("value1", messages.get(0)[1]); + } + ; + } + + /** + * testSingleProcessorStateUsingKafkaStreams(). + * + * @throws TimeoutException TimeoutException + * @throws IOException IOException + * @throws InterruptedException InterruptedException + * @throws ExecutionException ExecutionException + */ + public void testSingleProcessorStateUsingKafkaStreams() throws TimeoutException, + IOException, InterruptedException, ExecutionException { + Properties props = new Properties(); + props.put(PropertyNames.APPLICATION_ID, "passthrough-test-" + System.currentTimeMillis()); + // RTC-141484 - Kafka version upgrade from 1.0.0. to + // 2.2.0 changes + props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass().getName()); + props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass().getName()); + props.put(PropertyNames.BOOTSTRAP_SERVERS, KAFKA_CLUSTER.bootstrapServers()); + props.put(PropertyNames.ZOOKEEPER_CONNECT, KAFKA_CLUSTER.zkconnectstring()); + props.put(PropertyNames.NUM_STREAM_THREADS, "1"); + props.put(PropertyNames.REPLICATION_FACTOR, "1"); + props.put(StreamsConfig.STATE_DIR_CONFIG, "/tmp/kafka-streams"); + KafkaTestUtils.purgeLocalStreamsState(props); + try (var streams = new KafkaStreams(new Topology().addSource("source", inTopicName) + .addProcessor("pass-through", new ProcessorSupplier() { + @Override + public Processor get() { + return new Processor() { + private ProcessorContext ctx; + + @Override + public void init(ProcessorContext context) { + this.ctx = context; + } + + @Override + public void process(Record kafkaRecord) { + ctx.forward(kafkaRecord); + } + + public void punctuate(long timestamp) { + // + } + + @Override + public void close() { + // + } + }; + } + + }, "source") + .addStateStore(Stores.keyValueStoreBuilder(Stores.inMemoryKeyValueStore("state"), + Serdes.String(), Serdes.String()), + "pass-through") + .addSink("sink", outTopicName, "pass-through"), props)) { + streams.start(); + KafkaTestUtils.sendMessages(inTopicName, producerProps, "key1", "value1"); + List messages = KafkaTestUtils.getMessages(outTopicName, + consumerProps, 1, Constants.THREAD_SLEEP_TIME_10000); + Assert.assertEquals("key1", messages.get(0)[0]); + Assert.assertEquals("value1", messages.get(0)[1]); + } + } + + @Test + public void testMetricsOfKafkaStreams() throws Exception { + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, SimpleTestServiceDiscoveryImpl.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "pt"); + ksProps.put(PropertyNames.NUM_STREAM_THREADS, 1); + + launchApplication(); + Map map = ctx.getBean(KafkaStreamsLauncher.class).getStreams().metrics(); + + for (Map.Entry metricNameEntry : map.entrySet()) { + if (metricNameEntry.getKey().name().equalsIgnoreCase("alive-stream-threads")) { + Assert.assertEquals("4", metricNameEntry.getValue().metricValue().toString()); + } + } + killThreads(1); + for (Map.Entry metricNameEntry : map.entrySet()) { + if (metricNameEntry.getKey().name().equalsIgnoreCase("alive-stream-threads")) { + Assert.assertEquals("4", metricNameEntry.getValue().metricValue().toString()); + } + } + shutDownApplication(); + } + + private void killThreads(int threadCount) { + ThreadGroup rootGroup = Thread.currentThread().getThreadGroup(); + ThreadGroup parentGroup; + + while ((parentGroup = rootGroup.getParent()) != null) { + rootGroup = parentGroup; + } + Thread[] threads = new Thread[rootGroup.activeCount()]; + while (rootGroup.enumerate(threads, true) == threads.length) { + threads = new Thread[threads.length * Constants.TWO]; + } + + for (Thread thread : threads) { + if (thread == null) { + continue; + } + String name = thread.getName(); + if (name.endsWith("-StreamThread-" + threadCount)) { + LOGGER.info("Killing thread {} ", name); + thread.stop(); + } + } + + } + + /** + * class CustomTestServiceDiscoveryImpl implements StreamProcessorDiscoveryService. + */ + public static final class CustomTestServiceDiscoveryImpl implements StreamProcessorDiscoveryService { + @Override + public List> discoverProcessors() { + return Arrays.asList(new CustomTestProcessor()); + } + } + + /** + * class CustomTestProcessor implements StreamProcessor. + */ + @Component + public static final class CustomTestProcessor implements StreamProcessor { + + ObjectMapper objectMapper = new ObjectMapper(); + private StreamProcessingContext spc; + private List dtcList = null; + private GenericDAO dao = null; + + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + dtcList = dao.getRecords("", "v01", "DTC"); + } + + @Override + public String name() { + return "CustomTestProcessor"; + } + + @Override + public void process(Record kafkaRecord) { + StringBuilder dtcs = new StringBuilder(); + + for (String dtc : dtcList) { + dtcs.append(dtc).append(","); + } + if (dtcs.length() > 0) { + dtcs.deleteCharAt(dtcs.length() - 1); + } + spc.forward(new Record<>("DTC", dtcs.toString(), kafkaRecord.timestamp())); + } + + @Override + public void punctuate(long timestamp) { + } + + @Override + public void close() { + } + + @Override + public String[] sinks() { + return new String[] { TOPIC_NAMES[1] }; + } + + @Override + public void configChanged(Properties props) { + } + + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + @Override + public void updateSharedData(Object key, Object value, String streamName) { + try { + @SuppressWarnings("rawtypes") + HashMap message = objectMapper.readValue(String.valueOf(value), HashMap.class); + @SuppressWarnings("rawtypes") + List list = (List) (((Map) message.get("v01")).get("DTC")); + + dtcList.addAll(list); + } catch (IOException e) { + LOGGER.error("Exception occurred while parsing value : " + value, e); + } + } + + @Override + public void setExternalSharedDataSource(GenericDAO dao) { + this.dao = dao; + } + } + + /** + * class SimpleTestServiceDiscoveryImpl implements StreamProcessorDiscoveryService. + */ + @Component + public static final class SimpleTestServiceDiscoveryImpl implements StreamProcessorDiscoveryService { + @Override + public List> discoverProcessors() { + return Arrays.asList(new PassThroughTestProcessor()); + } + } + + /** + * class IgniteCacheTestServiceDiscoveryImpl implements StreamProcessorDiscoveryService. + */ + @Component + public static final class IgniteCacheTestServiceDiscoveryImpl implements StreamProcessorDiscoveryService { + @Override + public List> discoverProcessors() { + return Arrays.asList(new IgniteCacheTestProcessor()); + } + } + + /** + * innerc class StateTestServiceDiscoveryImpl implements StreamProcessorDiscoveryService. + */ + @Component + public static final class StateTestServiceDiscoveryImpl implements StreamProcessorDiscoveryService { + @Override + public List> discoverProcessors() { + return Arrays.asList(new StreamProcessorWithStateStore()); + } + } + + /** + * class StreamProcessorWithStateStore implements StreamProcessor. + */ + @Component + public static final class StreamProcessorWithStateStore implements StreamProcessor { + private StreamProcessingContext spc; + private KeyValueStore objectStore; + + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + objectStore = spc.getStateStore("state"); + } + + @Override + public String name() { + return "state-proc"; + } + + /** + * process(). + * + * @param kafkaRecord kafkaRecord + */ + @Override + public void process(Record kafkaRecord) { + objectStore.put(new String(kafkaRecord.key()), new String(kafkaRecord.value())); + String value = String.valueOf(objectStore.get(new String(kafkaRecord.key()))); + spc.forward(new Record<>(new String(kafkaRecord.key()), + new String(kafkaRecord.value()), System.currentTimeMillis())); + } + + @Override + public void punctuate(long timestamp) { + KeyValueIterator i = objectStore.all(); + if (i.hasNext()) { + KeyValue kv = i.next(); + spc.forward(new Record<>(kv.key, kv.value, System.currentTimeMillis())); + } + } + + @Override + public void close() { + } + + @Override + public void configChanged(Properties props) { + } + + @Override + public HarmanPersistentKVStore createStateStore() { + return new ObjectStateStore("state", true); + } + + @Override + public String[] sinks() { + return new String[] { TOPIC_NAMES[1] }; + } + } + + /** + * class PassThroughTestProcessor implements StreamProcessor. + */ + @Component + public static final class PassThroughTestProcessor implements StreamProcessor { + private StreamProcessingContext spc; + private Properties config; + + public Properties getConfig() { + return config; + } + + @Override + public void init(StreamProcessingContext spc) { + Assert.assertNotNull(config); + Assert.assertFalse(config.isEmpty()); + this.spc = spc; + } + + @Override + public String name() { + return "simple"; + } + + @Override + public void process(Record kafkaRecord) { + String streamName = spc.streamName(); + int partition = spc.partition(); + long offset = spc.offset(); + System.out.println("Record metaData - streamName : " + streamName + + ", partition : " + partition + ", offset : " + offset); + spc.forward(new Record<>(new String(kafkaRecord.key()), + new String(kafkaRecord.value()), kafkaRecord.timestamp())); + } + + @Override + public void punctuate(long timestamp) { + } + + @Override + public void close() { + } + + @Override + public void configChanged(Properties props) { + } + + @Override + public void initConfig(Properties props) { + this.config = props; + } + + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + @Override + public String[] sinks() { + return new String[] { TOPIC_NAMES[1] }; + } + + } + + /** + * class IgniteCacheTestProcessor implements StreamProcessor. + */ + @Component + public static final class IgniteCacheTestProcessor implements StreamProcessor { + private StreamProcessingContext spc; + private Properties config; + @Autowired + private IgniteCache igniteCache; + + public Properties getConfig() { + return config; + } + + @Override + public void init(StreamProcessingContext spc) { + Assert.assertNotNull(config); + Assert.assertFalse(config.isEmpty()); + this.spc = spc; + } + + @Override + public String name() { + return "simple"; + } + + @Override + public void process(Record kafkaRecord) { + igniteCache.putString(new PutStringRequest().withKey(new String(kafkaRecord.key())) + .withValue(new String(kafkaRecord.value())).withNamespaceEnabled(false)); + } + + @Override + public void punctuate(long timestamp) { + } + + @Override + public void close() { + } + + @Override + public void configChanged(Properties props) { + } + + @Override + public void initConfig(Properties props) { + this.config = props; + } + + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + @Override + public String[] sinks() { + return new String[] { TOPIC_NAMES[1] }; + } + + } + + /** + * inner class MultipleSourcesServiceDiscoveryImpl implements StreamProcessorDiscoveryService. + */ + public static class MultipleSourcesServiceDiscoveryImpl implements StreamProcessorDiscoveryService { + + @Override + public List> discoverProcessors() { + return Arrays.asList(new StreamProcessor[] { new StreamProcessoWithMultipleSourcesAndSinks() + }); + } + } + + /** + * class StreamProcessoWithMultipleSourcesAndSinks + * implements StreamProcessor. + */ + @Component + public static class StreamProcessoWithMultipleSourcesAndSinks + implements StreamProcessor { + private StreamProcessingContext spc = null; + + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + } + + @Override + public String name() { + return "multi-source-pass-through"; + } + + @Override + public void process(Record kafkaRecord) { + String sinkName = null; + if (spc.streamName().equals(TOPIC_NAMES[0])) { + sinkName = TOPIC_NAMES[1] + "-1"; + } else { + sinkName = TOPIC_NAMES[1] + "-2"; + } + System.out.println("sinkName: " + sinkName); + spc.forward(new Record<>(new String(kafkaRecord.key()), + new String(kafkaRecord.value()), kafkaRecord.timestamp()), sinkName); + } + + @Override + public void punctuate(long timestamp) { + } + + @Override + public void close() { + } + + @Override + public void configChanged(Properties props) { + } + + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + @Override + public String[] sources() { + return new String[] { TOPIC_NAMES[0], TOPIC_NAMES[Constants.TWO] }; + } + + @Override + public String[] sinks() { + return new String[] { TOPIC_NAMES[1] + "-1", TOPIC_NAMES[1] + "-2" }; + } + } + + /** + * class HashMapStateTestServiceDiscoveryImpl implements StreamProcessorDiscoveryService. + */ + public static final class HashMapStateTestServiceDiscoveryImpl implements StreamProcessorDiscoveryService { + @Override + public List> discoverProcessors() { + return Arrays.asList(new StreamProcessorWithHashMapStateStore()); + } + } + + /** + * This stream processor uses hash map as a state store. + * Note that the createStateStore method is return null. + * The functionality of the class is as same as StreamProcessorWithStateStore class. + */ + @Component + public static final class StreamProcessorWithHashMapStateStore + implements StreamProcessor { + private StreamProcessingContext spc; + private KeyValueStore objectStore; + + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + objectStore = spc.getStateStore("state"); + // RTC-141484 - Kafka version upgrade from 1.0.0. to + // 2.2.0 changes + spc.schedule(Constants.THREAD_SLEEP_TIME_500, PunctuationType.WALL_CLOCK_TIME, new Punctuator() { + + public void punctuate(long timestamp) { + KeyValueIterator i = objectStore.all(); + if (i.hasNext()) { + KeyValue kv = i.next(); + spc.forward(new Record<>(kv.key, kv.value, System.currentTimeMillis())); + } + } + }); + } + + @Override + public String name() { + return "hashmap-state-proc"; + } + + @Override + public void process(Record kafkaRecord) { + objectStore.put(new String(kafkaRecord.key()), new String(kafkaRecord.value())); + } + + @Override + public void punctuate(long timestamp) { + KeyValueIterator i = objectStore.all(); + if (i.hasNext()) { + KeyValue kv = i.next(); + spc.forward(new Record<>(kv.key, kv.value, System.currentTimeMillis())); + } + } + + @Override + public void close() { + } + + @Override + public void configChanged(Properties props) { + } + + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + @Override + public String[] sinks() { + return new String[] { TOPIC_NAMES[1] }; + } + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsMaxUncaughtExceptionTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsMaxUncaughtExceptionTest.java new file mode 100644 index 0000000..6f53e32 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsMaxUncaughtExceptionTest.java @@ -0,0 +1,263 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.Metric; +import org.apache.kafka.common.MetricName; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.common.serialization.Serializer; +import org.apache.kafka.streams.KafkaStreams; +import org.apache.kafka.streams.Topology; +import org.apache.kafka.streams.processor.Processor; +import org.apache.kafka.streams.processor.ProcessorContext; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.eclipse.ecsp.stream.dma.handler.MaxFailuresUncaughtExceptionHandler; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.junit.runners.MethodSorters; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static java.util.concurrent.CompletableFuture.delayedExecutor; +import static java.util.concurrent.CompletableFuture.runAsync; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + + +/** + * class KafkaStreamsMaxUncaughtExceptionTest extends KafkaStreamsApplicationTestBase. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@EnableRuleMigrationSupport +@TestPropertySource("/stream-base-test.properties") +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class KafkaStreamsMaxUncaughtExceptionTest extends KafkaStreamsApplicationTestBase { + + public static int failureCount; + private static String sourceTopicName; + private static String sinkTopicName; + private static int i = 0; + private KafkaStreams streams; + + @Override + @Before + public void setup() throws Exception { + + super.setup(); + i++; + sourceTopicName = "sourceTopic" + i; + sinkTopicName = "sinkTopic" + i; + createTopics(sourceTopicName, sinkTopicName); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "tc-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().deserializer().getClass().getName()); + + ksProps.put("event.transformer.classes", "genericIgniteEventTransformer"); + ksProps.put("ignite.key.transformer.class", "org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + ksProps.put("ingestion.serializer.class", "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + ksProps.put("sink.topic.name", sinkTopicName); + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, PropBasedDiscoveryServiceImpl.class.getName()); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, sourceTopicName); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + + } + + @Test + public void maxExceptionHandlerForReplaceThread() throws Exception { + failureCount = Constants.TWO; + startKafkaStreams(); + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, "key1".getBytes(StandardCharsets.UTF_8), "value1" + .getBytes(StandardCharsets.UTF_8)); + Thread.sleep(Constants.INT_120000); + Map map = streams.metrics(); + + for (Map.Entry metricNameEntry : map.entrySet()) { + if (metricNameEntry.getKey().name().equalsIgnoreCase("alive-stream-threads")) { + Assert.assertEquals("4", metricNameEntry.getValue().metricValue().toString()); + } + } + Assert.assertEquals("2.0", String.valueOf(MaxFailuresUncaughtExceptionHandler.getThreadRecoveryTotal().get())); + } + + @Test + public void maxExceptionHandlerForShutDown() throws Exception { + + failureCount = Constants.THREE; + startKafkaStreams(); + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, "key1".getBytes(StandardCharsets.UTF_8), "value1" + .getBytes(StandardCharsets.UTF_8)); + runAsync(() -> {}, delayedExecutor(Constants.THREAD_SLEEP_TIME_30000, MILLISECONDS)).join(); + Map map = streams.metrics(); + + for (Map.Entry metricNameEntry : map.entrySet()) { + if (metricNameEntry.getKey().name().equalsIgnoreCase("alive-stream-threads")) { + runAsync(() -> {}, delayedExecutor(Constants.INT_80000, MILLISECONDS)).join(); + Assert.assertEquals("0", metricNameEntry.getValue().metricValue().toString()); + } + } + Assert.assertEquals("1.0", + String.valueOf(MaxFailuresUncaughtExceptionHandler.getClientShutdownTotal().get())); + } + + @Test + public void maxExceptionHandlerForExceedingMaxTimeInterval() throws Exception { + + failureCount = Constants.THREE; + startKafkaStreams(); + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, "key1".getBytes(StandardCharsets.UTF_8), "value1" + .getBytes(StandardCharsets.UTF_8)); + Thread.sleep(Constants.THREAD_SLEEP_TIME_120000); + Map map = streams.metrics(); + + for (Map.Entry metricNameEntry : map.entrySet()) { + if (metricNameEntry.getKey().name().equalsIgnoreCase("alive-stream-threads")) { + Assert.assertEquals("4", metricNameEntry.getValue().metricValue().toString()); + } + } + + } + + private void startKafkaStreams() { + ksProps.put(PropertyNames.NUM_STREAM_THREADS, Constants.FIVE); + final Serializer keySerializer = Serdes.ByteArray().serializer(); + final Serializer valueSerializer = Serdes.ByteArray().serializer(); + ExecutorService service = Executors.newSingleThreadExecutor(); + Runnable runnable = () -> { + final Topology topology = new Topology(); + + topology.addSource("source", sourceTopicName) + .addProcessor("preprocess", () -> new StreamPreProcessor(), "source") + .addProcessor("process", () -> new StreamServiceProcessor(), "preprocess") + .addSink("sink", sinkTopicName, keySerializer, valueSerializer, "process"); + + streams = new KafkaStreams(topology, ksProps); + streams.cleanUp(); + streams.setUncaughtExceptionHandler(new MaxFailuresUncaughtExceptionHandler( + Constants.THREE, Constants.THREAD_SLEEP_TIME_30000)); + streams.start(); + }; + service.execute(runnable); + } + + @After + public void destroy() { + streams.close(); + } + + /** + * inner class StreamServiceProcessor implements Processor. + */ + public static final class StreamServiceProcessor implements Processor { + private ProcessorContext spc; + + @Override + public void init(ProcessorContext context) { + this.spc = context; + } + + @Override + public void process(byte[] key, byte[] value) { + spc.forward(key, value); + } + + @Override + public void close() { + + } + + } + + /** + * inner class StreamPreProcessor implements Processor. + */ + public class StreamPreProcessor implements Processor { + private ProcessorContext spc; + + public void reset(int value) { + failureCount = value; + } + + @Override + public void init(ProcessorContext context) { + this.spc = context; + } + + @Override + public void process(byte[] key, byte[] value) { + if (failureCount > 0) { + failureCount--; + spc.forward(key, new String(value, StandardCharsets.UTF_8)); + } + spc.forward(key, value); + } + + @Override + public void close() { + + } + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsMaxUncaughtReplaceThreadTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsMaxUncaughtReplaceThreadTest.java new file mode 100644 index 0000000..6b7b80b --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsMaxUncaughtReplaceThreadTest.java @@ -0,0 +1,215 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.Metric; +import org.apache.kafka.common.MetricName; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.common.serialization.Serializer; +import org.apache.kafka.streams.KafkaStreams; +import org.apache.kafka.streams.Topology; +import org.apache.kafka.streams.processor.Processor; +import org.apache.kafka.streams.processor.ProcessorContext; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.eclipse.ecsp.stream.dma.handler.MaxFailuresUncaughtExceptionHandler; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Integration test case. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@EnableRuleMigrationSupport +@TestPropertySource("/stream-base-test.properties") +public class KafkaStreamsMaxUncaughtReplaceThreadTest extends KafkaStreamsApplicationTestBase { + + public static int failureCount; + private static String sourceTopicName; + private static String sinkTopicName; + private static int i = 0; + private KafkaStreams streams; + + @Override + @Before + public void setup() throws Exception { + + super.setup(); + i++; + sourceTopicName = "sourceTopic" + i; + sinkTopicName = "sinkTopic" + i; + createTopics(sourceTopicName, sinkTopicName); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "tc-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().deserializer().getClass().getName()); + + ksProps.put("event.transformer.classes", "genericIgniteEventTransformer"); + ksProps.put("ignite.key.transformer.class", "org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + ksProps.put("ingestion.serializer.class", "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + ksProps.put("sink.topic.name", sinkTopicName); + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, PropBasedDiscoveryServiceImpl.class.getName()); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, sourceTopicName); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + + } + + @Test + public void maxExceptionHandlerForReplaceThread() throws Exception { + failureCount = TestConstants.TWO; + startKafkaStreams(); + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, "key1".getBytes(StandardCharsets.UTF_8), "value1" + .getBytes(StandardCharsets.UTF_8)); + Thread.sleep(TestConstants.LONG_120000); + Map map = streams.metrics(); + + for (Map.Entry metricNameEntry : map.entrySet()) { + if (metricNameEntry.getKey().name().equalsIgnoreCase("alive-stream-threads")) { + Assert.assertEquals("4", metricNameEntry.getValue().metricValue().toString()); + } + } + Assert.assertEquals("2.0", String.valueOf(MaxFailuresUncaughtExceptionHandler.getThreadRecoveryTotal().get())); + } + + private void startKafkaStreams() { + ksProps.put(PropertyNames.NUM_STREAM_THREADS, TestConstants.FOUR); + final Serializer keySerializer = Serdes.ByteArray().serializer(); + final Serializer valueSerializer = Serdes.ByteArray().serializer(); + ExecutorService service = Executors.newSingleThreadExecutor(); + Runnable runnable = () -> { + final Topology topology = new Topology(); + + topology.addSource("source", sourceTopicName) + .addProcessor("preprocess", () -> new StreamPreProcessor(), "source") + .addProcessor("process", () -> new StreamServiceProcessor(), "preprocess") + .addSink("sink", sinkTopicName, keySerializer, valueSerializer, "process"); + + streams = new KafkaStreams(topology, ksProps); + streams.cleanUp(); + streams.setUncaughtExceptionHandler(new MaxFailuresUncaughtExceptionHandler(TestConstants.THREE, + TestConstants.LONG_30000)); + streams.start(); + }; + service.execute(runnable); + } + + @After + public void destroy() { + streams.close(); + } + + /** + * Test service processor. + */ + public static final class StreamServiceProcessor implements Processor { + private ProcessorContext spc; + + @Override + public void init(ProcessorContext context) { + this.spc = context; + } + + @Override + public void process(byte[] key, byte[] value) { + spc.forward(key, value); + } + + @Override + public void close() { + + } + + } + + /** + * Test pre processor. + */ + public class StreamPreProcessor implements Processor { + private ProcessorContext spc; + + public void reset(int value) { + failureCount = value; + } + + @Override + public void init(ProcessorContext context) { + this.spc = context; + } + + @Override + public void process(byte[] key, byte[] value) { + if (failureCount > 0) { + failureCount--; + spc.forward(key, new String(value, StandardCharsets.UTF_8)); + } + spc.forward(key, value); + } + + @Override + public void close() { + + } + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsMaxUncaughtShutdownTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsMaxUncaughtShutdownTest.java new file mode 100644 index 0000000..4587d4a --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/KafkaStreamsMaxUncaughtShutdownTest.java @@ -0,0 +1,281 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.Metric; +import org.apache.kafka.common.MetricName; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.common.serialization.Serializer; +import org.apache.kafka.streams.KafkaStreams; +import org.apache.kafka.streams.Topology; +import org.apache.kafka.streams.processor.Processor; +import org.apache.kafka.streams.processor.ProcessorContext; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.eclipse.ecsp.stream.dma.handler.MaxFailuresUncaughtExceptionHandler; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + + +/** + * Integration test case. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@EnableRuleMigrationSupport +@TestPropertySource("/stream-base-test.properties") +public class KafkaStreamsMaxUncaughtShutdownTest extends KafkaStreamsApplicationTestBase { + + /** The failure count. */ + public static int failureCount; + + /** The source topic name. */ + private static String sourceTopicName; + + /** The sink topic name. */ + private static String sinkTopicName; + + /** The i. */ + private static int i = 0; + + /** The streams. */ + private KafkaStreams streams; + + /** + * Setup. + * + * @throws Exception the exception + */ + @Override + @Before + public void setup() throws Exception { + + super.setup(); + i++; + sourceTopicName = "sourceTopic" + i; + sinkTopicName = "sinkTopic" + i; + createTopics(sourceTopicName, sinkTopicName); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "tc-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().deserializer().getClass().getName()); + + ksProps.put("event.transformer.classes", "genericIgniteEventTransformer"); + ksProps.put("ignite.key.transformer.class", "org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + ksProps.put("ingestion.serializer.class", "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + ksProps.put("sink.topic.name", sinkTopicName); + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, PropBasedDiscoveryServiceImpl.class.getName()); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, sourceTopicName); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + + } + + /** + * Max exception handler for shut down. + * + * @throws Exception the exception + */ + @Test + public void maxExceptionHandlerForShutDown() throws Exception { + + failureCount = TestConstants.THREE; + startKafkaStreams(); + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, "key1".getBytes(StandardCharsets.UTF_8), "value1" + .getBytes(StandardCharsets.UTF_8)); + Thread.sleep(TestConstants.LONG_30000); + Map map = streams.metrics(); + + for (Map.Entry metricNameEntry : map.entrySet()) { + if (metricNameEntry.getKey().name().equalsIgnoreCase("alive-stream-threads")) { + Thread.sleep(TestConstants.LONG_80000); + Assert.assertEquals("0", metricNameEntry.getValue().metricValue().toString()); + } + } + Assert.assertEquals("1.0", String.valueOf(MaxFailuresUncaughtExceptionHandler.getClientShutdownTotal().get())); + } + + /** + * Start kafka streams. + */ + private void startKafkaStreams() { + ksProps.put(PropertyNames.NUM_STREAM_THREADS, TestConstants.FOUR); + final Serializer keySerializer = Serdes.ByteArray().serializer(); + final Serializer valueSerializer = Serdes.ByteArray().serializer(); + ExecutorService service = Executors.newSingleThreadExecutor(); + Runnable runnable = () -> { + final Topology topology = new Topology(); + + topology.addSource("source", sourceTopicName) + .addProcessor("preprocess", () -> new StreamPreProcessor(), "source") + .addProcessor("process", () -> new StreamServiceProcessor(), "preprocess") + .addSink("sink", sinkTopicName, keySerializer, valueSerializer, "process"); + + streams = new KafkaStreams(topology, ksProps); + streams.cleanUp(); + streams.setUncaughtExceptionHandler(new MaxFailuresUncaughtExceptionHandler(TestConstants.THREE, + TestConstants.LONG_30000)); + streams.start(); + }; + service.execute(runnable); + } + + /** + * Destroy. + */ + @After + public void destroy() { + streams.close(); + } + + /** + * Test stream processor. + */ + public static final class StreamServiceProcessor implements Processor { + + /** The spc. */ + private ProcessorContext spc; + + /** + * Inits the. + * + * @param context the context + */ + @Override + public void init(ProcessorContext context) { + this.spc = context; + } + + /** + * Process. + * + * @param key the key + * @param value the value + */ + @Override + public void process(byte[] key, byte[] value) { + spc.forward(key, value); + } + + /** + * Close. + */ + @Override + public void close() { + + } + + } + + /** + * Test pre processor. + */ + public class StreamPreProcessor implements Processor { + + /** The spc. */ + private ProcessorContext spc; + + /** + * Reset. + * + * @param value the value + */ + public void reset(int value) { + failureCount = value; + } + + /** + * Inits the. + * + * @param context the context + */ + @Override + public void init(ProcessorContext context) { + this.spc = context; + } + + /** + * Process. + * + * @param key the key + * @param value the value + */ + @Override + public void process(byte[] key, byte[] value) { + if (failureCount > 0) { + failureCount--; + spc.forward(key, new String(value, StandardCharsets.UTF_8)); + } + spc.forward(key, value); + } + + /** + * Close. + */ + @Override + public void close() { + + } + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/ProcessorChainingTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/ProcessorChainingTest.java new file mode 100644 index 0000000..781f247 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/ProcessorChainingTest.java @@ -0,0 +1,597 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.List; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + + +/** + * {@link ProcessorChainingTest}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@EnableRuleMigrationSupport +@TestPropertySource("/stream-base-test.properties") +public class ProcessorChainingTest extends KafkaStreamsApplicationTestBase { + + /** The in topic name. */ + private static String inTopicName; + + /** The out topic name. */ + private static String outTopicName; + + /** The i. */ + private static int i = 0; + + /** + * Setup. + * + * @throws Exception the exception + */ + @Override + @Before + public void setup() throws Exception { + super.setup(); + i++; + inTopicName = "sourceTopic" + i; + outTopicName = "sinkTopic" + i; + createTopics(inTopicName, outTopicName); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "tc-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.String().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.String().serializer().getClass().getName()); + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, PropBasedDiscoveryServiceImpl.class.getName()); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, inTopicName); + ksProps.put(PropertyNames.APPLICATION_ID, "pt"); + + } + + /** + * Testing Processor chaining with PRE_PROCESSORS, SERVICE_STREAM_PROCESSORS and POST_PROCESSORS. + * + * @throws Exception the exception + */ + @Test + public void testProcessorChaining() throws Exception { + ksProps.put(PropertyNames.PRE_PROCESSORS, StreamPreProcessor.class.getName()); + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, StreamServiceProcessor.class.getName()); + ksProps.put(PropertyNames.POST_PROCESSORS, StreamPostProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + launchApplication(); + KafkaTestUtils.sendMessages(inTopicName, producerProps, "key1", "value1"); + List messages = KafkaTestUtils.getMessages(outTopicName, consumerProps, + 1, Constants.THREAD_SLEEP_TIME_10000); + Assert.assertEquals("key1", messages.get(0)[0]); + Assert.assertEquals("value1_StreamPreProcessor_StreamServiceProcessor_StreamPostProcessor", + messages.get(0)[1]); + + } + + /** + * Testing Processor chaining with only SERVICE_STREAM_PROCESSORS and POST_PROCESSORS. + * + * @throws Exception the exception + */ + // @Test + public void testProcessorChainingWithoutPreProcessor() throws Exception { + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, StreamServiceProcessor.class.getName()); + ksProps.put(PropertyNames.POST_PROCESSORS, StreamPostProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + launchApplication(); + KafkaTestUtils.sendMessages(inTopicName, producerProps, "key1", "value1"); + List messages = KafkaTestUtils.getMessages(outTopicName, consumerProps, + 1, Constants.THREAD_SLEEP_TIME_10000); + Assert.assertEquals("key1", messages.get(0)[0]); + Assert.assertEquals("value1_StreamServiceProcessor_StreamPostProcessor", + messages.get(0)[1]); + + } + + /** + * Testing Processor chaining with only multiple PRE_PROCESSORS, SERVICE_STREAM_PROCESSORS and POST_PROCESSORS. + * + * @throws Exception the exception + */ + @Test + public void testProcessorChainingWithMultiplePreProcessor() throws Exception { + ksProps.put(PropertyNames.PRE_PROCESSORS, StreamPreProcessor.class.getName() + "," + + StreamPreProcessor2.class.getName()); + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, StreamServiceProcessor.class.getName()); + ksProps.put(PropertyNames.POST_PROCESSORS, StreamPostProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + launchApplication(); + KafkaTestUtils.sendMessages(inTopicName, producerProps, "key1", "value1"); + List messages = KafkaTestUtils.getMessages(outTopicName, consumerProps, + 1, Constants.THREAD_SLEEP_TIME_10000); + Assert.assertEquals("key1", messages.get(0)[0]); + Assert.assertEquals("value1_StreamPreProcessor_StreamPreProcessor2_StreamServiceProcessor_StreamPostProcessor", + messages.get(0)[1]); + + } + + /** + * inner class StreamPostProcessor implements StreamProcessor. + */ + public static final class StreamPostProcessor implements StreamProcessor { + + /** The spc. */ + private StreamProcessingContext spc; + + /** + * Instantiates a new stream post processor. + */ + public StreamPostProcessor() { + + } + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + + return "StreamPostProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record kafkaRecord) { + String fwdValue = new String(kafkaRecord.value()) + "_StreamPostProcessor"; + spc.forward(new Record<>(new String(kafkaRecord.key()), fwdValue, kafkaRecord.timestamp())); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + + } + + /** + * Close. + */ + @Override + public void close() { + + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + + return null; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] { outTopicName }; + } + + } + + /** + * inner class StreamPreProcessor implements StreamProcessor. + */ + public static final class StreamPreProcessor implements StreamProcessor { + + /** The spc. */ + private StreamProcessingContext spc; + + /** + * Instantiates a new stream pre processor. + */ + public StreamPreProcessor() { + + } + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + + return "StreamPreProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record kafkaRecord) { + String fwdValue = new String(kafkaRecord.value()) + "_StreamPreProcessor"; + spc.forward(new Record<>(kafkaRecord.key(), fwdValue.getBytes(), kafkaRecord.timestamp())); + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + + } + + /** + * Close. + */ + @Override + public void close() { + + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { inTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] {}; + } + } + + /** + * inner class final class StreamServiceProcessor implements StreamProcessor. + */ + public static final class StreamPreProcessor2 implements StreamProcessor { + + /** The spc. */ + private StreamProcessingContext spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + + return "StreamPreProcessor_2"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record kafkaRecord) { + String fwdValue = new String(kafkaRecord.value()) + "_StreamPreProcessor2"; + spc.forward(new Record<>(kafkaRecord.key(), fwdValue.getBytes(), kafkaRecord.timestamp())); + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + + } + + /** + * Close. + */ + @Override + public void close() { + + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { inTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] {}; + } + } + + /** + * inner final class StreamServiceProcessor implements StreamProcessor. + */ + public static final class StreamServiceProcessor implements StreamProcessor { + + /** The spc. */ + private StreamProcessingContext spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + + return "StreamServiceProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record kafkaRecord) { + String fwdValue = new String(kafkaRecord.value()) + "_StreamServiceProcessor"; + spc.forward(new Record<>(kafkaRecord.key(), fwdValue.getBytes(), kafkaRecord.timestamp())); + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + + } + + /** + * Close. + */ + @Override + public void close() { + + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { inTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] {}; + } + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/PrometheusMetricsTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/PrometheusMetricsTest.java new file mode 100644 index 0000000..dd18506 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/PrometheusMetricsTest.java @@ -0,0 +1,889 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import io.prometheus.client.CollectorRegistry; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.eclipse.ecsp.domain.AbstractBlobEventData.Encoding; +import org.eclipse.ecsp.domain.BlobDataV1_0; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.IgniteEventSource; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.CompositeIgniteEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.transform.GenericIgniteEventTransformer; +import org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + + +/** + * {@link PrometheusMetricsTest} extends {@link KafkaStreamsApplicationTestBase}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@EnableRuleMigrationSupport +@TestPropertySource("/stream-base-test2.properties") +@Ignore("Class not ready for tests") +public class PrometheusMetricsTest extends KafkaStreamsApplicationTestBase { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(PrometheusMetricsTest.class); + + /** The Constant KEY_SER. */ + private static final IgniteKeyTransformerStringImpl KEY_SER = new IgniteKeyTransformerStringImpl(); + + /** The in topic name. */ + private static String inTopicName; + + /** The out topic name. */ + private static String outTopicName; + + /** The i. */ + private static int i = 0; + + /** The transformer. */ + @Autowired + GenericIgniteEventTransformer transformer; + /** + * Prometheus Agent Port Number. + **/ + @Value("${prometheus.agent.port:9100}") + private int prometheusExportPort; + + /** + * Send GET. + * + * @param url the url + * @return the string + * @throws IOException Signals that an I/O exception has occurred. + */ + private static String sendGET(String url) throws IOException { + URL obj = new URL(url); + HttpURLConnection con = (HttpURLConnection) obj.openConnection(); + con.setRequestMethod("GET"); + con.setRequestProperty("User-Agent", "Mozilla/5.0"); + int responseCode = con.getResponseCode(); + System.out.println("GET Response Code :: " + responseCode); + if (responseCode == HttpURLConnection.HTTP_OK) { // success + BufferedReader in = new BufferedReader(new InputStreamReader( + con.getInputStream())); + String inputLine; + StringBuffer response = new StringBuffer(); + + while ((inputLine = in.readLine()) != null) { + response.append(inputLine); + } + in.close(); + + return response.toString(); + } else { + System.out.println("GET request not worked"); + } + return null; + + } + + /** + * Setup. + * + * @throws Exception the exception + */ + @Override + @Before + public void setup() throws Exception { + super.setup(); + i++; + inTopicName = "sourceTopic" + i; + outTopicName = "sinkTopic" + i; + createTopics(inTopicName, outTopicName); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "tc-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.String().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.String().serializer().getClass().getName()); + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, PropBasedDiscoveryServiceImpl.class.getName()); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, inTopicName); + ksProps.put(PropertyNames.APPLICATION_ID, "pt"); + } + + /** + * Testing thread recovery metrics by killing a stream thread and + * checking the thread recovery count by hitting Prometheus Endpoint. + * + * @throws Exception Exception + */ + + @Test + public void maxExceptionHandlerForReplaceThread() throws Exception { + + ksProps.put(PropertyNames.PRE_PROCESSORS, StreamPreProcessor.class.getName()); + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, StreamServiceProcessor.class.getName()); + ksProps.put(PropertyNames.POST_PROCESSORS, StreamPostProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + ksProps.put(PropertyNames.NUM_STREAM_THREADS, "5"); + + launchApplication(); + ThreadGroup rootGroup = Thread.currentThread().getThreadGroup(); + ThreadGroup parentGroup; + while ((parentGroup = rootGroup.getParent()) != null) { + rootGroup = parentGroup; + } + Thread[] threads = new Thread[rootGroup.activeCount()]; + while (rootGroup.enumerate(threads, true) == threads.length) { + threads = new Thread[threads.length * Constants.TWO]; + } + + for (Thread thread : threads) { + if (thread == null) { + continue; + } + String name = thread.getName(); + if (name.endsWith("-StreamThread-4")) { + LOGGER.info("Killing thread {} ", name); + thread.stop(); + } + } + await().atMost(Constants.THREAD_SLEEP_TIME_120000, TimeUnit.MILLISECONDS); + String metricsGet = sendGET("http://localhost:" + prometheusExportPort); + String threadRecoveryMetric = metricsGet.substring(metricsGet.indexOf( + "Total number of times thread recovered")); + if (threadRecoveryMetric.indexOf("#") != Constants.NEGATIVE_ONE) { + threadRecoveryMetric = threadRecoveryMetric.substring(threadRecoveryMetric.indexOf( + "countertotal_number_of_times_thread_recovered"), threadRecoveryMetric.indexOf("# HELP")); + threadRecoveryMetric = threadRecoveryMetric.substring(threadRecoveryMetric.lastIndexOf(" ") + 1); + } + String uncaughtExceptionMetric = metricsGet.substring(metricsGet.indexOf( + "Total number of times uncaught exception occurs")); + if (uncaughtExceptionMetric.indexOf("#") != Constants.NEGATIVE_ONE) { + uncaughtExceptionMetric = uncaughtExceptionMetric.substring( + uncaughtExceptionMetric.indexOf("countertotal_number_of_times_uncaught_exception_occurs"), + uncaughtExceptionMetric.indexOf("# HELP")); + uncaughtExceptionMetric = uncaughtExceptionMetric.substring(uncaughtExceptionMetric.lastIndexOf(" ") + 1); + } + Assert.assertEquals("1.0", threadRecoveryMetric); + Assert.assertEquals("1.0", uncaughtExceptionMetric); + + } + + /** + * Testing client shut down metric by killing stream threads and + * checking the client shutdown count by hitting Prometheus Endpoint. + * + * @throws Exception Exception + */ + @Test + public void maxExceptionHandlerForShutDownClient() throws Exception { + ksProps.put(PropertyNames.PRE_PROCESSORS, StreamPreProcessor.class.getName()); + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, StreamServiceProcessor.class.getName()); + ksProps.put(PropertyNames.POST_PROCESSORS, StreamPostProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + ksProps.put(PropertyNames.NUM_STREAM_THREADS, "5"); + + launchApplication(); + ThreadGroup rootGroup = Thread.currentThread().getThreadGroup(); + ThreadGroup parentGroup; + + while ((parentGroup = rootGroup.getParent()) != null) { + rootGroup = parentGroup; + } + Thread[] threads = new Thread[rootGroup.activeCount()]; + while (rootGroup.enumerate(threads, true) == threads.length) { + threads = new Thread[threads.length * Constants.TWO]; + } + int failureCount = Constants.THREE; + for (Thread thread : threads) { + if (thread == null) { + continue; + } + String name = thread.getName(); + + if (name.contains("-StreamThread-") && name.startsWith("chaining") && failureCount != 0) { + LOGGER.info("Killing thread {} ", name); + thread.stop(); + await().atMost(Constants.THREAD_SLEEP_TIME_30000, TimeUnit.MILLISECONDS); + failureCount--; + } + } + await().atMost(Constants.THREAD_SLEEP_TIME_60000, TimeUnit.MILLISECONDS); + String metricsGet = sendGET("http://localhost:" + prometheusExportPort); + String shutdownClientMetric = metricsGet.substring(metricsGet.indexOf( + "Total number of times client shuts down")); + if (shutdownClientMetric.indexOf("#") != Constants.NEGATIVE_ONE) { + shutdownClientMetric = shutdownClientMetric.substring(shutdownClientMetric.indexOf( + "countertotal_number_of_times_client_shuts_down"), shutdownClientMetric.indexOf("# HELP")); + shutdownClientMetric = shutdownClientMetric.substring(shutdownClientMetric.lastIndexOf(" ") + 1); + } + String uncaughtExceptionMetric = metricsGet.substring(metricsGet.indexOf( + "Total number of times uncaught exception occurs")); + if (uncaughtExceptionMetric.indexOf("#") != Constants.NEGATIVE_ONE) { + uncaughtExceptionMetric = uncaughtExceptionMetric.substring( + uncaughtExceptionMetric.indexOf("countertotal_number_of_times_uncaught_exception_occurs"), + uncaughtExceptionMetric.indexOf("# HELP")); + uncaughtExceptionMetric = uncaughtExceptionMetric.substring(uncaughtExceptionMetric.lastIndexOf(" ") + 1); + } + String threadRecoveryMetric = metricsGet.substring(metricsGet.indexOf( + "Total number of times thread recovered")); + if (threadRecoveryMetric.indexOf("#") != Constants.NEGATIVE_ONE) { + threadRecoveryMetric = threadRecoveryMetric.substring(threadRecoveryMetric.indexOf( + "countertotal_number_of_times_thread_recovered"), threadRecoveryMetric.indexOf("# HELP")); + threadRecoveryMetric = threadRecoveryMetric.substring(threadRecoveryMetric.lastIndexOf(" ") + 1); + } + Assert.assertEquals("1.0", shutdownClientMetric); + Assert.assertEquals("2.0", threadRecoveryMetric); + Assert.assertEquals("3.0", uncaughtExceptionMetric); + + } + + /** + * Testing thread liveness metrics by killing a stream thread and + * checking the thread alive count by hitting Prometheus Endpoint. + * + * @throws Exception the exception + */ + @Test + public void testThreadCountAfterOneThreadKill() throws Exception { + ksProps.put(PropertyNames.PRE_PROCESSORS, StreamPreProcessor.class.getName()); + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, StreamServiceProcessor.class.getName()); + ksProps.put(PropertyNames.POST_PROCESSORS, StreamPostProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + ksProps.put(PropertyNames.NUM_STREAM_THREADS, "10"); + launchApplication(); + KafkaTestUtils.sendMessages(inTopicName, producerProps, "key", "value1"); + + ThreadGroup rootGroup = Thread.currentThread().getThreadGroup(); + ThreadGroup parentGroup; + + while ((parentGroup = rootGroup.getParent()) != null) { + rootGroup = parentGroup; + } + Thread[] threads = new Thread[rootGroup.activeCount()]; + while (rootGroup.enumerate(threads, true) == threads.length) { + threads = new Thread[threads.length * Constants.TWO]; + } + + for (Thread thread : threads) { + if (thread == null) { + continue; + } + String name = thread.getName(); + if (name.endsWith("-StreamThread-4")) { + LOGGER.info("Killing thread {} ", name); + thread.stop(); + } + } + await().atMost(Constants.THREAD_SLEEP_TIME_30000, TimeUnit.MILLISECONDS); + // Get metric and parse for thread count + String metricsGet = sendGET("http://localhost:" + prometheusExportPort); + String liveThreadMetric = metricsGet.substring(metricsGet.indexOf("thread_liveness{service")); + if (liveThreadMetric.indexOf("#") != Constants.NEGATIVE_ONE) { + liveThreadMetric = liveThreadMetric.substring(0, liveThreadMetric.indexOf("#")); + assertTrue(liveThreadMetric.endsWith("10.0")); + } else { // if this is the last metric on Prometheus + assertTrue(liveThreadMetric.endsWith("9.0")); + } + CollectorRegistry.defaultRegistry.clear(); // must clear registry + // otherwise metrics register + // in last test launch will + // conflict + shutDownApplication(); + } + + /** + * Testing Processor for service consumption metric. + * + * @throws Exception the exception + */ + @Test + public void testServiceConsumptionMetric() throws Exception { + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "tc-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, PropBasedDiscoveryServiceImpl.class.getName()); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, inTopicName); + ksProps.put(PropertyNames.APPLICATION_ID, "pt"); + + ksProps.put("event.transformer.classes", "genericIgniteEventTransformer"); + ksProps.put("ignite.key.transformer.class", "org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + ksProps.put("ingestion.serializer.class", "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + ksProps.put("sink.topic.name", outTopicName); + + ksProps.put(PropertyNames.PRE_PROCESSORS, + "org.eclipse.ecsp.analytics.stream.base.processors.TaskContextInitializer," + + "org.eclipse.ecsp.analytics.stream.base.processors.ProtocolTranslatorPreProcessor"); + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, StreamServiceProcessor.class.getName()); + ksProps.put(PropertyNames.POST_PROCESSORS, StreamPostProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + launchApplication(); + + launchApplication(); + long pushMessageTimeMs = System.currentTimeMillis() + Constants.THREAD_SLEEP_TIME_10000; + while (pushMessageTimeMs > System.currentTimeMillis()) { + IgniteStringKey igniteStringKey = new IgniteStringKey(); + igniteStringKey.setKey("dummy_key"); + + IgniteEventImpl event = getDummyIgniteBlobEvent("dummy_ID", + "req" + System.currentTimeMillis(), "dummy_vid"); + KafkaTestUtils.sendMessages(inTopicName, producerProps, + KEY_SER.toBlob(igniteStringKey), transformer.toBlob(event)); + } + + IgniteEventImpl event = getDummyIgniteBlobEvent("dummy_ID", "req" + System.currentTimeMillis(), "dummy_vid"); + List nestedEvents = new ArrayList<>(); + nestedEvents.add(event); + CompositeIgniteEvent event2 = new CompositeIgniteEvent(); + event2.setEventId(EventID.COMPOSITE_EVENT); + event2.setNestedEvents(nestedEvents); + + KafkaTestUtils.sendMessages(inTopicName, producerProps, + KEY_SER.toBlob(new IgniteStringKey("dummy_id")), transformer.toBlob(event2)); + + List messages = KafkaTestUtils.getMessages(outTopicName, + consumerProps, Constants.THREE, Constants.THREAD_SLEEP_TIME_5000); + + String metricsGet = sendGET("http://localhost:" + prometheusExportPort); + String liveThreadMetric = metricsGet.substring(metricsGet.indexOf("service_data_consumption_count{svc=")); + liveThreadMetric = liveThreadMetric.substring(0, liveThreadMetric.indexOf("service_data_consumption_sum{svc=")); + assertNotNull(liveThreadMetric); // assert metric is created. + CollectorRegistry.defaultRegistry.clear(); // must clear registry + // otherwise metrics register + // in last test launch will + // conflict + shutDownApplication(); + } + + /** + * Gets the dummy ignite blob event. + * + * @param deviceID the device ID + * @param requestId the request id + * @param vehicleId the vehicle id + * @return the dummy ignite blob event + */ + private IgniteEventImpl getDummyIgniteBlobEvent(String deviceID, String requestId, String vehicleId) { + + IgniteEventImpl igniteBlobEvent = new IgniteEventImpl(); + igniteBlobEvent.setSourceDeviceId(deviceID); + igniteBlobEvent.setEventId(EventID.BLOBDATA); + igniteBlobEvent.setRequestId(requestId); + igniteBlobEvent.setSchemaVersion(Version.V1_0); + igniteBlobEvent.setTimestamp(System.currentTimeMillis()); + igniteBlobEvent.setVehicleId(vehicleId); + igniteBlobEvent.setVersion(Version.V1_0); + BlobDataV1_0 eventData = new BlobDataV1_0(); + eventData.setEncoding(Encoding.JSON); + eventData.setEventSource(IgniteEventSource.IGNITE); + String speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":20.0}}"; + eventData.setPayload(speedEvent.getBytes()); + igniteBlobEvent.setEventData(eventData); + return igniteBlobEvent; + } + + /** + * inner class StreamPostProcessor implements StreamProcessor. + */ + public static final class StreamPostProcessor implements StreamProcessor { + + /** The spc. */ + private StreamProcessingContext spc; + + /** + * Instantiates a new stream post processor. + */ + public StreamPostProcessor() { + + } + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + + return "StreamPostProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record kafkaRecord) { + byte[] value = kafkaRecord.value(); + String fwdValue = new String(value) + "_StreamPostProcessor"; + spc.forward(new Record(new String(kafkaRecord.key()), fwdValue, kafkaRecord.timestamp())); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + + } + + /** + * Close. + */ + @Override + public void close() { + + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + + return null; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] { outTopicName }; + } + + } + + /** + * inner class class StreamPreProcessor implements StreamProcessor. + */ + public static final class StreamPreProcessor implements StreamProcessor { + + /** The spc. */ + private StreamProcessingContext spc; + + /** + * Instantiates a new stream pre processor. + */ + public StreamPreProcessor() { + + } + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + + return "StreamPreProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record kafkaRecord) { + String fwdValue = new String(kafkaRecord.value()) + "_StreamPreProcessor"; + spc.forward(new Record(kafkaRecord.key(), fwdValue.getBytes(), kafkaRecord.timestamp())); + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + + } + + /** + * Close. + */ + @Override + public void close() { + + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { inTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] {}; + } + } + + /** + * inner class StreamPreProcessor2 implements StreamProcessor. + */ + public static final class StreamPreProcessor2 implements StreamProcessor { + + /** The spc. */ + private StreamProcessingContext spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + + return "StreamPreProcessor_2"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record kafkaRecord) { + String fwdValue = new String(kafkaRecord.value()) + "_StreamPreProcessor2"; + spc.forward(new Record(kafkaRecord.key(), fwdValue.getBytes(), kafkaRecord.timestamp())); + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + + } + + /** + * Close. + */ + @Override + public void close() { + + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { inTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] {}; + } + } + + /** + * class StreamServiceProcessor implements StreamProcessor. + */ + public static final class StreamServiceProcessor implements StreamProcessor { + + /** The spc. */ + private StreamProcessingContext spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + + return "StreamServiceProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record kafkaRecord) { + String fwdValue = new String(kafkaRecord.value()) + "_StreamServiceProcessor"; + spc.forward(new Record(kafkaRecord.key(), fwdValue.getBytes(), kafkaRecord.timestamp())); + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + + } + + /** + * Close. + */ + @Override + public void close() { + + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { inTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] {}; + } + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/SimplePropertiesLoaderTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/SimplePropertiesLoaderTest.java new file mode 100644 index 0000000..cd5b971 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/SimplePropertiesLoaderTest.java @@ -0,0 +1,133 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.eclipse.ecsp.analytics.stream.base.SimplePropertiesLoader; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.util.Properties; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + + +/** + * {@link SimplePropertiesLoaderTest} UT class for {@link SimplePropertiesLoader}. + */ +public class SimplePropertiesLoaderTest { + + /** The Constant CLASSPATH_RESOUCE_NAME. */ + private static final String CLASSPATH_RESOUCE_NAME = "application-base-test.properties"; + + /** The Constant INVALID_RESOUCE_NAME. */ + private static final String INVALID_RESOUCE_NAME = "unknown"; + + /** The Constant LOCALHOST_MQTT. */ + private static final String LOCALHOST_MQTT = "tcp://127.0.0.1:1883"; + + /** The Constant MQTT_BROKER_URL. */ + private static final String MQTT_BROKER_URL = "mqtt.broker.url"; + + /** The file path under filesystem. */ + private final String FILE_PATH_UNDER_FILESYSTEM = + getClass().getClassLoader().getResource(CLASSPATH_RESOUCE_NAME).getPath(); + + /** The simple properties loader. */ + private SimplePropertiesLoader simplePropertiesLoader; + + /** + * Sets the up. + * + * @throws Exception the exception + */ + @Before + public void setUp() throws Exception { + simplePropertiesLoader = new SimplePropertiesLoader(); + } + + /** + * Tear down. + * + * @throws Exception the exception + */ + @After + public void tearDown() throws Exception { + simplePropertiesLoader = null; + } + + /** + * if source is not a valid file name then it will throw IllegalArgumentException. + * + * @throws IOException IOException + */ + @Test(expected = IllegalArgumentException.class) + public void testLoadPropertiesForInvalidSource() throws IOException { + simplePropertiesLoader.loadProperties(INVALID_RESOUCE_NAME); + } + + /** + * Only loading filesystem file. + * + * @throws IOException IOException + */ + @Test + public void testLoadPropertiesForVvalidSourceInFileSystem() throws IOException { + Properties props = simplePropertiesLoader.loadProperties(FILE_PATH_UNDER_FILESYSTEM); + assertNotNull(props); + assertEquals(LOCALHOST_MQTT, props.getProperty(MQTT_BROKER_URL)); + } + + /** + * Null check It should load only system property and environment variable. + * + * @throws IOException IOException + */ + @Test + public void testLoadPropertiesForSorceIsNull() throws IOException { + Properties props = simplePropertiesLoader.loadProperties(null); + assertNotNull(props); + assertTrue(props.size() > 0); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessorFilterTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessorFilterTest.java new file mode 100644 index 0000000..bdf28d2 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/StreamProcessorFilterTest.java @@ -0,0 +1,611 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessorFilter; +import org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.junit.Assert; +import org.junit.Before; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.junit.runners.MethodSorters; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.List; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertEquals; + + +/** + * unit tests for {@link StreamProcessorFilter}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@EnableRuleMigrationSupport +@TestPropertySource("/stream-base-test2.properties") +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class StreamProcessorFilterTest extends KafkaStreamsApplicationTestBase { + + /** The in topic name. */ + private static String inTopicName; + + /** The out topic name. */ + private static String outTopicName; + + /** The i. */ + private static int i = 0; + + /** + * Setup. + * + * @throws Exception the exception + */ + @Override + @Before + public void setup() throws Exception { + super.setup(); + i++; + inTopicName = "sourceTopic" + i; + outTopicName = "sinkTopic" + i; + createTopics(inTopicName, outTopicName); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "tc-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.String().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.String().serializer().getClass().getName()); + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, PropBasedDiscoveryServiceImpl.class.getName()); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, inTopicName); + ksProps.put(PropertyNames.APPLICATION_ID, "pt"); + + } + + /** + * Testing Processor chaining with PRE_PROCESSORS and POST_PROCESSORS as included in the chain. + * + * @throws Exception the exception + */ + @Test + public void testStreamProcessorFilterAsEnabled() throws Exception { + ksProps.put(PropertyNames.PRE_PROCESSORS, StreamPreProcessorEnabled.class.getName()); + ksProps.put(PropertyNames.POST_PROCESSORS, StreamPostProcessorEnabled.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + launchApplication(); + KafkaTestUtils.sendMessages(inTopicName, producerProps, "key1", "value1"); + List messages = KafkaTestUtils.getMessages(outTopicName, consumerProps, 1, + Constants.THREAD_SLEEP_TIME_10000); + Assert.assertEquals("key1", messages.get(0)[0]); + Assert.assertEquals("value1_StreamPreProcessor_StreamPostProcessor", + messages.get(0)[1]); + } + + /** + * Testing Processor chaining with PRE_PROCESSORS and POST_PROCESSORS as not included in the chain. + * + * @throws Exception the exception + */ + @Test + public void testStreamProcessorFilterAsNotEnabled() throws Exception { + ksProps.put(PropertyNames.PRE_PROCESSORS, StreamPreProcessorDisabled.class.getName()); + ksProps.put(PropertyNames.POST_PROCESSORS, StreamPostProcessorDisabled.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + launchApplication(); + KafkaTestUtils.sendMessages(inTopicName, producerProps, "key1", "value1"); + List messages = KafkaTestUtils.getMessages(outTopicName, consumerProps, 1, + Constants.THREAD_SLEEP_TIME_10000); + assertEquals(0, messages.size()); + } + + /** + * StreamPreProcessorEnabled class implements {@link StreamProcessorFilter}. + **/ + public static final class StreamPreProcessorEnabled implements + StreamProcessor, StreamProcessorFilter { + + /** The spc. */ + private StreamProcessingContext spc; + + /** + * Instantiates a new stream pre processor enabled. + */ + public StreamPreProcessorEnabled() { + + } + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "StreamPreProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record kafkaRecord) { + String fwdValue = new String(kafkaRecord.value()) + "_StreamPreProcessor"; + spc.forward(new Record(kafkaRecord.key(), fwdValue.getBytes(), kafkaRecord.timestamp())); + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + } + + /** + * Close. + */ + @Override + public void close() { + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { inTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] {}; + } + + /** + * returns if current stream processor is enabled or not. + * + * @param props properties + * @return true + */ + @Override + public boolean includeInProcessorChain(Properties props) { + return true; + } + } + + /** + * StreamPostProcessorEnabled implements {@link StreamProcessorFilter}. + **/ + public static final class StreamPostProcessorEnabled implements + StreamProcessor, StreamProcessorFilter { + + /** The spc. */ + private StreamProcessingContext spc; + + /** + * Instantiates a new stream post processor enabled. + */ + public StreamPostProcessorEnabled() { + + } + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "StreamPostProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record kafkaRecord) { + String fwdValue = new String(kafkaRecord.value()) + "_StreamPostProcessor"; + spc.forward(new Record(new String(kafkaRecord.key()), fwdValue, kafkaRecord.timestamp())); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + } + + /** + * Close. + */ + @Override + public void close() { + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] { outTopicName }; + } + + /** + * returns if current stream processor is enabled or not. + * + * @param props properties + * @return true + */ + @Override + public boolean includeInProcessorChain(Properties props) { + return true; + } + } + + /** + * StreamPreProcessorDisabled implements {@link StreamProcessor}. + **/ + public static final class StreamPreProcessorDisabled + implements StreamProcessor, StreamProcessorFilter { + + /** The spc. */ + private StreamProcessingContext spc; + + /** + * Instantiates a new stream pre processor disabled. + */ + public StreamPreProcessorDisabled() { + + } + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "StreamPreProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record kafkaRecord) { + String fwdValue = new String(kafkaRecord.value()) + "_StreamPreProcessor"; + spc.forward(new Record(kafkaRecord.key(), fwdValue.getBytes(), kafkaRecord.timestamp())); + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + } + + /** + * Close. + */ + @Override + public void close() { + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { inTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] {}; + } + + /** + * returns if current stream processor is enabled or not. + * + * @param props properties + * @return false; + */ + @Override + public boolean includeInProcessorChain(Properties props) { + return false; + } + } + + /** + * StreamPostProcessorDisabled implements {@link StreamProcessor}. + **/ + public static final class StreamPostProcessorDisabled + implements StreamProcessor, StreamProcessorFilter { + + /** The spc. */ + private StreamProcessingContext spc; + + /** + * Instantiates a new stream post processor disabled. + */ + public StreamPostProcessorDisabled() { + + } + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "StreamPostProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record kafkaRecord) { + String fwdValue = new String(kafkaRecord.value()) + "_StreamPostProcessor"; + spc.forward(new Record(new String(kafkaRecord.key()), fwdValue, kafkaRecord.timestamp())); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + } + + /** + * Close. + */ + @Override + public void close() { + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] { outTopicName }; + } + + /** + * returns if current stream processor is enabled or not. + * + * @param props properties + * @return false + */ + @Override + public boolean includeInProcessorChain(Properties props) { + return false; + } + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/TestKryo.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/TestKryo.java new file mode 100644 index 0000000..a2e0ac3 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/TestKryo.java @@ -0,0 +1,113 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; +import com.esotericsoftware.kryo.serializers.CompatibleFieldSerializer; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; + + +/** + * TestKryo class. + */ +public class TestKryo { + + /** + * Instantiates a new test kryo. + */ + private TestKryo() { + } + + /** + * main method. + * + * @param args args + */ + public static void main(String[] args) { + Kryo k = new Kryo(); + k.setDefaultSerializer(CompatibleFieldSerializer.class); + k.setCopyReferences(false); + // Output output = new Output(1024, -1); + // kryo.writeClassAndObject(output, data); + // return output.toBytes(); + + Name n = null; + ByteArrayOutputStream baos = new ByteArrayOutputStream(1 * Constants.BYTE_1024 * Constants.BYTE_1024); + Output output = new Output(baos); + k.writeClassAndObject(output, n); + output.close(); + byte[] bytes = baos.toByteArray(); + System.out.println("Null object"); + System.out.println(bytes.length); + System.out.println(bytes[0]); + Input input = new Input(new ByteArrayInputStream(bytes)); + Object o = k.readClassAndObject(input); + input.close(); + System.out.println(o); + n = new Name(); + n.name = "Fido"; + baos = new ByteArrayOutputStream(1 * Constants.BYTE_1024 * Constants.BYTE_1024); + output = new Output(baos); + k.writeClassAndObject(output, n); + output.close(); + bytes = baos.toByteArray(); + System.out.println("Not null object"); + System.out.println(bytes.length); + System.out.println(bytes[0]); + input = new Input(new ByteArrayInputStream(bytes)); + o = k.readClassAndObject(input); + input.close(); + System.out.println(o); + } + + /** + * inner class Name. + */ + public static class Name { + + /** The name. */ + public String name; + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/ThreadLocalTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/ThreadLocalTest.java new file mode 100644 index 0000000..f9292c1 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/ThreadLocalTest.java @@ -0,0 +1,122 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base; + + +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.threadlocal.ContextKey; +import org.eclipse.ecsp.analytics.stream.threadlocal.TaskContextHandler; +import org.junit.Assert; +import org.junit.Test; + +import java.util.concurrent.TimeUnit; + +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + + + +/** + * {@link ThreadLocalTest} UT class for {@link ThreadLocal}. + */ +public class ThreadLocalTest { + + /** + * Test thread local. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testThreadLocal() throws InterruptedException { + String value1 = "ThreadTopic1"; + String value2 = "ThreadTopic2"; + ThreadClassImpl threadOne = new ThreadClassImpl(value1); + threadOne.start(); + + ThreadClassImpl threadTwo = new ThreadClassImpl(value2); + threadTwo.start(); + await().atMost(TestConstants.THREAD_SLEEP_TIME_1000, TimeUnit.MILLISECONDS); + Assert.assertEquals(value1, threadOne.getValue()); + Assert.assertEquals(value2, threadTwo.getValue()); + } + + /** + * inner class {@link ThreadClassImpl} extends {@link Thread}. + */ + public class ThreadClassImpl extends Thread { + + /** The handler. */ + private TaskContextHandler handler = TaskContextHandler.getTaskContextHandler(); + + /** The value stored. */ + private String valueStored; + + /** The value. */ + private String value; + + /** + * Instantiates a new thread class impl. + * + * @param value the value + */ + public ThreadClassImpl(String value) { + this.value = value; + } + + /** + * Run. + */ + @Override + public void run() { + handler.setValue("Task", ContextKey.KAFKA_SINK_TOPIC, this.value); + valueStored = handler.getValue("Task", ContextKey.KAFKA_SINK_TOPIC).get() + ""; + } + + /** + * Gets the value. + * + * @return the value + */ + public String getValue() { + return valueStored; + } + + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/constants/TestConstants.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/constants/TestConstants.java new file mode 100644 index 0000000..b1d1300 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/constants/TestConstants.java @@ -0,0 +1,261 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.constants; + + +/** + * TestConstants class: constants class for tests. + */ +public class TestConstants { + + /** The Constant INT_30. */ + public static final int INT_30 = 30; + + /** The Constant INT_60. */ + public static final int INT_60 = 60; + + /** The Constant INT_499. */ + public static final int INT_499 = 499; + + /** The Constant INT_500. */ + public static final int INT_500 = 500; + + /** The Constant INT_1000. */ + public static final int INT_1000 = 1000; + + /** The Constant THREAD_SLEEP_TIME_60000. */ + public static final long THREAD_SLEEP_TIME_60000 = 60000L; + + /** The Constant THREAD_SLEEP_TIME_20000. */ + public static final long THREAD_SLEEP_TIME_20000 = 20000L; + + /** The Constant THREAD_SLEEP_TIME_6000. */ + public static final long THREAD_SLEEP_TIME_6000 = 6000L; + + /** The Constant THREAD_SLEEP_TIME_5000. */ + public static final long THREAD_SLEEP_TIME_5000 = 5000L; + + /** The Constant THREAD_SLEEP_TIME_4000. */ + public static final long THREAD_SLEEP_TIME_4000 = 4000L; + + /** The Constant THREAD_SLEEP_TIME_40000. */ + public static final long THREAD_SLEEP_TIME_40000 = 40000L; + + /** The Constant THREAD_SLEEP_TIME_3000. */ + public static final long THREAD_SLEEP_TIME_3000 = 3000L; + + /** The Constant THREAD_SLEEP_TIME_900. */ + public static final long THREAD_SLEEP_TIME_900 = 900L; + + /** The Constant THREAD_SLEEP_TIME_2500. */ + public static final long THREAD_SLEEP_TIME_2500 = 2500L; + + /** The Constant THREAD_SLEEP_TIME_2000. */ + public static final long THREAD_SLEEP_TIME_2000 = 2000L; + + /** The Constant THREAD_SLEEP_TIME_1500. */ + public static final long THREAD_SLEEP_TIME_1500 = 1500L; + + /** The Constant THREAD_SLEEP_TIME_15000. */ + public static final long THREAD_SLEEP_TIME_15000 = 15000L; + + /** The Constant THREAD_SLEEP_TIME_1000. */ + public static final long THREAD_SLEEP_TIME_1000 = 1000L; + + /** The Constant THREAD_SLEEP_TIME_500. */ + public static final long THREAD_SLEEP_TIME_500 = 500L; + + /** The Constant THREAD_SLEEP_TIME_400. */ + public static final long THREAD_SLEEP_TIME_400 = 400L; + + /** The Constant THREAD_SLEEP_TIME_523. */ + public static final long THREAD_SLEEP_TIME_523 = 523L; + + /** The Constant THREAD_SLEEP_TIME_423. */ + public static final long THREAD_SLEEP_TIME_423 = 423L; + + /** The Constant THREAD_SLEEP_TIME_323. */ + public static final long THREAD_SLEEP_TIME_323 = 323L; + + /** The Constant THREAD_SLEEP_TIME_223. */ + public static final long THREAD_SLEEP_TIME_223 = 223L; + + /** The Constant THREAD_SLEEP_TIME_200. */ + public static final long THREAD_SLEEP_TIME_200 = 200L; + + /** The Constant THREAD_SLEEP_TIME_300. */ + public static final long THREAD_SLEEP_TIME_300 = 300L; + + /** The Constant THREAD_SLEEP_TIME_123. */ + public static final long THREAD_SLEEP_TIME_123 = 123L; + + /** The Constant THREAD_SLEEP_TIME_100. */ + public static final long THREAD_SLEEP_TIME_100 = 100L; + + /** The Constant THREAD_SLEEP_TIME_10000. */ + public static final long THREAD_SLEEP_TIME_10000 = 10000L; + + /** The Constant THREAD_SLEEP_TIME_13000. */ + public static final long THREAD_SLEEP_TIME_13000 = 13000L; + + /** The Constant LONG_120000. */ + public static final long LONG_120000 = 120000L; + + /** The Constant HUNDRED_THOUSAND. */ + public static final long HUNDRED_THOUSAND = 100000L; + + /** The Constant LONG_80000. */ + public static final long LONG_80000 = 80000L; + + /** The Constant THIRTEEN. */ + public static final int THIRTEEN = 13; + + /** The Constant TWELVE. */ + public static final int TWELVE = 12; + + /** The Constant THREAD_SLEEP_TIME_10. */ + public static final long THREAD_SLEEP_TIME_10 = 10L; + + /** The Constant THREAD_SLEEP_TIME_1100. */ + public static final long THREAD_SLEEP_TIME_1100 = 1100L; + + /** The Constant HOST. */ + public static final int HOST = 1234; + + /** The Constant THREAD_SLEEP_TIME_50. */ + public static final int THREAD_SLEEP_TIME_50 = 50; + + /** The Constant PORT_1969. */ + public static final int PORT_1969 = 1969; + + /** The Constant TWO_TWO_THREE. */ + public static final long TWO_TWO_THREE = 223L; + + /** The Constant THREE_TWO_THREE. */ + public static final long THREE_TWO_THREE = 323L; + + /** The Constant FIFTY_THOUSAND. */ + public static final int FIFTY_THOUSAND = 50000; + + /** The Constant ONE_BILLION. */ + public static final long ONE_BILLION = 1000000000L; + + /** The Constant TEN_THOUSAND. */ + public static final int TEN_THOUSAND = 10000; + + /** The Constant INT_60000. */ + public static final int INT_60000 = 60000; + + /** The Constant PORT. */ + public static final int PORT = 1883; + + /** The Constant THOUSAND. */ + public static final int THOUSAND = 1000; + + /** The Constant ONE. */ + public static final int ONE = 1; + + /** The Constant TWO. */ + public static final int TWO = 2; + + /** The Constant THREE. */ + public static final int THREE = 3; + + /** The Constant FOUR. */ + public static final int FOUR = 4; + + /** The Constant FIVE. */ + public static final int FIVE = 5; + + /** The Constant TWENTY. */ + public static final int TWENTY = 20; + + /** The Constant SEVEN. */ + public static final int SEVEN = 7; + + /** The Constant THIRTY. */ + public static final int THIRTY = 30; + + /** The Constant TWENTY_FIVE. */ + public static final long TWENTY_FIVE = 25L; + + /** The Constant HUNDRED_DOUBLE. */ + public static final double HUNDRED_DOUBLE = 100.00; + + /** The Constant INT_1343678902. */ + public static final int INT_1343678902 = 1343678902; + + /** The Constant LONG_1024. */ + public static final long LONG_1024 = 1024; + + /** The Constant INT_1024. */ + public static final int INT_1024 = 1024; + + /** The Constant LONG_12345. */ + public static final long LONG_12345 = 12345L; + + /** The Constant LONG_99. */ + public static final long LONG_99 = 99L; + + /** The Constant LONG_11000. */ + public static final long LONG_11000 = 11000L; + + /** The Constant INT_MINUS_ONE. */ + public static final int INT_MINUS_ONE = -1; + + /** The Constant LONG_30000. */ + public static final long LONG_30000 = 30000; + + /** The Constant DOUBLE_TWENTY. */ + public static final double DOUBLE_TWENTY = 20.0; + + /** The Constant LONG_90. */ + public static final long LONG_90 = 90L; + + /** The Constant LONG_50. */ + public static final long LONG_50 = 50L; + + /** + * Instantiates a new test constants. + */ + private TestConstants() { + + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/context/StreamBaseSpringContextTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/context/StreamBaseSpringContextTest.java new file mode 100644 index 0000000..11c6bc6 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/context/StreamBaseSpringContextTest.java @@ -0,0 +1,97 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.context; + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.context.StreamBaseSpringContext; +import org.eclipse.ecsp.analytics.stream.base.metrics.reporter.HarmanRocksDBMetricsExporter; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.util.ReflectionTestUtils; + + +/** + * class {@link StreamBaseSpringContextTest} extends {@link KafkaStreamsApplicationTestBase}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@TestPropertySource("/stream-base-test.properties") +public class StreamBaseSpringContextTest extends KafkaStreamsApplicationTestBase { + + /** The spring ctx. */ + @Autowired + private StreamBaseSpringContext springCtx; + + /** + * Setup. + * + * @throws Exception the exception + */ + @Before + public void setup() throws Exception { + super.setup(); + } + + /** + * Test get bean. + */ + @Test + public void testGetBean() { + HarmanRocksDBMetricsExporter exporter = StreamBaseSpringContext.getBean(HarmanRocksDBMetricsExporter.class); + Assert.assertNotNull(exporter); + } + + /** + * Test set application context. + */ + @Test + public void testSetApplicationContext() { + ApplicationContext ctx = (ApplicationContext) ReflectionTestUtils.getField(springCtx, "context"); + Assert.assertNotNull(ctx); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/KafkaSinkNodeTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/KafkaSinkNodeTest.java new file mode 100644 index 0000000..8c10c13 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/KafkaSinkNodeTest.java @@ -0,0 +1,183 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.dao.impl; + +import org.apache.kafka.clients.CommonClientConfigs; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.Serdes; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.SimplePropertiesLoader; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.List; +import java.util.Properties; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + + +/** + * Test class to verify the functionalities of KafkaSinkNodeTest class. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@EnableRuleMigrationSupport +@TestPropertySource("/integration-test-application.properties") +/*@org.junit.experimental.categories.Category(NightlyBuildTestCase.class)*/ +public class KafkaSinkNodeTest extends KafkaStreamsApplicationTestBase { + + /** The Constant SOURCE_TOPIC_NAME. */ + private static final String SOURCE_TOPIC_NAME = "KafkaSinkNodeTestSourceTopic"; + + /** The Constant MESSAGE_KEY. */ + private static final String MESSAGE_KEY = "testKey"; + + /** The Constant PAYLOAD. */ + private static final String PAYLOAD = "testMessage"; + + /** The kafka sink node. */ + private final KafkaSinkNode kafkaSinkNode = new KafkaSinkNode(); + + /** The properties. */ + private Properties properties; + + /** + * setup(). + * + * @throws Exception Exception + */ + @Before + public void setup() throws Exception { + super.setup(); + createTopics(SOURCE_TOPIC_NAME); + + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "ksn-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + + SimplePropertiesLoader spl = new SimplePropertiesLoader(); + properties = spl.loadProperties(getClass().getClassLoader() + .getResource("application-base-test.properties").getPath()); + properties.put(PropertyNames.BOOTSTRAP_SERVERS, + KAFKA_CLUSTER.bootstrapServers()); + properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + } + + /** + * Publising message to kafka and consume the same. + * + * @throws TimeoutException TimeoutException + * @throws InterruptedException InterruptedException + */ + @Test + public void testPut() throws TimeoutException, InterruptedException { + properties.put(PropertyNames.KAFKA_DEVICE_EVENTS_ASYNC_PUTS, "true"); + kafkaSinkNode.init(properties); + kafkaSinkNode.put(MESSAGE_KEY.getBytes(), PAYLOAD.getBytes(), SOURCE_TOPIC_NAME, ""); + kafkaSinkNode.flush(); + List allMessages = KafkaTestUtils.getMessages(SOURCE_TOPIC_NAME, + consumerProps, 1, Constants.THREAD_SLEEP_TIME_1000); + boolean flag = false; + + for (String[] keyMessage : allMessages) { + if (keyMessage.length == Constants.TWO) { + if (MESSAGE_KEY.equals(keyMessage[0]) + && PAYLOAD.equals(keyMessage[1])) { + flag = true; + break; + } + } + } + assertTrue("Dint get message which is sent", flag); + } + + /** + * Publising message to kafka usin when ssl enabled and consume the same. + */ + public void testInitWithSSLEnabled() { + String password = "password"; + String keystore = "src/test/resources/kafka.client.keystore.jks"; + String truststore = "src/test/resources/kafka.client.truststore.jks"; + String sslClientAuth = "required"; + properties.put(PropertyNames.KAFKA_CLIENT_KEYSTORE, keystore); + properties.put(PropertyNames.KAFKA_CLIENT_KEYSTORE_PASSWORD, password); + properties.put(PropertyNames.KAFKA_CLIENT_KEY_PASSWORD, password); + properties.put(PropertyNames.KAFKA_CLIENT_TRUSTSTORE, truststore); + properties.put(PropertyNames.KAFKA_CLIENT_TRUSTSTORE_PASSWORD, password); + properties.put(PropertyNames.KAFKA_SSL_CLIENT_AUTH, sslClientAuth); + properties.put(PropertyNames.KAFKA_SSL_ENABLE, true); + kafkaSinkNode.init(properties); + assertEquals("SSL", properties.getProperty(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG), + "Expected protocol set to SSL"); + } + + + /** + * Clean. + */ + @After + public void clean() { + kafkaSinkNode.close(); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/MockKafkaPartitioner.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/MockKafkaPartitioner.java new file mode 100644 index 0000000..86fba5b --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/MockKafkaPartitioner.java @@ -0,0 +1,48 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.dao.impl; + +/** + * Test class to verify the functionalities of KafkaSinkNodeTest class. + */ + +public class MockKafkaPartitioner { + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/MongoSinkNodeTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/MongoSinkNodeTest.java new file mode 100644 index 0000000..556af50 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/dao/impl/MongoSinkNodeTest.java @@ -0,0 +1,103 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.dao.impl; + + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; + +import java.util.Properties; +import java.util.concurrent.TimeoutException; + + +/** + * Test class to verify the functionalities of KafkaSinkNodeTest class. + */ +//@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@EnableRuleMigrationSupport +@TestPropertySource("/integration-test-application.properties") +public class MongoSinkNodeTest extends KafkaStreamsApplicationTestBase { + + /** The mongo sink node. */ + private final MongoSinkNode mongoSinkNode = new MongoSinkNode(); + + /** The properties. */ + private Properties properties = new Properties(); + + /** + * setup(). + * + * @throws Exception Exception + */ + @Before + public void setup() throws Exception { + properties.setProperty(PropertyNames.MONGODB_URL, "localhost"); + properties.setProperty(PropertyNames.MONGODB_PORT, "12345"); + properties.setProperty(PropertyNames.MONGODB_AUTH_USERNAME, "ADMIN"); + properties.setProperty(PropertyNames.MONGODB_AUTH_PSWD, "password"); + properties.setProperty(PropertyNames.MONGODB_AUTH_DB, "ADMIN"); + properties.setProperty(PropertyNames.MONGODB_DBNAME, "ADMIN"); + properties.setProperty(PropertyNames.MONGODB_POOL_MAX_SIZE, "50"); + properties.setProperty(PropertyNames.MONGO_CLIENT_MAX_WAIT_TIME_MS, "30000"); + properties.setProperty(PropertyNames.MONGO_CLIENT_CONNECTION_TIMEOUT_MS, "20000"); + properties.setProperty(PropertyNames.MONGO_CLIENT_SOCKET_TIMEOUT_MS, "60000"); + ConnectionException exp = new ConnectionException("Exception in connection"); + } + + /** + * Publising message to kafka and consume the same. + */ + @Test + public void testInit() { + mongoSinkNode.init(properties); + mongoSinkNode.get("a", "b", "c"); + mongoSinkNode.put("a", "b", "c", "d"); + Assertions.assertDoesNotThrow(() -> mongoSinkNode.deleteSingleRecord("a", "b")); + } +} + diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/healthcheck/KafkaTopicsMonitorTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/healthcheck/KafkaTopicsMonitorTest.java new file mode 100644 index 0000000..762925c --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/healthcheck/KafkaTopicsMonitorTest.java @@ -0,0 +1,244 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.healthcheck; + +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.DescribeTopicsResult; +import org.apache.kafka.clients.admin.TopicDescription; +import org.apache.kafka.common.KafkaFuture; +import org.apache.kafka.common.Node; +import org.apache.kafka.common.TopicPartitionInfo; +import org.eclipse.ecsp.analytics.stream.base.KafkaSslConfig; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/** + * UT class {@link KafkaTopicsMonitorTest}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = {KafkaTopicsHealthMonitor.class, KafkaSslConfig.class}) +@TestPropertySource("/topics-health-monitor-test.properties") +public class KafkaTopicsMonitorTest { + + /** The topic. */ + private final String topic = "health"; + + /** The boot strap server. */ + @Value("${" + PropertyNames.BOOTSTRAP_SERVERS + ":localhost:9092}") + private String bootStrapServer; + + + + /** The ctx. */ + @Autowired + ApplicationContext ctx; + + /** The spc. */ + @Mock + StreamProcessingContext spc; + + /** The kafa topics monitor. */ + @InjectMocks + private KafkaTopicsHealthMonitor kafaTopicsMonitor; + + /** The props. */ + private Properties props; + + /** The describe topics result. */ + @Mock + private DescribeTopicsResult describeTopicsResult; + + /** The describe topics result failure. */ + @Mock + private DescribeTopicsResult describeTopicsResultFailure; + + /** The admin. */ + @Mock + private AdminClient admin; + + /** + * setup(). + * + * @throws Exception Exception + */ + @Before + public void setup() throws Exception { + MockitoAnnotations.initMocks(this); + props = new Properties(); + props.put(PropertyNames.BOOTSTRAP_SERVERS, bootStrapServer); + Set topics = new HashSet<>(); + topics.add(topic); + ReflectionTestUtils.setField(kafaTopicsMonitor, "topics", topics); + + Map topicConfig = new HashMap<>(); + KafkaTopicsHealthMonitor monitor = ctx.getBean(KafkaTopicsHealthMonitor.class); + topicConfig = ReflectionTestUtils.invokeMethod(monitor, "getTopicsConfig", new Object[0]); + ReflectionTestUtils.setField(kafaTopicsMonitor, "topicConfig", topicConfig); + } + + /** + * Test to create a single feed in DB and verify there is no exception while saving the data to MongoDB. + * + * @throws Exception the exception + */ + @Test + public void testKafkaTopicsMonitor() throws Exception { + + Mockito.when(describeTopicsResult.topicNameValues()).thenReturn(getDescribeTopicsResult()); + Mockito.when(admin.describeTopics(Mockito.anyCollection())).thenReturn(describeTopicsResult); + assertEquals(true, kafaTopicsMonitor.isHealthy(true)); + + Mockito.when(describeTopicsResult.topicNameValues()).thenReturn(getDescribeTopicsResultFailure()); + Mockito.when(admin.describeTopics(Mockito.anyCollection())).thenReturn(describeTopicsResult); + assertEquals(false, kafaTopicsMonitor.isHealthy(true)); + + } + + /** + * getDescribeTopicsResult(). + * + * @return Map + * @throws InterruptedException InterruptedException + * @throws ExecutionException ExecutionException + */ + private Map> getDescribeTopicsResult() + throws InterruptedException, ExecutionException { + Node node = new Node(Constants.INT_18, "localhost", Constants.INT_9092); + TopicPartitionInfo topicPartitionInfo = new TopicPartitionInfo(1, node, Collections + .singletonList(node), Collections.singletonList(node)); + + TopicDescription topicDescription = new TopicDescription(topic, false, + Collections.singletonList(topicPartitionInfo)); + + KafkaFuture kf = KafkaFuture.completedFuture(topicDescription); + + Map> map = new HashMap>(); + map.put(topic, kf); + + return map; + } + + /** + * getDescribeTopicsResultFailure(). + * + * @return map + * @throws InterruptedException InterruptedException + * @throws ExecutionException ExecutionException + */ + private Map> getDescribeTopicsResultFailure() + throws InterruptedException, ExecutionException { + + TopicDescription topicDescription = new TopicDescription(topic, false, + Collections.emptyList()); + + KafkaFuture kf = KafkaFuture.completedFuture(topicDescription); + + Map> map = new HashMap>(); + map.put(topic, kf); + + return map; + } + + /** + * Test scenario where partition leader is null. + * + * @throws Exception the exception + */ + @Test + public void testKafkaTopicsMontiorWithNullPartitionLeader() throws Exception { + + Mockito.when(describeTopicsResult.topicNameValues()) + .thenReturn(getDescribeTopicsResultWithNullPartitionLeader()); + Mockito.when(admin.describeTopics(Mockito.anyCollection())).thenReturn(describeTopicsResult); + assertEquals(false, kafaTopicsMonitor.isHealthy(true)); + } + + /** + * getDescribeTopicsResultWithNullPartitionLeader(). + * + * @return Map + * @throws InterruptedException InterruptedException + * @throws ExecutionException ExecutionException + */ + private Map> getDescribeTopicsResultWithNullPartitionLeader() + throws InterruptedException, ExecutionException { + Node node = new Node(Constants.INT_18, "localhost", Constants.INT_9092); + TopicPartitionInfo topicPartitionInfo = new TopicPartitionInfo(1, null, Collections + .singletonList(node), Collections.singletonList(node)); + + TopicDescription topicDescription = new TopicDescription(topic, false, + Collections.singletonList(topicPartitionInfo)); + + KafkaFuture kf = KafkaFuture.completedFuture(topicDescription); + + Map> map = new HashMap>(); + map.put(topic, kf); + + return map; + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/http/HttpClientTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/http/HttpClientTest.java new file mode 100644 index 0000000..3ff5f38 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/http/HttpClientTest.java @@ -0,0 +1,288 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.http; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.io.IOException; +import java.io.StringReader; +import java.util.HashMap; +import java.util.Map; + + +/** + * class HttpClientTest extends KafkaStreamsApplicationTestBase. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/stream-base-vehicle-profile-test.properties") +public class HttpClientTest extends KafkaStreamsApplicationTestBase { + + /** The web server. */ + @Rule + public MockWebServer webServer = new MockWebServer(); + + /** The http client. */ + @Autowired + HttpClient httpClient; + + /** + * Test http client ok. + */ + @Test + public void testHttpClientOk() { + String strResponse = "{\"message\":\"ok\"}"; + webServer.enqueue(new MockResponse().setBody(strResponse)); + + JsonNode resNode = httpClient.invokeJsonResource("http://localhost:" + webServer.getPort() + "/"); + Assert.assertEquals(strResponse, resNode.toString()); + } + + /** + * Test json node. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + @Test + public void testJsonNode() throws IOException { + String strResponse = "{\"message\":\"SUCCESS\",\"data\":{\"vin\":\"5A8HR44H08R828625\"," + + "\"vehicleId\":\"5A8HR44H08R828625\",\"createdOn\":\"2018-06-27T01:29:49.225+0000\"," + + "\"updatedOn\":\"2018-06-27T01:29:49.225+0000\",\"productionDate\":" + + "\"2018-06-26T19:56:37.638+0000\",\"saleDate\":\"2018-06-26T19:56:37.638+0000\"" + + ",\"salesCode\":\"JHG8756\",\"vehicleAttributes\":{\"make\":\"Studebaker\"," + + "\"model\":\"US6\",\"marketingColor\":\"Cherry Blossom\",\"baseColor\":" + + "\"Pink\",\"destinationCountry\":\"Iceland\",\"engineType\":\"Gasoline\"," + + "\"bodyStyle\":\"Truck\"},\"authorizedUsers\":[{\"oemUserId\":\"sclaus\"," + + "\"role\":\"owner\",\"createdOn\":\"2018-06-26T19:56:37.638+0000\",\"updatedOn" + + "\":null}],\"modemInfo\":{\"iccid\":\"89 310 410 10 654378930 1\",\"imei\":99000," + + "\"msisdn\":\"2484977387\",\"imsi\":30272},\"vehicleArchType\":\"string\"," + + "\"ecus\":[{\"name\":\"TELEMATICS\",\"swVersion\":\"10.0.14\",\"serialNo\":" + + "\"TBU987979689A\",\"clientId\":\"1234\",\"stockingLogic\":{\"services\"" + + ":[\"SQDF\",\"ECALL\"],\"applications\":[{\"applicationId\":\"Navigation\"," + + "\"version\":\"1.1.2\"}]},\"provisionedServices\":{\"services\":[\"SQDF\"]," + + "\"applications\":[{\"applicationId\":\"Navigation\",\"version\":\"1.0.2\"}]}," + + "\"provisioningState\":{\"state\":\"PENDING\",\"datetime\":" + + "\"2018-06-26T19:56:37.638+0000\"},\"drmBlobSigned\":\"6464A654DF64CDA\"," + + "\"drmType\":\"JAR\",\"productId\":\"JFGJ120987\"},{\"name\":\"HU\"," + + "\"swVersion\":\"10.0.14\",\"serialNo\":\"TBU987979689A\",\"clientId\":\"12345\"," + + "\"stockingLogic\":{\"services\":[\"SQDF\"],\"applications\":" + + "[{\"applicationId\":\"Navigation\",\"version\":\"1.0.2\"}]}," + + "\"provisionedServices\":{\"services\":[\"SQDF\"],\"applications\"" + + ":[{\"applicationId\":\"Navigation\",\"version\":\"1.0.2\"}]},\"provisioningState\"" + + ":{\"state\":\"PENDING\",\"datetime\":\"2018-06-26T19:56:37.638+0000\"}," + + "\"drmBlobSigned\":\"6464A654DF64CDA\",\"drmType\":\"JAR\"," + + "\"productId\":\"JFGJ120987\"}],\"firstTrialDate\":" + + "\"2018-06-26T19:56:37.638+0000\",\"vehicleType\":\"RENTAL\"," + + "\"subscribedPackages\":[{\"packageName\":\"DEMO\",\"startDate\":" + + "\"2018-06-26T19:56:37.638+0000\",\"endDate\":\"2018-06-26T19:56:37.638+0000\"," + + "\"subscriptionId\":\"DM-461584\"}],\"schemaVersion\":\"1.0\"}}"; + + JsonNode jsonNode = new ObjectMapper().readTree(new StringReader(strResponse)); + JsonNode ecusNode = jsonNode.findValue("ecus"); + for (JsonNode ecus : ecusNode) { + JsonNode serviceNode = ecus.findValue("services"); + String[] expectedServices = { "SQDF", "ECALL" }; + int i = 0; + for (JsonNode s : serviceNode) { + if (s.asText().equals("ECALL")) { + System.out.println(ecus.findValuesAsText("clientId")); + } + Assert.assertEquals(expectedServices[i++], s.asText()); + } + } + } + + /** + * Test http client GET. + */ + /* + * Test GET request with headers and query string + */ + @Test + public void testHttpClientGET() { + MockResponse mockResponse = new MockResponse(); + mockResponse.setResponseCode(Constants.THREAD_SLEEP_TIME_200); + + String strResponse = "{\"message\":\"success\",\"data\":{\"transactionStatus\":\"DELIVERED\"}}"; + mockResponse.setBody(strResponse); + + webServer.enqueue(mockResponse); + + Map headers = new HashMap<>(); + headers.put("sessionId", "Session1234"); + headers.put("clientRequestId", "Request1234"); + + Map parameters = new HashMap<>(); + parameters.put("dummyParam1", "dummyValue1"); + + Map resNode = httpClient.invokeJsonResource(HttpClient.HttpReqMethod.GET, + "http://localhost:" + webServer.getPort() + "/v1.0/m2m/sim/transaction/307631ea-715a-4686-9b2a-d46bf7b99386", headers, + parameters, Constants.THREE, TestConstants.THREAD_SLEEP_TIME_5000); + + String responseCode = (String) resNode.get(HttpClient.RESPONSE_CODE); + JsonNode responseJSON = (JsonNode) resNode.get(HttpClient.RESPONSE_JSON); + + Assert.assertEquals("200", responseCode); + Assert.assertEquals(strResponse, responseJSON.toString()); + } + + /** + * Test http client GET with retries. + */ + /* + * Test GET request with headers and query string + */ + @Test + public void testHttpClientGETWithRetries() { + MockResponse mockResponse = new MockResponse(); + + Map headers = new HashMap<>(); + headers.put("sessionId", "Session1234"); + headers.put("clientRequestId", "Request1234"); + + Map parameters = new HashMap<>(); + parameters.put("dummyParam1", "dummyValue1"); + + Map resNode = httpClient.invokeJsonResource(HttpClient.HttpReqMethod.GET, + "http://localhost:" + webServer.getPort() + "/v1.0/m2m/sim/transaction/307631ea-715a-4686-9b2a-d46bf7b99386", headers, + parameters, Constants.THREE, TestConstants.THREAD_SLEEP_TIME_5000); + + String responseCode = (String) resNode.get(HttpClient.RESPONSE_CODE); + JsonNode responseJSON = (JsonNode) resNode.get(HttpClient.RESPONSE_JSON); + + Assert.assertEquals(null, responseCode); + Assert.assertEquals(null, responseJSON); + } + + /** + * Test http client PUT. + */ + /* + * Test PUT request with headers, request parameters as sent as JSON string + */ + @Test + public void testHttpClientPUT() { + MockResponse mockResponse = new MockResponse(); + mockResponse.setResponseCode(Constants.INT_202); + + String strResponse = "{\"message\":\"success\",\"data\":{\"transactionId\"" + + ":\"307631ea-715a-4686-9b2a-d46bf7b99386\"}}"; + mockResponse.setBody(strResponse); + + webServer.enqueue(mockResponse); + + Map headers = new HashMap<>(); + headers.put("sessionId", "Session1234"); + headers.put("clientRequestId", "Request1234"); + + Map parameters = new HashMap<>(); + parameters.put("vehicleId", "vehicleId123"); + parameters.put("smsType", "SHOULDER_TAP"); + + Map addParams = new HashMap<>(); + addParams.put("PRIORITY", "LOW"); + parameters.put("additionalParameters", addParams); + + Map resNode = httpClient.invokeJsonResource(HttpClient.HttpReqMethod.PUT, + "http://localhost:" + webServer.getPort() + "/v1.0/m2m/sms/send", headers, parameters, Constants.THREE, TestConstants.THREAD_SLEEP_TIME_5000); + + String responseCode = (String) resNode.get(HttpClient.RESPONSE_CODE); + JsonNode responseJSON = (JsonNode) resNode.get(HttpClient.RESPONSE_JSON); + + Assert.assertEquals("202", responseCode); + Assert.assertEquals(strResponse, responseJSON.toString()); + } + + /** + * Test http client POST. + */ + /* + * Test POST request with headers, request parameters as sent as JSON string + */ + @Test + public void testHttpClientPOST() { + MockResponse mockResponse = new MockResponse(); + mockResponse.setResponseCode(Constants.INT_202); + + String strResponse = "{\"message\":\"success\",\"data\":" + + "{\"transactionId\":\"307631ea-715a-4686-9b2a-d46bf7b99386\"}}"; + mockResponse.setBody(strResponse); + + webServer.enqueue(mockResponse); + + Map headers = new HashMap<>(); + headers.put("sessionId", "Session1234"); + headers.put("clientRequestId", "Request1234"); + + Map parameters = new HashMap<>(); + parameters.put("vehicleId", "vehicleId123"); + parameters.put("smsType", "SHOULDER_TAP"); + + Map addParams = new HashMap<>(); + addParams.put("PRIORITY", "LOW"); + parameters.put("additionalParameters", addParams); + + Map resNode = httpClient.invokeJsonResource(HttpClient.HttpReqMethod.POST, + "http://localhost:" + webServer.getPort() + "/v1.0/m2m/sms/send", headers, parameters, Constants.THREE, TestConstants.THREAD_SLEEP_TIME_5000); + + String responseCode = (String) resNode.get(HttpClient.RESPONSE_CODE); + JsonNode responseJSON = (JsonNode) resNode.get(HttpClient.RESPONSE_JSON); + + Assert.assertEquals("202", responseCode); + Assert.assertEquals(strResponse, responseJSON.toString()); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/GlobalMessageGeneratorIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/GlobalMessageGeneratorIntegrationTest.java new file mode 100644 index 0000000..453120c --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/GlobalMessageGeneratorIntegrationTest.java @@ -0,0 +1,125 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.idgen.internal; + + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/** + * class GlobalMessageGeneratorIntegrationTest extends {@link KafkaStreamsApplicationTestBase}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@TestPropertySource("/messageid-generator-test.properties") +public class GlobalMessageGeneratorIntegrationTest extends KafkaStreamsApplicationTestBase { + + /** The global message id generator. */ + @Autowired + private GlobalMessageIdGenerator globalMessageIdGenerator; + + /** The sequence block DAO. */ + @Autowired + private SequenceBlockDAO sequenceBlockDAO; + + /** + * Test generate unique msg id. + * + * @throws Exception the exception + */ + @Test + public void testGenerateUniqueMsgId() throws Exception { + String vechileId = "vechileid1"; + String messageId = globalMessageIdGenerator.generateUniqueMsgId(vechileId); + List sequenceBlockList = sequenceBlockDAO.findByIds(vechileId); + assertEquals(Integer.valueOf(messageId), + Integer.valueOf(sequenceBlockList.get(0).getMaxValue() - globalMessageIdGenerator.getBlockValue() + 1)); + } + + /** + * Map entry must be deleted which is least recently used, after some specified threshold value reached. + */ + @Test + public void testGenerateUniqueMsgIdForEviction() { + List list1 = new ArrayList<>(); + int i = 0; + while (i++ < Constants.THREAD_SLEEP_TIME_100) { + list1.add(globalMessageIdGenerator.generateUniqueMsgId(Integer.toString(i))); + } + @SuppressWarnings("unchecked") + Map vehicleIdCounterMap = (Map) + ReflectionTestUtils.getField(globalMessageIdGenerator, + "vehicleIdCounterMap"); + assertNull(vehicleIdCounterMap.get("0")); + assertNotNull(vehicleIdCounterMap.get("97")); + assertNotNull(vehicleIdCounterMap.get("98")); + assertNotNull(vehicleIdCounterMap.get("99")); + assertNotNull(vehicleIdCounterMap.get("100")); + } + + /** + * Test generate unique msg id when vehicle id null. + * + * @throws Exception the exception + */ + @Test + public void testGenerateUniqueMsgIdWhenVehicleIdNull() throws Exception { + assertThrows(RuntimeException.class, () -> globalMessageIdGenerator.generateUniqueMsgId(null)); + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/GlobalMessageGeneratorTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/GlobalMessageGeneratorTest.java new file mode 100644 index 0000000..0c1c3a6 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/GlobalMessageGeneratorTest.java @@ -0,0 +1,114 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.idgen.internal; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import java.lang.reflect.Field; + + +/** + * class {@link GlobalMessageGeneratorTest}. + */ +public class GlobalMessageGeneratorTest { + + /** The global message id generator. */ + @InjectMocks + private GlobalMessageIdGenerator globalMessageIdGenerator; + + /** The message id config service. */ + @Mock + private SequenceBlockService messageIdConfigService; + + /** + * setUp(). + * + * @throws NoSuchFieldException the no such field exception + * @throws SecurityException the security exception + * @throws IllegalArgumentException the illegal argument exception + * @throws IllegalAccessException IllegalAccessException + * @throwIllegalAccessException NoSuchFieldException + * @thIllegalAccessException SecurityException + */ + @Before + public void setup() throws NoSuchFieldException, SecurityException, + IllegalArgumentException, IllegalAccessException { + MockitoAnnotations.initMocks(this); + globalMessageIdGenerator.init(); + Field retryCounterFiled = globalMessageIdGenerator.getClass().getDeclaredField("retryCounter"); + retryCounterFiled.setAccessible(true); + retryCounterFiled.set(globalMessageIdGenerator, (byte) Constants.THREE); + + retryCounterFiled = globalMessageIdGenerator.getClass().getDeclaredField("retryInterval"); + retryCounterFiled.setAccessible(true); + retryCounterFiled.set(globalMessageIdGenerator, Constants.THREAD_SLEEP_TIME_1000); + } + + /** + * Retry unit test while DAO layer does not return any value. + * + * @throws Exception Exception + */ + @Test(expected = RuntimeException.class) + public void testGenerateUniqueMsgId() throws Exception { + Mockito.when(messageIdConfigService.getMessageIdConfig(Mockito.anyString())) + .thenReturn(null); + globalMessageIdGenerator.generateUniqueMsgId("testid"); + } + + /** + * Test generate unique msg id when vehicle id null. + * + * @throws Exception the exception + */ + @Test(expected = RuntimeException.class) + public void testGenerateUniqueMsgIdWhenVehicleIdNull() throws Exception { + Mockito.when(messageIdConfigService.getMessageIdConfig(Mockito.anyString())) + .thenReturn(null); + globalMessageIdGenerator.generateUniqueMsgId(null); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockServiceImplIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockServiceImplIntegrationTest.java new file mode 100644 index 0000000..e6bfdbd --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockServiceImplIntegrationTest.java @@ -0,0 +1,131 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.idgen.internal; + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import static org.junit.Assert.assertEquals; + + +/** + * {@link SequenceBlockServiceImplIntegrationTest} test class for {@link SequenceBlockServiceImpl}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@TestPropertySource("/stream-base-test.properties") +public class SequenceBlockServiceImplIntegrationTest { + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS. */ + @ClassRule + public static final EmbeddedRedisServer REDIS = new EmbeddedRedisServer(); + + /** The Constant VEHICLE_ID. */ + private static final String VEHICLE_ID = "Vehicle123"; + + /** The sequence block service. */ + @Autowired + private SequenceBlockService sequenceBlockService; + + /** The sequence block dao. */ + @Autowired + private SequenceBlockDAO sequenceBlockDao; + + /** The block value. */ + @Value("${" + PropertyNames.SEQUENCE_BLOCK_MAXVALUE + ":1000}") + private int blockValue; + + /** + * Test seq block service save and update. + */ + @Test + public void testSeqBlockServiceSaveAndUpdate() { + + sequenceBlockDao.deleteAll(); + String vehicleId = VEHICLE_ID; + SequenceBlock seqBlock = sequenceBlockService.getMessageIdConfig(vehicleId); + assertEquals("The same vehicle id is not retrived ", true, VEHICLE_ID.equals(seqBlock.getVehicleId())); + assertEquals("sequence block's max value not updated correctly", + true, (blockValue == seqBlock.getMaxValue())); + + seqBlock = sequenceBlockService.getMessageIdConfig(vehicleId); + assertEquals("sequence block's max value not updated correctly", + true, (blockValue * Constants.TWO == seqBlock.getMaxValue())); + + seqBlock = sequenceBlockService.getMessageIdConfig(vehicleId); + assertEquals("Entity not updated", true, seqBlock.getVehicleId().equals(vehicleId)); + assertEquals("Expected Current value not equal to current " + + "value of seq block", seqBlock.getMaxValue() - blockValue, + seqBlock.getCurrentValue()); + } + + /** + * Test seq block service max value reached. + */ + @Test + public void testSeqBlockServiceMaxValueReached() { + + String vehicleId = VEHICLE_ID; + SequenceBlock seqBlock = new SequenceBlock(); + seqBlock.setMaxValue(Integer.MAX_VALUE); + seqBlock.setTimeStamp(System.currentTimeMillis()); + seqBlock.setVehicleId(vehicleId); + sequenceBlockDao.save(seqBlock); + + seqBlock = sequenceBlockService.getMessageIdConfig(vehicleId); + assertEquals("Collection not reset when max value for Integer reached", blockValue, seqBlock.getMaxValue()); + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockServiceImplTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockServiceImplTest.java new file mode 100644 index 0000000..6329556 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/idgen/internal/SequenceBlockServiceImplTest.java @@ -0,0 +1,177 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.idgen.internal; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.nosqldao.IgniteQuery; +import org.eclipse.ecsp.nosqldao.Updates; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; + + +/** + * UT class {@link SequenceBlockServiceImplTest}. + */ +public class SequenceBlockServiceImplTest { + + /** The Constant VEHICLE_ID. */ + private static final String VEHICLE_ID = "Vehicle999"; + + /** The seq block service. */ + @InjectMocks + private SequenceBlockServiceImpl seqBlockService; + + /** The sequence block DAO. */ + @Mock + private SequenceBlockDAO sequenceBlockDAO; + + /** The block value. */ + private int blockValue = Constants.THREAD_SLEEP_TIME_1000; + + /** + * setup(). + * + * @throws NoSuchFieldException NoSuchFieldException + * @throws SecurityException SecurityException + * @throws IllegalArgumentException IllegalArgumentException + * @throws IllegalAccessException IllegalAccessException + */ + @Before + public void setup() throws NoSuchFieldException, SecurityException, + IllegalArgumentException, IllegalAccessException { + MockitoAnnotations.initMocks(this); + + Field blockValueField = seqBlockService.getClass().getDeclaredField("blockValue"); + blockValueField.setAccessible(true); + blockValueField.set(seqBlockService, blockValue); + } + + /** + * Test generate unique msg id with insert. + */ + @Test + public void testGenerateUniqueMsgIdWithInsert() { + + Mockito.when(sequenceBlockDAO.find(ArgumentMatchers.any(IgniteQuery.class))).thenReturn(null); + SequenceBlock seqBlock = new SequenceBlock(); + seqBlock.setVehicleId(VEHICLE_ID); + seqBlock.setTimeStamp(System.currentTimeMillis()); + seqBlock.setMaxValue(blockValue); + seqBlock.setCurrentValue(0); + Mockito.when(sequenceBlockDAO.save(ArgumentMatchers.any(SequenceBlock.class))).thenReturn(seqBlock); + + SequenceBlock updatedSeqBlock = seqBlockService.getMessageIdConfig(VEHICLE_ID); + + assertEquals("Max value not saved as expected", + seqBlock.getMaxValue(), updatedSeqBlock.getMaxValue()); + assertEquals("Current value not saved as expected", + seqBlock.getCurrentValue(), updatedSeqBlock.getCurrentValue()); + } + + /** + * Test generate unique msg id with update pass. + */ + @Test + public void testGenerateUniqueMsgIdWithUpdatePass() { + + int oldMaxValue = Constants.THREAD_SLEEP_TIME_4000; + SequenceBlock seqBlock = new SequenceBlock(); + seqBlock.setCurrentValue(oldMaxValue - blockValue); + seqBlock.setMaxValue(oldMaxValue); + seqBlock.setTimeStamp(System.currentTimeMillis() - (Constants.SIXTY * Constants.THREAD_SLEEP_TIME_1000)); + seqBlock.setVehicleId(VEHICLE_ID); + List seqBlockList = new ArrayList<>(); + seqBlockList.add(seqBlock); + + Mockito.when(sequenceBlockDAO.find(ArgumentMatchers + .any(IgniteQuery.class))).thenReturn(seqBlockList); + Mockito.when(sequenceBlockDAO.update(ArgumentMatchers + .any(IgniteQuery.class), ArgumentMatchers.any(Updates.class))) + .thenReturn(true); + + SequenceBlock updatedSeqBlock = seqBlockService.getMessageIdConfig(VEHICLE_ID); + + Mockito.verify(sequenceBlockDAO, Mockito.times(1)) + .update(ArgumentMatchers.any(IgniteQuery.class), ArgumentMatchers.any(Updates.class)); + + assertEquals("Max value not updated as expected", oldMaxValue + blockValue, updatedSeqBlock.getMaxValue()); + assertEquals("Current value not updated as expected", oldMaxValue, updatedSeqBlock.getCurrentValue()); + } + + /** + * Test generate unique msg id with update fail. + */ + @Test + public void testGenerateUniqueMsgIdWithUpdateFail() { + + int oldMaxValue = Constants.THREAD_SLEEP_TIME_4000; + SequenceBlock seqBlock = new SequenceBlock(); + seqBlock.setCurrentValue(oldMaxValue - blockValue); + seqBlock.setMaxValue(oldMaxValue); + seqBlock.setTimeStamp(System.currentTimeMillis() - (Constants.SIXTY * Constants.THREAD_SLEEP_TIME_1000)); + seqBlock.setVehicleId(VEHICLE_ID); + List seqBlockList = new ArrayList<>(); + seqBlockList.add(seqBlock); + + Mockito.when(sequenceBlockDAO.find(ArgumentMatchers.any(IgniteQuery.class))) + .thenReturn(seqBlockList); + Mockito.when(sequenceBlockDAO.update(ArgumentMatchers.any(IgniteQuery.class), + ArgumentMatchers.any(Updates.class))) + .thenReturn(false); + + SequenceBlock updatedSeqBlock = seqBlockService.getMessageIdConfig(VEHICLE_ID); + + Mockito.verify(sequenceBlockDAO, Mockito.times(1)) + .update(ArgumentMatchers.any(IgniteQuery.class), ArgumentMatchers.any(Updates.class)); + assertEquals("Sequence Block got updated", null, updatedSeqBlock); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/EmbeddedKafka.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/EmbeddedKafka.java new file mode 100644 index 0000000..3318670 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/EmbeddedKafka.java @@ -0,0 +1,273 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka; + +import kafka.cluster.EndPoint; +import kafka.server.KafkaConfig; +import kafka.server.KafkaConfig$; +import kafka.server.KafkaServer; +import kafka.utils.TestUtils; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.common.errors.UnknownTopicOrPartitionException; +import org.apache.kafka.common.network.ListenerName; +import org.apache.kafka.common.security.auth.SecurityProtocol; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.common.utils.Time; +import org.apache.kafka.streams.StreamsConfig; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.junit.rules.TemporaryFolder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import scala.collection.mutable.ArraySeq; + +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ExecutionException; + + +/** + * Runs an in-memory, "embedded" instance of a Kafka broker, which listens at `127.0.0.1:9092` by default. + * Requires a running ZooKeeper instance to connect to. By default, + * it expects a ZooKeeper instance running at `127.0.0.1:2181`. You can + * specify a different ZooKeeper instance by setting the `zookeeper.connect` parameter in the broker's configuration. + */ +public class EmbeddedKafka { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(EmbeddedKafka.class); + + /** The Constant DEFAULT_ZK_CONNECT. */ + private static final String DEFAULT_ZK_CONNECT = "127.0.0.1:2181"; + + /** The effective config. */ + private final Properties effectiveConfig; + + /** The log dir. */ + private final File logDir; + + /** The tmp folder. */ + private final TemporaryFolder tmpFolder; + + /** The kafka. */ + private final KafkaServer kafka; + + /** + * Creates and starts an embedded Kafka broker. + * + * @param config Broker configuration settings. Used to modify, for example, + * on which port the broker should listen to. Note that you cannot + * change some settings such as `log.dirs`, `port`. + * @throws IOException Signals that an I/O exception has occurred. + */ + public EmbeddedKafka(final Properties config) throws IOException { + tmpFolder = new TemporaryFolder(); + tmpFolder.create(); + logDir = tmpFolder.newFolder(); + effectiveConfig = effectiveConfigFrom(config); + final boolean loggingEnabled = true; + + final KafkaConfig kafkaConfig = new KafkaConfig(effectiveConfig, loggingEnabled); + LOGGER.debug("Starting embedded Kafka broker (with log.dirs={} and ZK ensemble at {}) ...", + logDir, zookeeperConnect()); + kafka = TestUtils.createServer(kafkaConfig, Time.SYSTEM); + LOGGER.debug("Startup of embedded Kafka broker at {} completed (with ZK ensemble at {}) ...", + brokerList(), zookeeperConnect()); + } + + /** + * Effective config from. + * + * @param initialConfig the initial config + * @return the properties + */ + private Properties effectiveConfigFrom(final Properties initialConfig) { + final Properties effectiveConfig = new Properties(); + effectiveConfig.put(KafkaConfig$.MODULE$.BrokerIdProp(), 0); + effectiveConfig.put(KafkaConfig.ListenersProp(), "PLAINTEXT://127.0.0.1:9092"); + effectiveConfig.put(KafkaConfig$.MODULE$.NumPartitionsProp(), 1); + effectiveConfig.put(KafkaConfig$.MODULE$.AutoCreateTopicsEnableProp(), true); + effectiveConfig.put(KafkaConfig$.MODULE$.MessageMaxBytesProp(), Constants.INT_1000000); + effectiveConfig.put(KafkaConfig$.MODULE$.ControlledShutdownEnableProp(), true); + + effectiveConfig.putAll(initialConfig); + effectiveConfig.setProperty(KafkaConfig$.MODULE$.LogDirProp(), logDir.getAbsolutePath()); + + //effectiveConfig.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, effectiveConfig) + effectiveConfig.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass().getName()); + effectiveConfig.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass().getName()); + //effectiveConfig.put(PropertyNames.NUM_STREAM_THREADS, "1"); + //effectiveConfig.put(PropertyNames.REPLICATION_FACTOR, "1"); + effectiveConfig.put(StreamsConfig.STATE_DIR_CONFIG, "/tmp/kafka-streams"); + + return effectiveConfig; + } + + /** + * This broker's `metadata.broker.list` value. Example: `127.0.0.1:9092`. + * You can use this to tell Kafka producers and consumers how to connect to this instance. + * + * @return the string + */ + public String brokerList() { + final EndPoint endPoint = ((ArraySeq) kafka.advertisedListeners()).head(); + final String hostname = endPoint.host() == null ? "" : endPoint.host(); + + return String.join(":", hostname, Integer.toString( + kafka.boundPort(ListenerName.forSecurityProtocol(SecurityProtocol.PLAINTEXT)) + )); + } + + /** + * The ZooKeeper connection string aka `zookeeper.connect`. + * + * @return the string + */ + public String zookeeperConnect() { + return effectiveConfig.getProperty("zookeeper.connect", DEFAULT_ZK_CONNECT); + } + + /** + * Stop the broker. + */ + public void stop() { + LOGGER.debug("Shutting down embedded Kafka broker at {} (with ZK ensemble at {}) ...", + brokerList(), zookeeperConnect()); + kafka.shutdown(); + kafka.awaitShutdown(); + LOGGER.debug("Removing temp folder {} with logs.dir at {} ...", tmpFolder, logDir); + tmpFolder.delete(); + LOGGER.debug("Shutdown of embedded Kafka broker at {} completed (with ZK ensemble at {}) ...", + brokerList(), zookeeperConnect()); + } + + /** + * Create a Kafka topic with 1 partition and a replication factor of 1. + * + * @param topic + * The name of the topic. + */ + public void createTopic(final String topic) { + createTopic(topic, 1, (short) 1, Collections.emptyMap()); + } + + /** + * Create a Kafka topic with the given parameters. + * + * @param topic + * The name of the topic. + * @param partitions + * The number of partitions for this topic. + * @param replication + * The replication factor for (the partitions of) this topic. + */ + public void createTopic(final String topic, final int partitions, final short replication) { + createTopic(topic, partitions, replication, Collections.emptyMap()); + } + + /** + * Create a Kafka topic with the given parameters. + * + * @param topic + * The name of the topic. + * @param partitions + * The number of partitions for this topic. + * @param replication + * The replication factor for (partitions of) this topic. + * @param topicConfig + * Additional topic-level configuration settings. + */ + public void createTopic(final String topic, + final int partitions, + final short replication, + final Map topicConfig) { + LOGGER.debug("Creating topic { name: {}, partitions: {}, replication: {}, config: {} }", + topic, partitions, replication, topicConfig); + + final Properties properties = new Properties(); + properties.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList()); + + try (final AdminClient adminClient = AdminClient.create(properties)) { + final NewTopic newTopic = new NewTopic(topic, partitions, replication); + newTopic.configs(topicConfig); + adminClient.createTopics(Collections.singleton(newTopic)).all().get(); + } catch (final InterruptedException | ExecutionException fatal) { + throw new RuntimeException(fatal); + } + + } + + /** + * Delete a Kafka topic. + * + * @param topic + * The name of the topic. + */ + public void deleteTopic(final String topic) { + LOGGER.debug("Deleting topic {}", topic); + final Properties properties = new Properties(); + properties.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList()); + + try (final AdminClient adminClient = AdminClient.create(properties)) { + adminClient.deleteTopics(Collections.singleton(topic)).all().get(); + } catch (final InterruptedException e) { + LOGGER.error("InterruptedException occurred when attempting to delete topics", e); + throw new RuntimeException(e); + } catch (final ExecutionException e) { + if (!(e.getCause() instanceof UnknownTopicOrPartitionException)) { + LOGGER.error("ExecutionException occurred when attempting to delete topics", e); + throw new RuntimeException(e); + } + } + } + + /** + * Kafka server. + * + * @return the kafka server + */ + KafkaServer kafkaServer() { + return kafka; + } +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/EmbeddedZookeeper.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/EmbeddedZookeeper.java new file mode 100644 index 0000000..a642bdf --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/EmbeddedZookeeper.java @@ -0,0 +1,104 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka; + +import org.apache.curator.test.TestingServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + + +/** + * Runs an in-memory, "embedded" instance of a ZooKeeper server. + * The ZooKeeper server instance is automatically started when you create a new instance of this class. + */ +public class EmbeddedZookeeper { + + /** The Constant LOG. */ + private static final Logger LOG = LoggerFactory.getLogger(EmbeddedZookeeper.class); + + /** The server. */ + private final TestingServer server; + + /** + * Creates and starts a ZooKeeper instance. + * + * @throws Exception Exception + */ + public EmbeddedZookeeper() throws Exception { + LOG.debug("Starting embedded ZooKeeper server..."); + this.server = new TestingServer(); + LOG.debug("Embedded ZooKeeper server at {} uses the temp directory at {}", + server.getConnectString(), server.getTempDirectory()); + } + + /** + * setUp(). + * + * @throws IOException IOException + */ + public void stop() throws IOException { + LOG.debug("Shutting down embedded ZooKeeper server at {} ...", server.getConnectString()); + server.close(); + LOG.debug("Shutdown of embedded ZooKeeper server at {} completed", server.getConnectString()); + } + + /** + * The ZooKeeper connection string aka `zookeeper.connect` in `hostnameOrIp:port` format. Example: `127.0.0.1:2181`. + * You can use this to e.g. tell Kafka brokers how to connect to this instance. + * + * @return the string + */ + public String connectString() { + return server.getConnectString(); + } + + /** + * The hostname of the ZooKeeper instance. Example: `127.0.0.1` + * + * @return the string + */ + public String hostname() { + // "server:1:2:3" -> "server:1:2" + return connectString().substring(0, connectString().lastIndexOf(':')); + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/SingleNodeKafkaCluster.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/SingleNodeKafkaCluster.java new file mode 100644 index 0000000..1e76ea0 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/SingleNodeKafkaCluster.java @@ -0,0 +1,387 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka; + +import de.flapdoodle.embed.process.runtime.Network; +import kafka.server.KafkaConfig; +import kafka.server.KafkaConfig$; +import org.apache.kafka.common.errors.UnknownTopicOrPartitionException; +import org.apache.kafka.test.TestCondition; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.junit.rules.ExternalResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import scala.jdk.CollectionConverters; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + + +/** + * Runs an in-memory, "embedded" Kafka cluster with 1 ZooKeeper instance and 1 Kafka broker. + */ +public class SingleNodeKafkaCluster extends ExternalResource { + + /** The Constant LOG. */ + private static final Logger LOG = LoggerFactory.getLogger(SingleNodeKafkaCluster.class); + + /** The Constant KAFKA_SCHEMAS_TOPIC. */ + private static final String KAFKA_SCHEMAS_TOPIC = "_schemas"; + + /** The Constant KAFKASTORE_OPERATION_TIMEOUT_MS. */ + private static final String KAFKASTORE_OPERATION_TIMEOUT_MS = "60000"; + + /** The Constant KAFKASTORE_DEBUG. */ + private static final String KAFKASTORE_DEBUG = "true"; + + /** The Constant KAFKASTORE_INIT_TIMEOUT. */ + private static final String KAFKASTORE_INIT_TIMEOUT = "90000"; + + /** The kafka broker port. */ + private static int kafkaBrokerPort = 1234; // pick a random port + + /** The broker config. */ + private final Properties brokerConfig; + + /** The zookeeper. */ + private EmbeddedZookeeper zookeeper; + + /** The broker. */ + private EmbeddedKafka broker; + + /** The running. */ + private boolean running; + + /** + * Creates and starts the cluster. + */ + public SingleNodeKafkaCluster() { + this(new Properties()); + } + + /** + * Creates and starts the cluster. + * + * @param brokerConfig + * Additional broker configuration settings. + */ + public SingleNodeKafkaCluster(final Properties brokerConfig) { + this.brokerConfig = new Properties(); + + this.brokerConfig.putAll(brokerConfig); + } + + /** + * Zkconnectstring. + * + * @return the string + */ + public String zkconnectstring() { + return zookeeper.connectString(); + } + + /** + * Creates and starts the cluster. + * + * @throws Exception the exception + */ + public void start() throws Exception { + LOG.debug("Initiating embedded Kafka cluster startup"); + LOG.debug("Starting a ZooKeeper instance..."); + zookeeper = new EmbeddedZookeeper(); + LOG.debug("ZooKeeper instance is running at {}", zookeeper.connectString()); + + final Properties effectiveBrokerConfig = effectiveBrokerConfigFrom(brokerConfig, zookeeper); + LOG.debug("Starting a Kafka instance on port {} ...", + effectiveBrokerConfig.getProperty(KafkaConfig.ListenersProp())); + broker = new EmbeddedKafka(effectiveBrokerConfig); + LOG.debug("Kafka instance is running at {}, connected to ZooKeeper at {}", + broker.brokerList(), broker.zookeeperConnect()); + } + + /** + * Effective broker config from. + * + * @param brokerConfig the broker config + * @param zookeeper the zookeeper + * @return the properties + */ + private Properties effectiveBrokerConfigFrom(final Properties brokerConfig, final EmbeddedZookeeper zookeeper) { + final Properties effectiveConfig = new Properties(); + + try { + kafkaBrokerPort = Network.getFreeServerPort(); + } catch (IOException e) { + LOG.error("Error fetching kafka port", e); + } + effectiveConfig.putAll(brokerConfig); + effectiveConfig.put(KafkaConfig$.MODULE$.ZkConnectProp(), zookeeper.connectString()); + effectiveConfig.put(KafkaConfig$.MODULE$.ZkSessionTimeoutMsProp(), + TestConstants.INT_30 * TestConstants.THOUSAND); + effectiveConfig.put(KafkaConfig.ListenersProp(), String.format("PLAINTEXT://127.0.0.1:%s", kafkaBrokerPort)); + effectiveConfig.put(KafkaConfig$.MODULE$.ZkConnectionTimeoutMsProp(), + TestConstants.INT_60 * TestConstants.THOUSAND); + effectiveConfig.put(KafkaConfig$.MODULE$.DeleteTopicEnableProp(), true); + effectiveConfig.put(KafkaConfig$.MODULE$.LogCleanerDedupeBufferSizeProp(), + TestConstants.TWO * TestConstants.LONG_1024 * TestConstants.LONG_1024); + effectiveConfig.put(KafkaConfig$.MODULE$.GroupMinSessionTimeoutMsProp(), 0); + effectiveConfig.put(KafkaConfig$.MODULE$.OffsetsTopicReplicationFactorProp(), (short) 1); + effectiveConfig.put(KafkaConfig$.MODULE$.OffsetsTopicPartitionsProp(), 1); + effectiveConfig.put(KafkaConfig$.MODULE$.AutoCreateTopicsEnableProp(), true); + return effectiveConfig; + } + + /** + * Before. + * + * @throws Exception the exception + */ + @Override + protected void before() throws Exception { + start(); + } + + /** + * After. + */ + @Override + protected void after() { + stop(); + } + + /** + * Stops the cluster. + */ + public void stop() { + LOG.info("Stopping Confluent"); + try { + if (broker != null) { + broker.stop(); + } + try { + if (zookeeper != null) { + zookeeper.stop(); + } + } catch (final IOException fatal) { + throw new RuntimeException(fatal); + } + } finally { + running = false; + } + LOG.info("Confluent Stopped"); + } + + /** + * This cluster's `bootstrap.servers` value. Example: `127.0.0.1:9092`. + *

    + * You can use this to tell Kafka Streams applications, + * Kafka producers, and Kafka consumers (new consumer API) how to connect to this + * cluster. + *

    + * + * @return the string + */ + public String bootstrapServers() { + return broker.brokerList(); + } + + /** + * Delete topic. + * + * @param topic the topic + */ + public void deleteTopic(final String topic) { + broker.deleteTopic(topic); + } + + /** + * Creates a Kafka topic with 1 partition and a replication factor of 1. + * + * @param topic The name of the topic. + * @throws InterruptedException the interrupted exception + */ + public void createTopic(final String topic) throws InterruptedException { + createTopic(topic, 1, (short) 1, Collections.emptyMap()); + } + + /** + * Creates a Kafka topic with the given parameters. + * + * @param topic The name of the topic. + * @param partitions The number of partitions for this topic. + * @param replication The replication factor for (the partitions of) this topic. + * @throws InterruptedException the interrupted exception + */ + public void createTopic(final String topic, final int partitions, + final short replication) throws InterruptedException { + createTopic(topic, partitions, replication, Collections.emptyMap()); + } + + /** + * Creates a Kafka topic with the given parameters. + * + * @param topic The name of the topic. + * @param partitions The number of partitions for this topic. + * @param replication The replication factor for (partitions of) this topic. + * @param topicConfig Additional topic-level configuration settings. + * @throws InterruptedException the interrupted exception + */ + public void createTopic(final String topic, + final int partitions, + final short replication, + final Map topicConfig) throws InterruptedException { + createTopic(TestConstants.THREAD_SLEEP_TIME_60000, topic, partitions, replication, topicConfig); + } + + /** + * Creates a Kafka topic with the given parameters and blocks until all topics got created. + * + * @param timeoutMs the timeout ms + * @param topic The name of the topic. + * @param partitions The number of partitions for this topic. + * @param replication The replication factor for (partitions of) this topic. + * @param topicConfig Additional topic-level configuration settings. + * @throws InterruptedException the interrupted exception + */ + public void createTopic(final long timeoutMs, + final String topic, + final int partitions, + final short replication, + final Map topicConfig) throws InterruptedException { + broker.createTopic(topic, partitions, replication, topicConfig); + + } + + /** + * Deletes multiple topics and blocks until all topics got deleted. + * + * @param timeoutMs the max time to wait for the topics to be deleted (does not block if {@code <= 0}) + * @param topics the name of the topics + * @throws InterruptedException the interrupted exception + */ + public void deleteTopicsAndWait(final long timeoutMs, final String... topics) throws InterruptedException { + for (final String topic : topics) { + try { + broker.deleteTopic(topic); + } catch (final UnknownTopicOrPartitionException expected) { + // indicates (idempotent) success + } + } + + } + + /** + * Checks if is running. + * + * @return true, if is running + */ + public boolean isRunning() { + return running; + } + + /** + * The Class TopicsDeletedCondition. + */ + private final class TopicsDeletedCondition implements TestCondition { + + /** The deleted topics. */ + final Set deletedTopics = new HashSet<>(); + + /** + * Instantiates a new topics deleted condition. + * + * @param topics the topics + */ + private TopicsDeletedCondition(final String... topics) { + Collections.addAll(deletedTopics, topics); + } + + /** + * Condition met. + * + * @return true, if successful + */ + @Override + public boolean conditionMet() { + final Set allTopicsFromZk = new HashSet<>( + CollectionConverters.SetHasAsJava(broker.kafkaServer() + .zkClient().getAllTopicsInCluster(false)).asJava()); + + final Set allTopicsFromBrokerCache = new HashSet<>( + CollectionConverters.SeqHasAsJava(broker.kafkaServer() + .metadataCache().getAllTopics().toSeq()).asJava()); + + return !allTopicsFromZk.removeAll(deletedTopics) && !allTopicsFromBrokerCache.removeAll(deletedTopics); + } + } + + /** + * The Class TopicCreatedCondition. + */ + private final class TopicCreatedCondition implements TestCondition { + + /** The created topic. */ + final String createdTopic; + + /** + * Instantiates a new topic created condition. + * + * @param topic the topic + */ + private TopicCreatedCondition(final String topic) { + createdTopic = topic; + } + + /** + * Condition met. + * + * @return true, if successful + */ + @Override + public boolean conditionMet() { + return broker.kafkaServer().zkClient().getAllTopicsInCluster(false).contains(createdTopic) + && broker.kafkaServer().metadataCache().contains(createdTopic); + } + } +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackDoorKafkaConsumerIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackDoorKafkaConsumerIntegrationTest.java new file mode 100644 index 0000000..6181cfe --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackDoorKafkaConsumerIntegrationTest.java @@ -0,0 +1,434 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka.internal; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.eclipse.ecsp.entities.AbstractIgniteEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.handler.DeviceStatusBackDoorKafkaConsumer; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.Optional; +import java.util.Properties; + + +/** + * BackDoorKafkaConsumerIntegrationTest implements {@link KafkaStreamsApplicationTestBase}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/dma-connectionstatus-handler-test.properties") +public class BackDoorKafkaConsumerIntegrationTest extends KafkaStreamsApplicationTestBase { + + /** The conn status topic. */ + private static String connStatusTopic; + + /** The source topic name. */ + private static String sourceTopicName; + + /** The i. */ + private static int i = 0; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The back doorconsumer. */ + @Autowired + private DeviceStatusBackDoorKafkaConsumer backDoorconsumer; + + /** + * Subclasses should invoke this method in their @Before. + * + * @throws Exception when topic creation fails + */ + @Before + public void setup() throws Exception { + super.setup(); + i++; + sourceTopicName = "sourceTopic"; + connStatusTopic = DMAConstants.DEVICE_STATUS_TOPIC_PREFIX + serviceName.toLowerCase(); + createTopics(connStatusTopic, sourceTopicName, "dff-dfn-updates"); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, sourceTopicName); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "tc-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + } + + /** + * Tear down. + * + * @throws Exception the exception + */ + @After + public void tearDown() throws Exception { + shutDownApplication(); + } + + /** + * Start back door kafka consumer test. + * + * @throws Exception the exception + */ + @Test + public void startBackDoorKafkaConsumerTest() throws Exception { + + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, BackDoorTestServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + launchApplication(); + Thread.sleep(TestConstants.FIFTY_THOUSAND); + backDoorconsumer.shutdown(); + Thread.sleep(TestConstants.FIFTY_THOUSAND); + backDoorconsumer.setIgniteKeyTransformerImpl("org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + backDoorconsumer.setKafkaBootstrapServers(consumerProps.getProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG)); + backDoorconsumer.initializeProperties(); + TestCallBack callBack = new TestCallBack(); + + backDoorconsumer.setDeviceStatusTopicName(connStatusTopic); + backDoorconsumer.setDmaConsumerGroupId((i + "testBackDoorGroupId")); + backDoorconsumer.setDmaConsumerPoll(TestConstants.THREAD_SLEEP_TIME_10); + backDoorconsumer.setDmaAutoOffsetReset("latest"); + backDoorconsumer.setServiceName("TestService"); + backDoorconsumer.addCallback(callBack, 0); + backDoorconsumer.startDMABackDoorConsumer(); + Thread.sleep(TestConstants.FIFTY_THOUSAND); + String deviceId = "12345"; + String speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": " + + "{\"value\":20.0},\"MessageId\": " + + "\"1234\",\"CorrelationId\": \"1234\",\"BizTransactionId\": \"Biz1234\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, deviceId.getBytes(), speedEvent.getBytes()); + Thread.sleep(TestConstants.FIFTY_THOUSAND); + Assert.assertEquals(deviceId, callBack.getKey()); + Assert.assertEquals("Speed", callBack.getValue().getEventId()); + Assert.assertEquals("1.0", callBack.getValue().getVersion().getValue()); + Assert.assertEquals("1234", callBack.getValue().getMessageId()); + Assert.assertEquals("1234", callBack.getValue().getCorrelationId()); + Assert.assertEquals("Biz1234", callBack.getValue().getBizTransactionId()); + + backDoorconsumer.shutdown(); + shutDownApplication(); + } + + /** + * Test get instance. + * + * @throws Exception the exception + */ + @Test + public void testGetInstance() throws Exception { + + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, BackDoorTestServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + launchApplication(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + backDoorconsumer.setIgniteKeyTransformerImpl("org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + backDoorconsumer.setKafkaBootstrapServers(consumerProps.getProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG)); + backDoorconsumer + .setConnectionMsgValueTransformer("org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer"); + backDoorconsumer.initializeProperties(); + Assert.assertTrue(backDoorconsumer.getPayloadValueTransformer() instanceof DeviceMessageIgniteEventTransformer); + } + + /** + * Test get instance with exception. + * + * @throws Exception the exception + */ + @Test(expected = IllegalArgumentException.class) + public void testGetInstanceWithException() throws Exception { + + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, BackDoorTestServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + launchApplication(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + backDoorconsumer.setIgniteKeyTransformerImpl("org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + backDoorconsumer.setKafkaBootstrapServers(consumerProps.getProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG)); + backDoorconsumer + .setConnectionMsgValueTransformer("org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer"); + Mockito.when(ctx.getBean((Class) Mockito.any())).thenThrow(IllegalArgumentException.class); + backDoorconsumer.initializeProperties(); + } + + /** + * Stop and start back door kafka consumer. + * + * @throws Exception the exception + */ + @Test + public void stopAndStartBackDoorKafkaConsumer() throws Exception { + + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, BackDoorTestServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + launchApplication(); + Thread.sleep(TestConstants.HUNDRED_THOUSAND); + backDoorconsumer.shutdown(); + Thread.sleep(TestConstants.FIFTY_THOUSAND); + backDoorconsumer.setIgniteKeyTransformerImpl("org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + backDoorconsumer.setKafkaBootstrapServers(consumerProps.getProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG)); + backDoorconsumer.initializeProperties(); + TestCallBack callBack = new TestCallBack(); + backDoorconsumer.setDeviceStatusTopicName(connStatusTopic); + backDoorconsumer.setDmaConsumerGroupId((i + "testBackDoorGroupId")); + backDoorconsumer.setDmaConsumerPoll(TestConstants.THREAD_SLEEP_TIME_10); + backDoorconsumer.setDmaAutoOffsetReset("latest"); + backDoorconsumer.setServiceName("TestService"); + backDoorconsumer.addCallback(callBack, 0); + backDoorconsumer.startDMABackDoorConsumer(); + Thread.sleep(TestConstants.FIFTY_THOUSAND); + String deviceId = "12345"; + String speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": " + + "{\"value\":20.0},\"MessageId\": \"1234\"," + + "\"CorrelationId\": \"1234\",\"BizTransactionId\": \"Biz1234\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, deviceId.getBytes(), speedEvent.getBytes()); + Thread.sleep(TestConstants.FIFTY_THOUSAND); + Assert.assertEquals(deviceId, callBack.getKey()); + Assert.assertEquals("Speed", callBack.getValue().getEventId()); + Assert.assertEquals("1.0", callBack.getValue().getVersion().getValue()); + Assert.assertEquals("1234", callBack.getValue().getMessageId()); + Assert.assertEquals("1234", callBack.getValue().getCorrelationId()); + Assert.assertEquals("Biz1234", callBack.getValue().getBizTransactionId()); + + backDoorconsumer.shutdown(); + Thread.sleep(TestConstants.FIFTY_THOUSAND); + backDoorconsumer.addCallback(callBack, 0); + backDoorconsumer.startDMABackDoorConsumer(); + Thread.sleep(TestConstants.FIFTY_THOUSAND); + speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":21.0}," + + "\"MessageId\": \"1235\",\"CorrelationId\": \"1234\",\"BizTransactionId\": \"Biz1234\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, deviceId.getBytes(), speedEvent.getBytes()); + Thread.sleep(TestConstants.FIFTY_THOUSAND); + Assert.assertEquals(deviceId, callBack.getKey()); + Assert.assertEquals("Speed", callBack.getValue().getEventId()); + Assert.assertEquals("1.0", callBack.getValue().getVersion().getValue()); + Assert.assertEquals("1235", callBack.getValue().getMessageId()); + Assert.assertEquals("1234", callBack.getValue().getCorrelationId()); + Assert.assertEquals("Biz1234", callBack.getValue().getBizTransactionId()); + + backDoorconsumer.shutdown(); + shutDownApplication(); + } + + /** + * This stream processor implements {@link IgniteEventStreamProcessor}. + */ + public static final class BackDoorTestServiceProcessor implements IgniteEventStreamProcessor { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "BackDoorTestServiceProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + AbstractIgniteEvent event = (AbstractIgniteEvent) kafkaRecord.value(); + event.setDeviceRoutable(true); + kafkaRecord.withValue(event); + spc.forward(kafkaRecord); + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + // + } + + /** + * Close. + */ + @Override + public void close() { + // + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + // + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { sourceTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] {}; + } + } + + /** + * The Class TestCallBack. + */ + class TestCallBack implements BackdoorKafkaConsumerCallback { + + /** The key. */ + private String key; + + /** The value. */ + private IgniteEvent value; + + /** + * Gets the key. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Gets the value. + * + * @return the value + */ + public IgniteEvent getValue() { + return value; + } + + /** + * Process. + * + * @param key the key + * @param value the value + * @param meta the meta + */ + @Override + public void process(IgniteKey key, IgniteEvent value, OffsetMetadata meta) { + this.key = key.getKey().toString(); + this.value = value; + } + + /** + * Gets the committable offset. + * + * @return the committable offset + */ + @Override + public Optional getCommittableOffset() { + return Optional.empty(); + } + + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackDoorKafkaConsumerMockTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackDoorKafkaConsumerMockTest.java new file mode 100644 index 0000000..ddc88a1 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackDoorKafkaConsumerMockTest.java @@ -0,0 +1,490 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka.internal; + +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.common.Node; +import org.apache.kafka.common.PartitionInfo; +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.streams.KafkaStreams.State; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; + + +/** + * Test class to test the BackDoorKafkaConsumer. + */ +public class BackDoorKafkaConsumerMockTest { + + /** The mockito rule. */ + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + /** The topic. */ + String topic = "testtopic"; + + /** The backdoor kafka consumer. */ + @InjectMocks + private TestBackDoorKafkaConsumer backdoorKafkaConsumer = new TestBackDoorKafkaConsumer(); + + /** The topic offset dao. */ + @Mock + private BackdoorKafkaTopicOffsetDAOMongoImpl topicOffsetDao; + + /** The consumer. */ + @Mock + private KafkaConsumer consumer; + + /** The admin client. */ + @Mock + private AdminClient adminClient; + + /** The kafka consumer run executor. */ + @Mock + private ExecutorService kafkaConsumerRunExecutor; + + /** The offsets mgmt executor. */ + @Mock + private ScheduledExecutorService offsetsMgmtExecutor; + + /** + * Subclasses should invoke this method in their @Before. + */ + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + backdoorKafkaConsumer.setConsumer(consumer); + backdoorKafkaConsumer.setKafkaConsumerTopic(topic); + backdoorKafkaConsumer.addCallback(new TestCallBack(), 0); + backdoorKafkaConsumer.addCallback(new TestCallBack(), 1); + } + + /** + * Test remove consumer group. + */ + @Test + public void testRemoveConsumerGroup() { + backdoorKafkaConsumer.getClosed().set(false); + backdoorKafkaConsumer.getStartedConsumer().set(true); + backdoorKafkaConsumer.setKafkaAdminClient(adminClient); + backdoorKafkaConsumer.setOffsetsMgmtExecutor(offsetsMgmtExecutor); + backdoorKafkaConsumer.setKafkaConsumerRunExecutor(kafkaConsumerRunExecutor); + backdoorKafkaConsumer.shutdown(); + Mockito.verify(adminClient, Mockito.times(1)).deleteConsumerGroups(Mockito.anyList()); + } + + /** + * Test back door health monitor. + */ + @Test + public void testBackDoorHealthMonitor() { + Assert.assertFalse(backdoorKafkaConsumer.needsRestartOnFailure()); + Assert.assertFalse(backdoorKafkaConsumer.isEnabled()); + Assert.assertEquals("TestBackDoorKafkaConsumerMonitor", backdoorKafkaConsumer.monitorName()); + Assert.assertEquals("TestBackDoorKafkaConsumerGuage", backdoorKafkaConsumer.metricName()); + } + + /** + * Test reset kafka consumer offset. + */ + @Test + public void testResetKafkaConsumerOffset() { + List topicOffsetList = new ArrayList(); + BackdoorKafkaTopicOffset bkto1 = new BackdoorKafkaTopicOffset(topic, 0, TestConstants.THREAD_SLEEP_TIME_1000); + BackdoorKafkaTopicOffset bkto2 = new BackdoorKafkaTopicOffset(topic, 1, TestConstants.THREAD_SLEEP_TIME_2000); + topicOffsetList.add(bkto1); + topicOffsetList.add(bkto2); + Mockito.when(topicOffsetDao.getTopicOffsetList(topic)) + .thenReturn(topicOffsetList); + + List partitions = new ArrayList(); + Node leader = new Node(1, "host", TestConstants.HOST); + Node[] replicas = new Node[1]; + replicas[0] = leader; + PartitionInfo p1 = new PartitionInfo(topic, 0, leader, + replicas, replicas, replicas); + TopicPartition tp1 = new TopicPartition(topic, 0); + + PartitionInfo p2 = new PartitionInfo(topic, 1, leader, + replicas, replicas, replicas); + + partitions.add(p1); + partitions.add(p2); + + TopicPartition tp2 = new TopicPartition(topic, 1); + List topicPartitions = new ArrayList(); + topicPartitions.add(tp1); + topicPartitions.add(tp2); + + Mockito.when(consumer.partitionsFor(topic)) + .thenReturn(partitions); + + Map endOffsetMap = new HashMap(); + endOffsetMap.put(tp1, TestConstants.THREAD_SLEEP_TIME_1500); + endOffsetMap.put(tp2, TestConstants.THREAD_SLEEP_TIME_2500); + + Map beginningOffsetMap = new HashMap(); + beginningOffsetMap.put(tp1, TestConstants.THREAD_SLEEP_TIME_500); + beginningOffsetMap.put(tp2, TestConstants.THREAD_SLEEP_TIME_1500); + + Mockito.when(consumer.endOffsets(topicPartitions)) + .thenReturn(endOffsetMap); + Mockito.when(consumer.beginningOffsets(topicPartitions)) + .thenReturn(beginningOffsetMap); + + backdoorKafkaConsumer.resetKafkaConsumerOffset(); + + Mockito.verify(consumer, Mockito.times(1)).seek(tp1, TestConstants.THREAD_SLEEP_TIME_1000); + Mockito.verify(consumer, Mockito.times(1)).seek(tp2, TestConstants.THREAD_SLEEP_TIME_2000); + + } + + /** + * Test reset kafka consumer offset to beginning. + */ + @Test + public void testResetKafkaConsumerOffsetToBeginning() { + List topicOffsetList = new ArrayList(); + BackdoorKafkaTopicOffset bkto1 = new BackdoorKafkaTopicOffset(topic, 0, TestConstants.THREAD_SLEEP_TIME_100); + BackdoorKafkaTopicOffset bkto2 = new BackdoorKafkaTopicOffset(topic, 1, TestConstants.THREAD_SLEEP_TIME_200); + topicOffsetList.add(bkto1); + topicOffsetList.add(bkto2); + Mockito.when(topicOffsetDao.getTopicOffsetList(topic)) + .thenReturn(topicOffsetList); + + List partitions = new ArrayList(); + Node leader = new Node(1, "host", TestConstants.HOST); + Node[] replicas = new Node[1]; + replicas[0] = leader; + PartitionInfo p1 = new PartitionInfo(topic, 0, leader, + replicas, replicas, replicas); + TopicPartition tp1 = new TopicPartition(topic, 0); + + PartitionInfo p2 = new PartitionInfo(topic, 1, leader, + replicas, replicas, replicas); + partitions.add(p1); + partitions.add(p2); + List topicPartitions = new ArrayList(); + topicPartitions.add(tp1); + TopicPartition tp2 = new TopicPartition(topic, 1); + topicPartitions.add(tp2); + + Mockito.when(consumer.partitionsFor(topic)) + .thenReturn(partitions); + + Map endOffsetMap = new HashMap(); + endOffsetMap.put(tp1, TestConstants.THREAD_SLEEP_TIME_1500); + endOffsetMap.put(tp2, TestConstants.THREAD_SLEEP_TIME_2500); + + Map beginningOffsetMap = new HashMap(); + beginningOffsetMap.put(tp1, TestConstants.THREAD_SLEEP_TIME_500); + beginningOffsetMap.put(tp2, TestConstants.THREAD_SLEEP_TIME_1500); + + Mockito.when(consumer.endOffsets(topicPartitions)) + .thenReturn(endOffsetMap); + Mockito.when(consumer.beginningOffsets(topicPartitions)) + .thenReturn(beginningOffsetMap); + + backdoorKafkaConsumer.resetKafkaConsumerOffset(); + + Mockito.verify(consumer, Mockito.times(1)).seekToBeginning(Collections.singletonList(tp1)); + Mockito.verify(consumer, Mockito.times(1)).seekToBeginning(Collections.singletonList(tp2)); + + } + + /** + * Test reset kafka consumer offset to end. + */ + @Test + public void testResetKafkaConsumerOffsetToEnd() { + List topicOffsetList = new ArrayList(); + + Mockito.when(topicOffsetDao.getTopicOffsetList(topic)) + .thenReturn(topicOffsetList); + + List partitions = new ArrayList(); + Node leader = new Node(1, "host", TestConstants.HOST); + Node[] replicas = new Node[1]; + replicas[0] = leader; + PartitionInfo p1 = new PartitionInfo(topic, 0, leader, + replicas, replicas, replicas); + TopicPartition tp1 = new TopicPartition(topic, 0); + PartitionInfo p2 = new PartitionInfo(topic, 1, leader, + replicas, replicas, replicas); + partitions.add(p1); + partitions.add(p2); + List topicPartitions = new ArrayList(); + TopicPartition tp2 = new TopicPartition(topic, 1); + topicPartitions.add(tp1); + topicPartitions.add(tp2); + + Mockito.when(consumer.partitionsFor(topic)) + .thenReturn(partitions); + + Map endOffsetMap = new HashMap(); + endOffsetMap.put(tp1, TestConstants.THREAD_SLEEP_TIME_1500); + endOffsetMap.put(tp2, TestConstants.THREAD_SLEEP_TIME_2500); + + Map beginningOffsetMap = new HashMap(); + beginningOffsetMap.put(tp1, TestConstants.THREAD_SLEEP_TIME_500); + beginningOffsetMap.put(tp2, TestConstants.THREAD_SLEEP_TIME_1500); + + Mockito.when(consumer.endOffsets(topicPartitions)) + .thenReturn(endOffsetMap); + Mockito.when(consumer.beginningOffsets(topicPartitions)) + .thenReturn(beginningOffsetMap); + + backdoorKafkaConsumer.resetKafkaConsumerOffset(); + + Mockito.verify(consumer, Mockito.times(1)).seekToEnd(Collections.singletonList(tp1)); + Mockito.verify(consumer, Mockito.times(1)).seekToEnd(Collections.singletonList(tp2)); + + } + + /** + * The Class TestCallBack. + */ + class TestCallBack implements BackdoorKafkaConsumerCallback { + + /** The key. */ + private String key; + + /** The value. */ + private IgniteEvent value; + + /** + * Gets the key. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Gets the value. + * + * @return the value + */ + public IgniteEvent getValue() { + return value; + } + + /** + * Process. + * + * @param key the key + * @param value the value + * @param meta the meta + */ + @Override + public void process(IgniteKey key, IgniteEvent value, OffsetMetadata meta) { + this.key = key.getKey().toString(); + this.value = value; + } + + /** + * Gets the committable offset. + * + * @return the committable offset + */ + @Override + public Optional getCommittableOffset() { + return Optional.empty(); + } + + } + + /** + * The Class TestBackDoorKafkaConsumer. + */ + private class TestBackDoorKafkaConsumer extends BackdoorKafkaConsumer { + + /** The resetcomplete. */ + private boolean resetcomplete = false; + + /** The streamstate. */ + private State streamstate = State.RUNNING; + + /** + * Gets the name. + * + * @return the name + */ + @Override + public String getName() { + return "test"; + } + + /** + * Gets the kafka consumer group id. + * + * @return the kafka consumer group id + */ + @Override + public String getKafkaConsumerGroupId() { + return "testGrpId"; + } + + /** + * Gets the kafka consumer topic. + * + * @return the kafka consumer topic + */ + @Override + public String getKafkaConsumerTopic() { + return topic; + } + + /** + * Gets the poll. + * + * @return the poll + */ + @Override + public long getPoll() { + return TestConstants.THREAD_SLEEP_TIME_10; + } + + /** + * Checks if is offsets reset complete. + * + * @return true, if is offsets reset complete + */ + @Override + public boolean isOffsetsResetComplete() { + return resetcomplete; + } + + /** + * Sets the reset offsets. + * + * @param reset the new reset offsets + */ + @Override + public void setResetOffsets(boolean reset) { + resetcomplete = reset; + + } + + /** + * Gets the stream state. + * + * @return the stream state + */ + @Override + public State getStreamState() { + return streamstate; + } + + /** + * Sets the stream state. + * + * @param newState the new stream state + */ + @Override + public void setStreamState(State newState) { + streamstate = newState; + + } + + /** + * Monitor name. + * + * @return the string + */ + @Override + public String monitorName() { + return "TestBackDoorKafkaConsumerMonitor"; + } + + /** + * Needs restart on failure. + * + * @return true, if successful + */ + @Override + public boolean needsRestartOnFailure() { + return false; + } + + /** + * Metric name. + * + * @return the string + */ + @Override + public String metricName() { + return "TestBackDoorKafkaConsumerGuage"; + } + + /** + * Checks if is enabled. + * + * @return true, if is enabled + */ + @Override + public boolean isEnabled() { + return false; + } + + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackDoorKafkaConsumerTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackDoorKafkaConsumerTest.java new file mode 100644 index 0000000..bb062ed --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackDoorKafkaConsumerTest.java @@ -0,0 +1,259 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka.internal; + +import org.apache.kafka.clients.CommonClientConfigs; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.KafkaAdminClient; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.config.SslConfigs; +import org.apache.kafka.common.config.internals.BrokerSecurityConfigs; +import org.apache.kafka.common.serialization.Serdes; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.handler.DeviceStatusBackDoorKafkaConsumer; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; + + +/** + * BackDoorKafkaConsumerTest implements {@link KafkaStreamsApplicationTestBase}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/dma-backdoor-consumer-test.properties") +public class BackDoorKafkaConsumerTest extends KafkaStreamsApplicationTestBase { + + /** The device conn status topic. */ + private String deviceConnStatusTopic = "device-status-test"; + + /** The i. */ + private static int i; + + /** The consumer. */ + @Autowired + private DeviceStatusBackDoorKafkaConsumer consumer; + + /** + * Subclasses should invoke this method in their @Before. + * + * @throws Exception the exception + */ + @Before + public void setUp() throws Exception { + i++; + deviceConnStatusTopic = deviceConnStatusTopic + i; + super.setup(); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + createTopics((deviceConnStatusTopic)); + } + + /** + * Test initialize properties. + */ + @Test + public void testInitializeProperties() { + // below 2 lines have been added just to pass the test case. + AdminClient client = KafkaAdminClient.create(consumerProps); + consumer.setKafkaBootstrapServers("localhost:9092"); + consumer.setKafkaAdminClient(client); + consumer.setIgniteKeyTransformerImpl("org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + + consumer.initializeProperties(); + Properties props = consumer.getKafkaConsumerProps(); + + Assert.assertEquals(Serdes.ByteArray().deserializer().getClass().getName(), + props.get(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG)); + Assert.assertEquals(Serdes.ByteArray().deserializer().getClass().getName(), + props.get(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG)); + Assert.assertNotNull(props.get(SslConfigs.SSL_KEYSTORE_LOCATION_CONFIG)); + Assert.assertNotNull(props.get(SslConfigs.SSL_KEYSTORE_PASSWORD_CONFIG)); + Assert.assertNotNull(props.get(SslConfigs.SSL_KEY_PASSWORD_CONFIG)); + Assert.assertNotNull(props.get(SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG)); + Assert.assertNotNull(props.get(SslConfigs.SSL_TRUSTSTORE_PASSWORD_CONFIG)); + Assert.assertEquals("required", props.get(BrokerSecurityConfigs.SSL_CLIENT_AUTH_CONFIG)); + Assert.assertEquals("SSL", props.get(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG)); + } + + /** + * Test ignite key transformer impl missing. + */ + @Test(expected = IllegalArgumentException.class) + public void testIgniteKeyTransformerImplMissing() { + consumer.setKafkaAdminClient(null); + consumer.setIgniteKeyTransformerImpl(null); + consumer.initializeProperties(); + } + + /** + * Test group id missing. + */ + @Test(expected = RuntimeException.class) + public void testGroupIdMissing() { + DeviceStatusBackDoorKafkaConsumer consumer = new DeviceStatusBackDoorKafkaConsumer(); + consumer.setIgniteKeyTransformerImpl("org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + consumer.setKafkaBootstrapServers("localhost:9092"); + + consumer.initializeProperties(); + TestCallBack callBack = new TestCallBack(); + + consumer.addCallback(callBack, 0); + consumer.setDeviceStatusTopicName(deviceConnStatusTopic); + consumer.setDmaConsumerPoll(TestConstants.THREAD_SLEEP_TIME_10); + consumer.setDmaAutoOffsetReset("latest"); + consumer.setServiceName("TestService"); + consumer.startBackDoorKafkaConsumer(); + } + + /** + * Test topic missing. + */ + @Test(expected = RuntimeException.class) + public void testTopicMissing() { + DeviceStatusBackDoorKafkaConsumer consumer = new DeviceStatusBackDoorKafkaConsumer(); + consumer.setIgniteKeyTransformerImpl("org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + consumer.setKafkaBootstrapServers("localhost:9092"); + + consumer.initializeProperties(); + TestCallBack callBack = new TestCallBack(); + + consumer.addCallback(callBack, 0); + consumer.setDmaConsumerGroupId((i + "testBackDoorGroupId")); + consumer.setDmaConsumerPoll(TestConstants.THREAD_SLEEP_TIME_10); + consumer.setDmaAutoOffsetReset("latest"); + consumer.setServiceName("TestService"); + consumer.startBackDoorKafkaConsumer(); + } + + /** + * Test when no call backs registered. + */ + @Test + public void testWhenNoCallBacksRegistered() { + consumer.setIgniteKeyTransformerImpl("org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + consumer.setKafkaBootstrapServers("localhost:9092"); + consumer.initializeProperties(); + consumer.setDeviceStatusTopicName(deviceConnStatusTopic); + consumer.setDmaConsumerGroupId((i + "testBackDoorGroupId")); + consumer.setDmaConsumerPoll(TestConstants.THREAD_SLEEP_TIME_10); + consumer.setDmaAutoOffsetReset("latest"); + consumer.setServiceName("TestService"); + consumer.startBackDoorKafkaConsumer(); + ConcurrentHashMap callbacks + = (ConcurrentHashMap) ReflectionTestUtils + .getField(consumer, "callBackMap"); + Assert.assertEquals(0, callbacks.size()); + } + + /** + * The Class TestCallBack. + */ + class TestCallBack implements BackdoorKafkaConsumerCallback { + + /** The key. */ + private String key; + + /** The value. */ + private IgniteEvent value; + + /** + * Gets the key. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Gets the value. + * + * @return the value + */ + public IgniteEvent getValue() { + return value; + } + + /** + * Process. + * + * @param key the key + * @param value the value + * @param meta the meta + */ + @Override + public void process(IgniteKey key, IgniteEvent value, OffsetMetadata meta) { + this.key = key.getKey().toString(); + this.value = value; + } + + /** + * Gets the committable offset. + * + * @return the committable offset + */ + @Override + public Optional getCommittableOffset() { + return Optional.empty(); + } + + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffsetDAOMongoImplTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffsetDAOMongoImplTest.java new file mode 100644 index 0000000..93c8777 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffsetDAOMongoImplTest.java @@ -0,0 +1,120 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka.internal; + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.BackdoorKafkaTopicOffset; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.BackdoorKafkaTopicOffsetDAOMongoImpl; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.List; + + +/** + * BackdoorKafkaTopicOffsetDAOMongoImplTest immplements {@link KafkaStreamsApplicationTestBase}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@TestPropertySource("/backdoor-dao-test.properties") +public class BackdoorKafkaTopicOffsetDAOMongoImplTest extends KafkaStreamsApplicationTestBase { + + /** The kafka topic. */ + private String kafkaTopic = "kafkaTopic"; + + /** The service. */ + private String service = "service"; + + /** The partition. */ + private int partition = 1; + + /** The offset. */ + private long offset = 1000L; + + /** The topic offset. */ + private BackdoorKafkaTopicOffset topicOffset; + + /** The backdoor dao. */ + @Autowired + private BackdoorKafkaTopicOffsetDAOMongoImpl backdoorDao; + + /** + * Setup. + */ + @Before + public void setup() { + topicOffset = new BackdoorKafkaTopicOffset(kafkaTopic, partition, offset); + } + + /** + * Test get backdoor kafka topic offset list. + */ + @Test + public void testGetBackdoorKafkaTopicOffsetList() { + backdoorDao.save(topicOffset); + List list = backdoorDao.getTopicOffsetList(kafkaTopic); + Assert.assertEquals(1, list.size()); + Assert.assertEquals(topicOffset, list.get(0)); + } + + /** + * Test save. + */ + @Test + public void testSave() { + backdoorDao.save(topicOffset); + Assert.assertEquals(topicOffset, backdoorDao.findById(topicOffset.getId())); + + long offsetTmp = TestConstants.THREAD_SLEEP_TIME_2000; + topicOffset.setOffset(offsetTmp); + backdoorDao.save(topicOffset); + Assert.assertEquals(topicOffset, backdoorDao.findById(topicOffset.getId())); + Assert.assertEquals(topicOffset.getOffset(), offsetTmp); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffsetTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffsetTest.java new file mode 100644 index 0000000..63e7687 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/internal/BackdoorKafkaTopicOffsetTest.java @@ -0,0 +1,183 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka.internal; + +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.BackdoorKafkaTopicOffset; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.util.concurrent.ConcurrentHashMap; + + +/** + * BackdoorKafkaTopicOffsetTest is a Test class for BackdoorKafkaTopicOffset class. + */ +public class BackdoorKafkaTopicOffsetTest { + + /** The kafka topic. */ + private String kafkaTopic = "kafkaTopic"; + + /** The service. */ + private String service = "service"; + + /** The partition. */ + private int partition = 1; + + /** The offset. */ + private long offset = 1000L; + + /** The topic offset. */ + private BackdoorKafkaTopicOffset topicOffset; + + /** + * Setup. + */ + @Before + public void setup() { + topicOffset = new BackdoorKafkaTopicOffset(kafkaTopic, partition, offset); + } + + /** + * Test backdoor kafka topic offset. + */ + @Test + public void testBackdoorKafkaTopicOffset() { + String id = new StringBuilder().append(kafkaTopic).append(":") + .append(partition).toString(); + Assert.assertEquals(id, topicOffset.getId()); + Assert.assertEquals(kafkaTopic, topicOffset.getKafkaTopic()); + Assert.assertEquals(partition, topicOffset.getPartition()); + Assert.assertEquals(offset, topicOffset.getOffset()); + } + + /** + * Test backdoor kafka topic offset copy constructor. + */ + @Test + public void testBackdoorKafkaTopicOffsetCopyConstructor() { + ConcurrentHashMap persistOffsetMap = new ConcurrentHashMap<>(); + Integer key = 1; + BackdoorKafkaTopicOffset newTopicOffset = new BackdoorKafkaTopicOffset(kafkaTopic, partition, offset); + persistOffsetMap.put(key, newTopicOffset); + BackdoorKafkaTopicOffset newTopicOffsetCopy = new BackdoorKafkaTopicOffset(newTopicOffset); + newTopicOffset.setOffset(TestConstants.THREAD_SLEEP_TIME_2000); + + Assert.assertNotEquals(persistOffsetMap.get(key).getOffset(), newTopicOffsetCopy.getOffset()); + Assert.assertNotEquals(newTopicOffset.getOffset(), newTopicOffsetCopy.getOffset()); + + Assert.assertEquals(persistOffsetMap.get(key).getOffset(), newTopicOffset.getOffset()); + BackdoorKafkaTopicOffset newTopicOffsetDuplicate = newTopicOffset; + Assert.assertEquals(newTopicOffsetDuplicate.getOffset(), newTopicOffset.getOffset()); + } + + /** + * Test set id. + */ + @Test + public void testSetId() { + topicOffset.setId("id"); + Assert.assertEquals("id", topicOffset.getId()); + } + + /** + * Test set kafka topic. + */ + @Test + public void testSetKafkaTopic() { + topicOffset.setKafkaTopic("newTopic"); + Assert.assertEquals("newTopic", topicOffset.getKafkaTopic()); + } + + /** + * Test set partition. + */ + @Test + public void testSetPartition() { + topicOffset.setPartition(TestConstants.THREAD_SLEEP_TIME_50); + Assert.assertEquals(TestConstants.THREAD_SLEEP_TIME_50, topicOffset.getPartition()); + } + + /** + * Test set offset. + */ + @Test + public void testSetOffset() { + topicOffset.setOffset(TestConstants.THREAD_SLEEP_TIME_5000); + Assert.assertEquals(TestConstants.THREAD_SLEEP_TIME_5000, topicOffset.getOffset()); + } + + /** + * Test to string. + */ + @Test + public void testToString() { + String id = new StringBuilder().append(kafkaTopic).append(":") + .append(partition).toString(); + String toStr = "BackdoorKafkaTopicOffset [getId()=" + id + + ", getKafkaTopic()=" + kafkaTopic + ", getPartition()=" + + partition + ", getOffset()=" + offset + "]"; + Assert.assertEquals(toStr, topicOffset.toString()); + } + + /** + * Test equals. + */ + @Test + public void testEquals() { + BackdoorKafkaTopicOffset newTopicOffset = new BackdoorKafkaTopicOffset(kafkaTopic, partition, offset); + Assert.assertEquals(newTopicOffset, topicOffset); + newTopicOffset = new BackdoorKafkaTopicOffset(kafkaTopic, partition, TestConstants.THREAD_SLEEP_TIME_5000); + Assert.assertNotEquals(newTopicOffset, topicOffset); + } + + /** + * Test hash code. + */ + @Test + public void testHashCode() { + BackdoorKafkaTopicOffset newTopicOffset = new BackdoorKafkaTopicOffset(kafkaTopic, partition, offset); + Assert.assertEquals(newTopicOffset.hashCode(), topicOffset.hashCode()); + newTopicOffset = new BackdoorKafkaTopicOffset(kafkaTopic, partition, TestConstants.THREAD_SLEEP_TIME_5000); + Assert.assertNotEquals(newTopicOffset.hashCode(), topicOffset.hashCode()); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/KafkaStreamsThreadStatusPrinterTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/KafkaStreamsThreadStatusPrinterTest.java new file mode 100644 index 0000000..4627da9 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/KafkaStreamsThreadStatusPrinterTest.java @@ -0,0 +1,116 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka.support; + +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.streams.KafkaStreams; +import org.apache.kafka.streams.TaskMetadata; +import org.apache.kafka.streams.ThreadMetadata; +import org.apache.kafka.streams.processor.TaskId; +import org.apache.kafka.streams.processor.internals.TaskMetadataImpl; +import org.apache.kafka.streams.processor.internals.ThreadMetadataImpl; +import org.eclipse.ecsp.analytics.stream.base.kafka.support.KafkaStreamsThreadStatusPrinter; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import static java.util.concurrent.CompletableFuture.delayedExecutor; +import static java.util.concurrent.CompletableFuture.runAsync; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + + +/** + * test class {@link KafkaStreamsThreadStatusPrinterTest}. + */ +public class KafkaStreamsThreadStatusPrinterTest { + + /** The mockito rule. */ + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + /** The ks. */ + @Mock + private KafkaStreams ks; + + /** + * Test no exceptions. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testNoExceptions() throws InterruptedException { + KafkaStreamsThreadStatusPrinter tsp = new KafkaStreamsThreadStatusPrinter(); + Mockito.when(ks.metadataForLocalThreads()).thenReturn(createThreadMetadata()); + tsp.init(ks); + runAsync(() -> {}, delayedExecutor(Constants.THREAD_SLEEP_TIME_500, MILLISECONDS)).join(); + Mockito.verify(ks, Mockito.atLeastOnce()).metadataForLocalThreads(); + tsp.close(); + } + + /** + * Creates the thread metadata. + * + * @return the sets the + */ + private Set createThreadMetadata() { + Set tps = new HashSet<>(); + tps.add(new TopicPartition("topic1", 1)); + Set activeTasks = new HashSet<>(); + activeTasks.add(new TaskMetadataImpl(new TaskId(1, 1, "taskid1"), tps, Collections.emptyMap(), + Collections.emptyMap(), Optional.of(Long.valueOf(System.currentTimeMillis())))); + Set producerClientIds = new HashSet<>(); + producerClientIds.add("producerClientId"); + ThreadMetadata tm = new ThreadMetadataImpl("thread1", "active", "mainConsumerClientId", + "restoreConsumerClientId", producerClientIds, "adminClientId", activeTasks, Collections.emptySet()); + Set tms = new HashSet<>(); + tms.add(tm); + return tms; + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/LoggingStateRestoreListenerTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/LoggingStateRestoreListenerTest.java new file mode 100644 index 0000000..290d87a --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/kafka/support/LoggingStateRestoreListenerTest.java @@ -0,0 +1,68 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.kafka.support; + +import org.apache.kafka.common.TopicPartition; +import org.eclipse.ecsp.analytics.stream.base.kafka.support.LoggingStateRestoreListener; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.junit.Test; +import org.junit.jupiter.api.Assertions; + + +/** + * class {@link LoggingStateRestoreListenerTest}. + */ +public class LoggingStateRestoreListenerTest { + + /** + * Test no exceptions. + */ + @Test + public void testNoExceptions() { + LoggingStateRestoreListener lsrl = new LoggingStateRestoreListener(); + lsrl.onRestoreStart(new TopicPartition("abcd", 1), "store1", + Constants.THREAD_SLEEP_TIME_100, Constants.THREAD_SLEEP_TIME_1000); + lsrl.onBatchRestored(new TopicPartition("abcd", 1), "store1", + Constants.THREAD_SLEEP_TIME_200, Constants.THREAD_SLEEP_TIME_100); + + Assertions.assertDoesNotThrow(() -> + lsrl.onRestoreEnd(new TopicPartition("abcd", 1), "store1", Constants.THREAD_SLEEP_TIME_100)); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/CumulativeLoggerUnitTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/CumulativeLoggerUnitTest.java new file mode 100644 index 0000000..f2f7c40 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/CumulativeLoggerUnitTest.java @@ -0,0 +1,116 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.metrics.reporter; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.metrics.reporter.CumulativeLogger; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicLong; + + +/** + * test class for {@link CumulativeLogger}. + */ +public class CumulativeLoggerUnitTest { + + /** The Constant COUNTER_1. */ + private static final String COUNTER_1 = "counter1"; + + /** The prop. */ + Properties prop; + + /** The cumulative logger. */ + private CumulativeLogger cumulativeLogger; + + /** + * setup method for initializing properties. + */ + @Before + public void setup() { + prop = new Properties(); + cumulativeLogger = CumulativeLogger.getLogger(); + ReflectionTestUtils.setField(cumulativeLogger, "logEveryXMinute", 1); + prop.put(PropertyNames.LOG_COUNTS_MINUTES, "1"); + } + + /** + * Test increment. + * + * @throws InterruptedException the interrupted exception + */ + @SuppressWarnings("unchecked") + @Test + public void testIncrement() throws InterruptedException { + CumulativeLogger.init(prop); + int count = 0; + int incrementBy = 1; + cumulativeLogger.incrementBy(COUNTER_1, incrementBy); + count += incrementBy; + + Map stateMap = (Map) ReflectionTestUtils.getField(cumulativeLogger, + "state"); + Assert.assertNotNull(stateMap); + Assert.assertEquals(count, stateMap.get(COUNTER_1).get()); + + cumulativeLogger.incrementByOne(COUNTER_1); + count += 1; + stateMap = (Map) ReflectionTestUtils.getField(cumulativeLogger, "state"); + Assert.assertNotNull(stateMap); + Assert.assertEquals(count, stateMap.get(COUNTER_1).get()); + } + + /** + * Test incorrect log duration value. + * + * @throws IllegalArgumentException the illegal argument exception + */ + @Test(expected = IllegalArgumentException.class) + public void testIncorrectLogDurationValue() throws IllegalArgumentException { + + prop.put(PropertyNames.LOG_COUNTS_MINUTES, "0"); + CumulativeLogger.init(prop); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/HarmanRocksDBMetricsExporterTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/HarmanRocksDBMetricsExporterTest.java new file mode 100644 index 0000000..5d38d94 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/metrics/reporter/HarmanRocksDBMetricsExporterTest.java @@ -0,0 +1,165 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.metrics.reporter; + +import org.apache.kafka.common.MetricName; +import org.apache.kafka.common.metrics.KafkaMetric; +import org.apache.kafka.common.metrics.Measurable; +import org.apache.kafka.common.metrics.MetricConfig; +import org.apache.kafka.common.utils.Time; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.ThreadUtils; +import org.eclipse.ecsp.utils.metrics.IgniteRocksDBGuage; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.rocksdb.RocksDB; +import org.rocksdb.RocksDBException; +import org.rocksdb.Status; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + + +/** + * Test class for {@link HarmanRocksDBMetricsExporter}. + */ +public class HarmanRocksDBMetricsExporterTest { + + /** The exporter. */ + @InjectMocks + private HarmanRocksDBMetricsExporter exporter = new HarmanRocksDBMetricsExporter(); + + /** The rocksdb guage. */ + private IgniteRocksDBGuage rocksdbGuage = mock(IgniteRocksDBGuage.class); + + /** The db. */ + @Mock + private RocksDB db; + + /** The exception. */ + @Mock + private RocksDBException exception; + + /** The status. */ + @Mock + private Status status; + + /** The utils. */ + @Mock + private ThreadUtils utils; + + /** + * Test init. + */ + @Test + public void testInit() { + ReflectionTestUtils.setField(exporter, "prometheusEnabled", true); + ReflectionTestUtils.setField(exporter, "rocksDBMetricsEnabled", true); + List metricsList = new ArrayList<>(); + metricsList.add("compaction-pending"); + metricsList.add("background-errors"); + metricsList.add("size-all-mem-tables"); + ReflectionTestUtils.setField(exporter, "rocksDBMetricsList", metricsList); + ReflectionTestUtils.setField(exporter, "rocksdbGuage", rocksdbGuage); + ReflectionTestUtils.invokeMethod(exporter, "init"); + + Mockito.verify(rocksdbGuage, Mockito.times(1)).setup(); + metricsList = (List) ReflectionTestUtils.getField(exporter, "rocksDBMetricsList"); + metricsList.forEach(metricName -> Assert.assertTrue(metricName.startsWith("rocksdb."))); + } + + /** + * Test fetch metrics. + * + * @throws RocksDBException the rocks DB exception + */ + @Test + public void testFetchMetrics() throws RocksDBException { + List metricsList = new ArrayList<>(); + metricsList.add("compaction-pending"); + metricsList.add("background-errors"); + metricsList.add("size-all-mem-tables"); + ReflectionTestUtils.setField(exporter, "rocksDBMetricsList", metricsList); + ReflectionTestUtils.invokeMethod(exporter, "prefix"); + + db = Mockito.mock(RocksDB.class); + ReflectionTestUtils.setField(exporter, "rocksdbGuage", rocksdbGuage); + ReflectionTestUtils.invokeMethod(exporter, "setRocksDB", db); + ReflectionTestUtils.setField(exporter, "isValidList", true); + exporter.fetchMetrics(); + + Mockito.verify(db, Mockito.times(Constants.THREE)).getLongProperty(Mockito.anyString()); + Mockito.verify(rocksdbGuage, Mockito.times(Constants.THREE)).set(Mockito.anyDouble(), Mockito.any()); + } + + /** + * Test publish metrics. + */ + @Test + public void testPublishMetrics() { + ReflectionTestUtils.setField(exporter, "prometheusEnabled", true); + ReflectionTestUtils.setField(exporter, "rocksdbGuage", rocksdbGuage); + + Measurable measurable = new Measurable() { + @Override + public double measure(MetricConfig config, long now) { + return 0; + } + }; + KafkaMetric metric1 = new KafkaMetric(new Object(), + new MetricName("test", "stream-state-metrics", "test", new HashMap<>()), + measurable, new MetricConfig(), Time.SYSTEM); + List kafkaMetrics = Arrays.asList(metric1); + exporter.publishMetrics(kafkaMetrics); + verify(rocksdbGuage).set(anyDouble(), anyString(), any(), any()); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/DeviceMessageUtilsTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/DeviceMessageUtilsTest.java new file mode 100644 index 0000000..f7c8d7e --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/DeviceMessageUtilsTest.java @@ -0,0 +1,102 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.mqtt; + +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator; +import org.eclipse.ecsp.domain.DeviceMessageFailureEventDataV1_0; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.handler.DeviceMessageUtils; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.ArgumentMatchers.any; + + +/** + * {@link DeviceMessageUtils} test class for {@link DeviceMessageUtilsTest}. + */ +public class DeviceMessageUtilsTest { + + /** The device message utils. */ + @InjectMocks + DeviceMessageUtils deviceMessageUtils = new DeviceMessageUtils(); + + /** The global message id generator. */ + @Mock + GlobalMessageIdGenerator globalMessageIdGenerator = Mockito.mock(GlobalMessageIdGenerator.class); + + /** The spc. */ + @Mock + private StreamProcessingContext spc = Mockito.mock(StreamProcessingContext.class); + + /** The key. */ + @Mock + private IgniteKey key = Mockito.mock(IgniteKey.class); + + /** + * Testpost failure event. + */ + @Test + public void testpostFailureEvent() { + ReflectionTestUtils.setField(deviceMessageUtils, "msgIdGenerator", globalMessageIdGenerator); + IgniteEventImpl igniteEventImpl = new IgniteEventImpl(); + igniteEventImpl.setEventId("123"); + igniteEventImpl.setBizTransactionId("vjhsv"); + igniteEventImpl.setVehicleId("vehicle12"); + igniteEventImpl.setTimezone((short) TestConstants.INT_1343678902); + DeviceMessageFailureEventDataV1_0 messageFailureEventDataV10 = new DeviceMessageFailureEventDataV1_0(); + messageFailureEventDataV10.setDeviceDeliveryCutoffExceeded(true); + messageFailureEventDataV10.setFailedIgniteEvent(igniteEventImpl); + + Mockito.when(globalMessageIdGenerator.generateUniqueMsgId( + Mockito.any())).thenReturn("123"); + String feedbackTopic = "feedbackTopic"; + deviceMessageUtils.postFailureEvent(messageFailureEventDataV10, + key, spc, feedbackTopic); + Mockito.verify(spc, Mockito.times(1)) + .forwardDirectly((IgniteKey) any(), (IgniteEventImpl) any(), Mockito.anyString()); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/HiveMQEmbeddedMQTTServerTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/HiveMQEmbeddedMQTTServerTest.java new file mode 100644 index 0000000..81a29c7 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/HiveMQEmbeddedMQTTServerTest.java @@ -0,0 +1,159 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.mqtt; + +import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3Publish; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static java.util.concurrent.CompletableFuture.delayedExecutor; +import static java.util.concurrent.CompletableFuture.runAsync; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.Assert.assertEquals; + + +/** + * class HiveMQEmbeddedMQTTServerTest extends HiveMQTestContainer. + */ +public class HiveMQEmbeddedMQTTServerTest extends HiveMQTestContainer { + + /** The Constant MQTT_SERVER. */ + @ClassRule + public static final MqttServer MQTT_SERVER = new MqttServer(); + + /** The Constant TOPIC. */ + private static final String TOPIC = "test"; + + /** The Constant PAYLOAD. */ + private static final String PAYLOAD = "testPayload"; + + /** The mqtt messages. */ + protected Map> mqttMessages = new HashMap<>(); + + /** + * Subscribe to topic. + * + * @throws InterruptedException the interrupted exception + */ + @Before + public void subscribeToTopic() throws InterruptedException { + this.subscribeHiveMQClientToMqttTopic(TOPIC); + } + + /** + * Test single message. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testSingleMessage() throws InterruptedException { + super.publishToTopic(TOPIC, PAYLOAD.getBytes()); + List messages = getMessagesFromMqttTopic(TOPIC, 1, Constants.THREAD_SLEEP_TIME_10000); + assertEquals("Expected payload is different", PAYLOAD, new String(messages.get(0))); + } + + /** + * Test multiple messages. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testMultipleMessages() throws InterruptedException { + super.publishToTopic(TOPIC, PAYLOAD.getBytes()); + super.publishToTopic(TOPIC, PAYLOAD.getBytes()); + List messages = getMessagesFromMqttTopic(TOPIC, Constants.TWO, Constants.THREAD_SLEEP_TIME_10000); + assertEquals("Expected payload is different", PAYLOAD, new String(messages.get(0))); + assertEquals("Expected payload is different", PAYLOAD, new String(messages.get(1))); + super.after(); + } + + /** + * Subscribe hive MQ client to mqtt topic. + * + * @param topic the topic + * @throws InterruptedException the interrupted exception + */ + private void subscribeHiveMQClientToMqttTopic(String topic) throws InterruptedException { + Consumer publishConsumer = (publish) -> { + if (mqttMessages.containsKey(topic)) { + mqttMessages.get(topic).add(publish.getPayloadAsBytes()); + } else { + List messages = new ArrayList<>(); + messages.add(publish.getPayloadAsBytes()); + mqttMessages.put(topic, messages); + } + }; + super.subscribeToTopic(TOPIC, publishConsumer); + } + + /** + * Gets the messages from mqtt topic. + * + * @param topic the topic + * @param n the n + * @param waitTime the wait time + * @return the messages from mqtt topic + * @throws InterruptedException the interrupted exception + */ + protected List getMessagesFromMqttTopic(String topic, int n, int waitTime) + throws InterruptedException { + int timeWaited = 0; + int increment = Constants.THREAD_SLEEP_TIME_2000; + List messages = new ArrayList<>(); + while ((messages.size() < n) && (timeWaited <= waitTime)) { + List payloads = mqttMessages.get(topic); + if (null != payloads) { + messages.addAll(payloads); + } + runAsync(() -> { + }, delayedExecutor(increment, MILLISECONDS)).join(); + timeWaited = timeWaited + increment; + } + return messages; + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/HiveMQTestContainer.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/HiveMQTestContainer.java new file mode 100644 index 0000000..47b0e06 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/HiveMQTestContainer.java @@ -0,0 +1,187 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.mqtt; + +import com.hivemq.client.internal.mqtt.lifecycle.MqttClientAutoReconnectImpl; +import com.hivemq.client.mqtt.MqttClient; +import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient; +import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3Publish; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + + +/** + * HiveMQTestContainer. + */ +public class HiveMQTestContainer { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(HiveMQTestContainer.class); + + /** The subscribe mqtt client. */ + private Mqtt3AsyncClient subscribeMqttClient; + + /** The publish mqtt client. */ + private Mqtt3AsyncClient publishMqttClient; + + /** + * HiveMQTestContainer(). + */ + public HiveMQTestContainer() { + try { + logger.info("MQTT Server started"); + subscribeMqttClient = MqttClient.builder().identifier(UUID.randomUUID().toString()) + .serverHost("localhost").serverPort(Constants.INT_1883) + .useMqttVersion3().automaticReconnect(MqttClientAutoReconnectImpl.DEFAULT) + .addConnectedListener(context -> logger.info("connected subscriber")) + .addDisconnectedListener(context -> logger.info("disconnected subscriber")) + .transportConfig().mqttConnectTimeout(Constants.FIVE, TimeUnit.MINUTES) + .socketConnectTimeout(Constants.FIVE, TimeUnit.MINUTES).applyTransportConfig() + .buildAsync(); + publishMqttClient = MqttClient.builder().identifier(UUID.randomUUID().toString()) + .serverHost("localhost").serverPort(Constants.INT_1883).useMqttVersion3() + .addConnectedListener(context -> logger.info("connected publisher")) + .addDisconnectedListener(context -> logger.info("disconnected publisher")) + .transportConfig().mqttConnectTimeout(Constants.FIVE, TimeUnit.MINUTES) + .socketConnectTimeout(Constants.FIVE, TimeUnit.MINUTES).applyTransportConfig() + .automaticReconnect(MqttClientAutoReconnectImpl.DEFAULT) + .buildAsync(); + } catch (Exception ex) { + throw new RuntimeException("Failed to create MQTT client", ex); + } + } + + /** + * After. + */ + protected void after() { + if (null != subscribeMqttClient) { + try { + subscribeMqttClient.disconnect(); + publishMqttClient.disconnect(); + subscribeMqttClient = null; + publishMqttClient = null; + } catch (Exception e) { + logger.error("Failed to close MQTT Client", e); + } + } + logger.info("MQTT Server stopped"); + } + + /** + * subscribeToTopic(). + * + * @param topic topic + * @param callback callback + * @throws InterruptedException InterruptedException + */ + public void subscribeToTopic(String topic, Consumer callback) throws InterruptedException { + if (!subscribeMqttClient.getState().isConnected()) { + subscribeMqttClient.connectWith().keepAlive(Constants.THREAD_SLEEP_TIME_60000).cleanSession(false).send() + .whenComplete((connAck, throwable) -> { + if (throwable != null) { + logger.info("Failure in subscribe connection", throwable); + } + }); + } + Thread.sleep(Constants.THREAD_SLEEP_TIME_1000); + subscribeMqttClient.subscribeWith() + .topicFilter(topic) + .callback(callback) + .send().whenComplete((connAck, throwable) -> { + if (throwable != null) { + logger.info("Failure in subscribing client", throwable); + } else { + logger.info("Subscribed to topic {}", topic); + } + }); + } + + /** + * publishToTopic(). + * + * @param topic topic + * @param payload payload + * @throws InterruptedException InterruptedException + */ + public void publishToTopic(String topic, byte[] payload) throws InterruptedException { + if (!publishMqttClient.getState().isConnected()) { + publishMqttClient.connect() + .whenComplete((connAck, throwable) -> { + if (throwable != null) { + logger.info("Failure in publishing the connection", throwable); + } + }); + } + + if (!publishMqttClient.getState().isConnected()) { + TimeUnit.SECONDS.sleep(Constants.FIVE); + } + + publishMqttClient.publishWith() + .topic(topic).payload(payload).send().whenComplete((publish, throwable) -> { + if (throwable != null) { + logger.info("Failure in publishing the message", throwable); + } else { + logger.info("Published to topic {}", topic); + } + }); + + if (!publishMqttClient.getState().isConnected()) { + TimeUnit.SECONDS.sleep(Constants.FIVE); + } + publishMqttClient.publishWith() + .topic(topic) + .payload(payload) + .send() + .whenComplete((publish, throwable) -> { + if (throwable != null) { + logger.info("Failure in publishing the message", throwable); + } else { + logger.info("Published to topic {}", topic); + } + }); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/MqttServer.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/MqttServer.java new file mode 100644 index 0000000..21bbb45 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/MqttServer.java @@ -0,0 +1,92 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.mqtt; + +import io.moquette.broker.Server; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.junit.rules.ExternalResource; + +import java.io.File; + + +/** + * class MqttServer extends ExternalResource. + */ +public class MqttServer extends ExternalResource { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(MqttServer.class); + + /** The mqtt server. */ + private final Server mqttServer; + + /** The config file. */ + private final String configFile; + + /** + * MqttServer: public constructor. + */ + public MqttServer() { + logger.info("Loading mqtt Server Config:{}", "/mqtt.conf"); + this.mqttServer = new Server(); + this.configFile = this.getClass().getResource("/mqtt.conf").getFile(); + } + + /** + * Before. + * + * @throws Throwable the throwable + */ + @Override + protected void before() throws Throwable { + logger.info("Starting (Embedded) Mqtt Server."); + this.mqttServer.startServer(new File(configFile)); + } + + /** + * After. + */ + @Override + protected void after() { + logger.info("Stopping (Embedded) Mqtt Server."); + this.mqttServer.stopServer(); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/MqttTLSServer.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/MqttTLSServer.java new file mode 100644 index 0000000..9cab1f6 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/mqtt/MqttTLSServer.java @@ -0,0 +1,91 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.mqtt; + +import io.moquette.broker.Server; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.junit.rules.ExternalResource; +import java.io.File; + + +/** + * Embedded MQTT Server with TLS Enabled. + */ +public class MqttTLSServer extends ExternalResource { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(MqttTLSServer.class); + + /** The mqtt server. */ + private final Server mqttServer; + + /** The config file. */ + private final String configFile; + + /** + * Initialize the server. + */ + public MqttTLSServer() { + logger.info("Loading mqtt Server Config:{}", "/mqtt_ssl.conf"); + this.mqttServer = new Server(); + this.configFile = this.getClass().getResource("/mqtt_ssl.conf").getFile(); + } + + /** + * Before. + * + * @throws Throwable the throwable + */ + @Override + protected void before() throws Throwable { + logger.info("Starting (Embedded) Mqtt Server."); + this.mqttServer.startServer(new File(configFile)); + } + + /** + * After. + */ + @Override + protected void after() { + logger.info("Stopping (Embedded) Mqtt Server."); + this.mqttServer.stopServer(); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsOffsetManagementDAOMongoImplTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsOffsetManagementDAOMongoImplTest.java new file mode 100644 index 0000000..6ed161a --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsOffsetManagementDAOMongoImplTest.java @@ -0,0 +1,135 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.offset; + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.offset.KafkaStreamsOffsetManagementDAOMongoImpl; +import org.eclipse.ecsp.analytics.stream.base.offset.KafkaStreamsTopicOffset; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.List; + + +/** + * class KafkaStreamsOffsetManagementDAOMongoImplTest extends KafkaStreamsApplicationTestBase. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@TestPropertySource("/offsetmanager-test.properties") +public class KafkaStreamsOffsetManagementDAOMongoImplTest extends KafkaStreamsApplicationTestBase { + + /** The kafka topic. */ + private String kafkaTopic = "kafkaTopic"; + + /** The partition. */ + private int partition = 1; + + /** The offset. */ + private long offset = 1000L; + + /** The topic offset. */ + private KafkaStreamsTopicOffset topicOffset; + + /** The offset dao. */ + @Autowired + private KafkaStreamsOffsetManagementDAOMongoImpl offsetDao; + + /** + * Setup. + */ + @Before + public void setup() { + topicOffset = new KafkaStreamsTopicOffset(kafkaTopic, partition, offset); + } + + /** + * Test get overriding collection name. + */ + @Test + public void testGetOverridingCollectionName() { + Assert.assertEquals("kafkastreamsoffsetecallspservicetest_hi", offsetDao.getOverridingCollectionName()); + } + + /** + * Test find all. + */ + @Test + public void testFindAll() { + KafkaStreamsTopicOffset topicOffset1 = new KafkaStreamsTopicOffset(kafkaTopic, partition, offset); + offsetDao.save(topicOffset1); + KafkaStreamsTopicOffset topicOffset2 = new KafkaStreamsTopicOffset(kafkaTopic, Constants.FIVE, offset); + offsetDao.save(topicOffset2); + KafkaStreamsTopicOffset topicOffset3 = new KafkaStreamsTopicOffset("topic2", + Constants.TWO, TestConstants.THREAD_SLEEP_TIME_2000); + offsetDao.save(topicOffset3); + KafkaStreamsTopicOffset topicOffset4 = new KafkaStreamsTopicOffset("topic2", + Constants.THREE, TestConstants.THREAD_SLEEP_TIME_2000); + offsetDao.save(topicOffset4); + + List offsetList = offsetDao.findAll(); + Assert.assertEquals(Constants.FOUR, offsetList.size()); + } + + /** + * Test save. + */ + @Test + public void testSave() { + offsetDao.save(topicOffset); + Assert.assertEquals(topicOffset, offsetDao.findById(topicOffset.getId())); + + long offsetTMP = TestConstants.THREAD_SLEEP_TIME_2000; + topicOffset.setOffset(offsetTMP); + offsetDao.save(topicOffset); + Assert.assertEquals(topicOffset, offsetDao.findById(topicOffset.getId())); + Assert.assertEquals(topicOffset.getOffset(), offsetTMP); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsTopicOffsetTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsTopicOffsetTest.java new file mode 100644 index 0000000..199fa04 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/KafkaStreamsTopicOffsetTest.java @@ -0,0 +1,158 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.offset; + +import org.eclipse.ecsp.analytics.stream.base.offset.KafkaStreamsTopicOffset; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + + +/** + * test class KafkaStreamsTopicOffsetTest. + */ +public class KafkaStreamsTopicOffsetTest { + + /** The kafka topic. */ + private String kafkaTopic = "kafkaTopic"; + + /** The partition. */ + private int partition = 1; + + /** The offset. */ + private long offset = 1000L; + + /** The topic offset. */ + private KafkaStreamsTopicOffset topicOffset; + + /** + * Setup. + */ + @Before + public void setup() { + topicOffset = new KafkaStreamsTopicOffset(kafkaTopic, partition, offset); + } + + /** + * Test kafka streams topic offset. + */ + @Test + public void testKafkaStreamsTopicOffset() { + String id = new StringBuilder().append(kafkaTopic).append(":") + .append(partition).toString(); + Assert.assertEquals(id, topicOffset.getId()); + Assert.assertEquals(kafkaTopic, topicOffset.getKafkaTopic()); + Assert.assertEquals(partition, topicOffset.getPartition()); + Assert.assertEquals(offset, topicOffset.getOffset()); + } + + /** + * Test set id. + */ + @Test + public void testSetId() { + topicOffset.setId("id"); + Assert.assertEquals("id", topicOffset.getId()); + } + + /** + * Test set kafka topic. + */ + @Test + public void testSetKafkaTopic() { + topicOffset.setKafkaTopic("newTopic"); + Assert.assertEquals("newTopic", topicOffset.getKafkaTopic()); + } + + /** + * Test set partition. + */ + @Test + public void testSetPartition() { + topicOffset.setPartition(Constants.FIFTY); + Assert.assertEquals(Constants.FIFTY, topicOffset.getPartition()); + } + + /** + * Test set offset. + */ + @Test + public void testSetOffset() { + topicOffset.setOffset(Constants.THREAD_SLEEP_TIME_5000); + Assert.assertEquals(Constants.THREAD_SLEEP_TIME_5000, topicOffset.getOffset()); + } + + /** + * Test to string. + */ + @Test + public void testToString() { + String id = new StringBuilder().append(kafkaTopic).append(":") + .append(partition).toString(); + String toStr = "KafkaStreamsTopicOffset [getId()=" + id + + ", getKafkaTopic()=" + kafkaTopic + ", getPartition()=" + + partition + ", getOffset()=" + offset + "]"; + Assert.assertEquals(toStr, topicOffset.toString()); + } + + /** + * Test equals. + */ + @Test + public void testEquals() { + KafkaStreamsTopicOffset newTopicOffset = new KafkaStreamsTopicOffset(kafkaTopic, partition, offset); + Assert.assertEquals(newTopicOffset, topicOffset); + newTopicOffset = new KafkaStreamsTopicOffset(kafkaTopic, partition, Constants.THREAD_SLEEP_TIME_5000); + Assert.assertNotEquals(newTopicOffset, topicOffset); + } + + /** + * Test hash code. + */ + @Test + public void testHashCode() { + KafkaStreamsTopicOffset newTopicOffset = new KafkaStreamsTopicOffset(kafkaTopic, partition, offset); + Assert.assertEquals(newTopicOffset.hashCode(), topicOffset.hashCode()); + newTopicOffset = new KafkaStreamsTopicOffset(kafkaTopic, partition, Constants.THREAD_SLEEP_TIME_5000); + Assert.assertNotEquals(newTopicOffset.hashCode(), topicOffset.hashCode()); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManagerIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManagerIntegrationTest.java new file mode 100644 index 0000000..eef1888 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManagerIntegrationTest.java @@ -0,0 +1,262 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.offset; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.List; +import java.util.Properties; + +import static java.util.concurrent.CompletableFuture.delayedExecutor; +import static java.util.concurrent.CompletableFuture.runAsync; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + + +/** + * class OffsetManagerIntegrationTest extends KafkaStreamsApplicationTestBase. + */ + +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@TestPropertySource("/offsetmanager-test.properties") +public class OffsetManagerIntegrationTest extends KafkaStreamsApplicationTestBase { + + /** The source topic name. */ + private static String sourceTopicName; + + /** The event counter. */ + private static int eventCounter; + + /** The vehicle id. */ + private String vehicleId = "Vehicle12345"; + + /** The offset persistence init delay. */ + @Value("${" + PropertyNames.KAFKA_STREAMS_OFFSET_PERSISTENCE_INIT_DELAY + ":10000}") + private int offsetPersistenceInitDelay; + + /** The offset persistence delay. */ + @Value("${" + PropertyNames.KAFKA_STREAMS_OFFSET_PERSISTENCE_DELAY + ":60000}") + private int offsetPersistenceDelay; + + /** The offset dao. */ + @Autowired + private KafkaStreamsOffsetManagementDAOMongoImpl offsetDao; + + /** + * setUp(). + * + * @throws Exception Exception. + */ + @Before + public void setup() throws Exception { + super.setup(); + sourceTopicName = "raw-events"; + createTopics(sourceTopicName); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, sourceTopicName); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + KafkaStreamsTopicOffset saveOffset = + new KafkaStreamsTopicOffset(sourceTopicName, 0, TestConstants.LONG_50); + offsetDao.save(saveOffset); + } + + // Following parameters have been changed specifically for this test case in + // offsetmanager-test.properties + // kafka.streams.offset.persistence.delay=1000 + // kafka.streams.offset.persistence.init.delay=10 + // kafka.streams.offset.persistence.enabled=true + // start.device.status.consumer=false + /** + * Test. + * + * @throws Exception the exception + */ + // start.dff.feed.consumer=false + @Test + public void test() throws Exception { + eventCounter = 0; + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, OffsetManagerServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "offset-sp" + System.currentTimeMillis()); + launchApplication(); + runAsync(() -> {}, delayedExecutor(Constants.THREAD_SLEEP_TIME_10000, MILLISECONDS)).join(); + + String event1 = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": " + + "{\"value\":20.0},\"MessageId\":\"1237\",\"BizTransactionId\": \"Biz1237\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"Device12345\"}"; + for (int i = 0; i < TestConstants.THREAD_SLEEP_TIME_100; i++) { + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, vehicleId.getBytes(), event1.getBytes()); + } + + long bufferTime = TestConstants.THREAD_SLEEP_TIME_5000; + long sleepTime = offsetPersistenceInitDelay + offsetPersistenceDelay + bufferTime; + runAsync(() -> {}, delayedExecutor(sleepTime, MILLISECONDS)).join(); + + List list = offsetDao.findAll(); + Assert.assertEquals(1, list.size()); + KafkaStreamsTopicOffset offset = list.get(0); + Assert.assertEquals(sourceTopicName, offset.getKafkaTopic()); + Assert.assertEquals(0, offset.getPartition()); + Assert.assertEquals(TestConstants.LONG_99, offset.getOffset()); + Assert.assertEquals(Constants.FIFTY, eventCounter); + + } + + /** + * inner class OffsetManagerServiceProcessor implements IgniteEventStreamProcessor. + */ + public static final class OffsetManagerServiceProcessor implements IgniteEventStreamProcessor { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "OffsetManagerServiceProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + eventCounter++; + spc.forward(kafkaRecord); + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + } + + /** + * Close. + */ + @Override + public void close() { + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { sourceTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] {}; + } + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManagerTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManagerTest.java new file mode 100644 index 0000000..382b8be --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/offset/OffsetManagerTest.java @@ -0,0 +1,259 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.offset; + +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.offset.KafkaStreamsOffsetManagementDAOMongoImpl; +import org.eclipse.ecsp.analytics.stream.base.offset.KafkaStreamsTopicOffset; +import org.eclipse.ecsp.analytics.stream.base.offset.OffsetManager; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + + +/** + * Test Class for {@link OffsetManager}. + */ +public class OffsetManagerTest { + + /** The mockito rule. */ + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + /** The offset manager. */ + @InjectMocks + private OffsetManager offsetManager = new OffsetManager(); + + /** The topic offset dao. */ + @Mock + private KafkaStreamsOffsetManagementDAOMongoImpl topicOffsetDao; + + /** The topic. */ + private String topic = "topic"; + + /** The partition. */ + private int partition = 1; + + /** The offset. */ + private long offset = 1000L; + + /** + * Sets the up. + */ + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + } + + /** + * Test update processed offset if flag not enabled. + */ + @Test + public void testUpdateProcessedOffsetIfFlagNotEnabled() { + offsetManager.setOffsetPersistenceEnabled(false); + ConcurrentHashMap persistOffsetMap = + (ConcurrentHashMap) Mockito + .mock(ConcurrentHashMap.class); + offsetManager.setPersistOffsetMap(persistOffsetMap); + offsetManager.updateProcessedOffset("topic", partition, offset); + Mockito.verify(persistOffsetMap, Mockito.times(0)).get(Mockito.anyString()); + } + + /** + * Test update processed offset. + */ + @Test + public void testUpdateProcessedOffset() { + offsetManager.setOffsetPersistenceEnabled(true); + ConcurrentHashMap persistOffsetMap = + (ConcurrentHashMap) Mockito + .mock(ConcurrentHashMap.class); + offsetManager.setPersistOffsetMap(persistOffsetMap); + + String key = offsetManager.getKey(topic, partition); + + KafkaStreamsTopicOffset topicOffset = new KafkaStreamsTopicOffset(topic, partition, offset); + Mockito.when(persistOffsetMap.get(key)) + .thenReturn(topicOffset); + offsetManager.updateProcessedOffset(topic, partition, offset); + + Mockito.verify(persistOffsetMap, Mockito.times(1)).get(key); + } + + /** + * Test is skip offset if flag not enabled. + */ + @Test + public void testIsSkipOffsetIfFlagNotEnabled() { + offsetManager.setOffsetPersistenceEnabled(false); + ConcurrentHashMap persistOffsetMap = + (ConcurrentHashMap) Mockito + .mock(ConcurrentHashMap.class); + ConcurrentHashMap refrenceMap = + (ConcurrentHashMap) Mockito + .mock(ConcurrentHashMap.class); + offsetManager.setPersistOffsetMap(persistOffsetMap); + offsetManager.setRefrenceMap(refrenceMap); + Assert.assertFalse(offsetManager.doSkipOffset("topic", 1, TestConstants.THREAD_SLEEP_TIME_1000)); + Mockito.verify(persistOffsetMap, Mockito.times(0)).get(Mockito.anyString()); + Mockito.verify(refrenceMap, Mockito.times(0)).get(Mockito.anyString()); + } + + /** + * Test is skip offset. + */ + @Test + public void testIsSkipOffset() { + offsetManager.setOffsetPersistenceEnabled(true); + ConcurrentHashMap persistOffsetMap = + (ConcurrentHashMap) Mockito + .mock(ConcurrentHashMap.class); + ConcurrentHashMap refrenceMap = + (ConcurrentHashMap) Mockito + .mock(ConcurrentHashMap.class); + offsetManager.setPersistOffsetMap(persistOffsetMap); + offsetManager.setRefrenceMap(refrenceMap); + + // Case 1 : When currentmap and refmap are null + Assert.assertFalse(offsetManager.doSkipOffset("topic", 1, TestConstants.THREAD_SLEEP_TIME_1000)); + + String key = offsetManager.getKey("topic", 1); + Mockito.verify(persistOffsetMap, Mockito.times(1)).get(key); + Mockito.verify(refrenceMap, Mockito.times(1)).get(key); + + // Case 2 : When currentmap and refmap not null + KafkaStreamsTopicOffset topicOffset = new KafkaStreamsTopicOffset("topic", + 1, TestConstants.THREAD_SLEEP_TIME_900); + Mockito.when(persistOffsetMap.get(key)) + .thenReturn(topicOffset); + Mockito.when(refrenceMap.get(key)) + .thenReturn(topicOffset); + // skip smaller offset than processed + Assert.assertTrue(offsetManager.doSkipOffset("topic", 1, TestConstants.THREAD_SLEEP_TIME_900)); + // Do not skip larger offset than processed + Assert.assertFalse(offsetManager.doSkipOffset("topic", 1, TestConstants.THREAD_SLEEP_TIME_1100)); + + // Case 3 : When currentmap is null ; and refmap not null + Mockito.when(persistOffsetMap.get(key)) + .thenReturn(null); + Mockito.when(refrenceMap.get(key)) + .thenReturn(topicOffset); + // skip smaller offset than processed + Assert.assertTrue(offsetManager.doSkipOffset("topic", 1, TestConstants.THREAD_SLEEP_TIME_900)); + // Do not skip larger offset than processed + Assert.assertFalse(offsetManager.doSkipOffset("topic", 1, TestConstants.THREAD_SLEEP_TIME_1100)); + } + + /** + * Test initialize refrence map. + */ + @Test + public void testInitializeRefrenceMap() { + ConcurrentHashMap refrenceMap = + (ConcurrentHashMap) Mockito + .mock(ConcurrentHashMap.class); + offsetManager.setRefrenceMap(refrenceMap); + + List topicOffsetList = new ArrayList(); + KafkaStreamsTopicOffset topicOffset1 = new KafkaStreamsTopicOffset(topic, partition, offset); + KafkaStreamsTopicOffset topicOffset2 = new KafkaStreamsTopicOffset(topic, Constants.FIVE, offset); + topicOffsetList.add(topicOffset1); + topicOffsetList.add(topicOffset2); + + Mockito.when(topicOffsetDao.findAll()) + .thenReturn(topicOffsetList); + offsetManager.initializeRefrenceMap(); + Mockito.verify(refrenceMap, Mockito.times(Constants.TWO)).put(Mockito.anyString(), Mockito.any()); + } + + /** + * Test get key. + */ + @Test + public void testGetKey() { + String topic = "topic"; + int partition = Constants.THREAD_SLEEP_TIME_10; + String key = topic + ":" + partition; + Assert.assertEquals(key, offsetManager.getKey(topic, partition)); + } + + /** + * Test persist offset. + */ + @Test + public void testPersistOffset() { + ConcurrentHashMap persistOffsetMap = + (ConcurrentHashMap) Mockito + .mock(ConcurrentHashMap.class); + KafkaStreamsTopicOffset topicOffset1 = new KafkaStreamsTopicOffset(topic, partition, offset); + offsetManager.setPersistOffsetMap(persistOffsetMap); + + // Case 1 check when map is null + offsetManager.persistOffset(); + Mockito.verify(topicOffsetDao, Mockito.times(0)).save(topicOffset1); + KafkaStreamsTopicOffset topicOffset2 = new KafkaStreamsTopicOffset(topic, Constants.FIVE, offset); + Mockito.verify(topicOffsetDao, Mockito.times(0)).save(topicOffset2); + + // Case 2 check when map is not null + List list = new ArrayList(); + list.add(topicOffset1); + list.add(topicOffset2); + + Mockito.when(persistOffsetMap.values()) + .thenReturn(list); + + offsetManager.persistOffset(); + Mockito.verify(topicOffsetDao, Mockito.times(1)).save(topicOffset1); + Mockito.verify(topicOffsetDao, Mockito.times(1)).save(topicOffset2); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperTest.java new file mode 100644 index 0000000..ff429dd --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/parser/EventWrapperTest.java @@ -0,0 +1,308 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.parser; + +import org.eclipse.ecsp.analytics.stream.base.parser.EventParser; +import org.eclipse.ecsp.analytics.stream.base.parser.EventWrapperBase; +import org.eclipse.ecsp.analytics.stream.base.parser.EventWrapperForSequence; +import org.eclipse.ecsp.analytics.stream.base.parser.GenericValue; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.junit.Assert; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + + +/** + * {@link EventWrapperTest}. + */ +public class EventWrapperTest { + + /** The event json. */ + private String eventJson = "{ \"uploadTimeStamp\": \"1475151885909\", \"PDID\": " + + "\"85e744138ccb48a996c9395bae8d2a23\", \"data\": [{ \"Data\": { \"seqNum\":" + + " 2904, \"status\": \"chunkUpldSuccessful\" }, \"EventID\": \"UploadStatus\"," + + " \"PDID\": \"85e744138ccb48a996c9395bae8d2a23\", \"Timestamp\": 1475151870888," + + " \"Timezone\": 180, \"Version\": \"1.0\", \"pii\": {} }, { \"Data\": " + + "{ \"seqNum\": 2905, \"status\": \"chunkUpldSuccessful\" }, \"EventID\":" + + " \"UploadStatus\", \"PDID\": \"85e744138ccb48a996c9395bae8d2a23\", " + + "\"Timestamp\": 1475151870896, \"Timezone\": 180, \"Version\": \"1.0\"" + + ", \"pii\": {} }, { \"Data\": { \"seqNum\": 2906, \"status\": \"successful\"," + + " \"uploadScheduled\": 15 }, \"EventID\": \"UploadStatus\", \"PDID\": " + + "\"85e744138ccb48a996c9395bae8d2a23\", \"Timestamp\": 1475151870901," + + " \"Timezone\": 180, \"Version\": \"1.0\", \"pii\": {} }, { \"Data\":" + + " { \"value\": \"12345678901232609\" }, \"EventID\": \"VIN\", \"PDID\":" + + " \"85e744138ccb48a996c9395bae8d2a23\", \"Timestamp\": 1475151880122," + + " \"Timezone\": 180, \"Version\": \"1.0\", \"pii\": {} }, { \"Data\":" + + " { \"unit\": \"Celsius\", \"value\": \"100\" }, \"EventID\": " + + "\"AmbientAirTemp\", \"PDID\": \"85e744138ccb48a996c9395bae8d2a23\"," + + " \"Timestamp\": 1475151880123, \"Timezone\": 180, \"Version\": \"1.0\"," + + " \"pii\": {} }, { \"Data\": { \"unit\": \"percentage\", \"value\": " + + "\"80\" }, \"EventID\": \"CalculatedEngineLoadValue\", \"PDID\": " + + "\"85e744138ccb48a996c9395bae8d2a23\", \"Timestamp\": 1475151880123, " + + "\"Timezone\": 180, \"Version\": \"1.0\", \"pii\": {} }, { \"Data\": " + + "{ \"unit\": \"mtrsPerSec\", \"value\": \"100\" }, \"EventID\": " + + "\"DistanceTravelledWhileMILisActivated\", \"PDID\": \"85e744138ccb48a996c9395bae8d2a23\", " + + "\"Timestamp\": 1475151880123, \"Timezone\": 180, \"Version\": \"1.0\", \"pii\": {} }" + + ", { \"Data\": { \"unit\": \"tempInFh\", \"value\": \"100\" }, \"EventID\": " + + "\"EngineCoolantTemperature\", \"PDID\": \"85e744138ccb48a996c9395bae8d2a23\", " + + "\"Timestamp\": 1475151880123, \"Timezone\": 180, \"Version\": \"1.0\", " + + "\"pii\": {} }, { \"Data\": { \"unit\": \"ltrsperhour\", \"value\": \"100\" }, " + + "\"EventID\": \"EngineFuelRate\", \"PDID\": \"85e744138ccb48a996c9395bae8d2a23\"," + + " \"Timestamp\": 1475151880123, \"Timezone\": 180, \"Version\": \"1.0\", " + + "\"pii\": {} }, { \"Data\": { \"unit\": \"Celcius\", \"value\": \"100\" }, " + + "\"EventID\": \"EngineOilTemperature\", \"PDID\": \"85e744138ccb48a996c9395bae8d2a23\"," + + " \"Timestamp\": 1475151880123, \"Timezone\": 180, \"Version\": \"1.0\", " + + "\"pii\": {} }, { \"Data\": { \"heading\": \"N\", \"isLastKnownLocation\": " + + "\"false\", \"latitude\": \"66.22458\", \"longitude\": \"43.852421\", " + + "\"noLastKnownLocation\": \"false\", \"speed\": \"30\" }, \"EventID\": \"Location\"," + + " \"PDID\": \"85e744138ccb48a996c9395bae8d2a23\", \"Timestamp\": 1475151880123," + + " \"Timezone\": 180, \"Version\": \"1.1\", \"pii\": {} }, { \"Data\":" + + " { \"heading\": \"N\", \"isLastKnownLocation\": \"false\", \"latitude\":" + + " \"66.22458\", \"longitude\": \"43.852432\", \"noLastKnownLocation\": " + + "\"false\", \"speed\": \"30\" }, \"EventID\": \"Location\", \"PDID\": " + + "\"85e744138ccb48a996c9395bae8d2a23\", \"Timestamp\": 1475151880123, " + + "\"Timezone\": 180, \"Version\": \"1.1\", \"pii\": {} }, { \"Data\": " + + "{ \"unit\": \"rpm\", \"value\": \"111\" }, \"EventID\": \"EngineRPM\", " + + "\"PDID\": \"85e744138ccb48a996c9395bae8d2a23\", \"Timestamp\": 1475151880125," + + " \"Timezone\": 180, \"Version\": \"1.0\", \"pii\": {} }, { \"Data\": " + + "{ \"unit\": \"newtonMeter\", \"value\": \"80\" }, \"EventID\": " + + "\"EngineReferenceTorque\", \"PDID\": \"85e744138ccb48a996c9395bae8d2a23\"" + + ", \"Timestamp\": 1475151880128, \"Timezone\": 180, \"Version\": " + + "\"1.0\", \"pii\": {} }, { \"Data\": { \"unit\": \"percentage\", \"value\":" + + " \"25\" }, \"EventID\": \"FuelLevel\", \"PDID\": " + + "\"85e744138ccb48a996c9395bae8d2a23\", \"Timestamp\": 1475151880130, " + + "\"Timezone\": 180, \"Version\": \"1.0\", \"pii\": {} }, { \"Data\": " + + "{ \"unit\": \"Celcius\", \"value\": \"100\" }, \"EventID\":" + + " \"IntakeAirTemperature\", \"PDID\": \"85e744138ccb48a996c9395bae8d2a23\"," + + " \"Timestamp\": 1475151880132, \"Timezone\": 180, \"Version\": \"1.0\"," + + " \"pii\": {} }, { \"Data\": { \"unit\": \"milligramPerCubicMeter\", " + + "\"value\": \"100\" }, \"EventID\": \"PMSensorMassConcentration\", " + + "\"PDID\": \"85e744138ccb48a996c9395bae8d2a23\", \"Timestamp\": 1475151880134," + + " \"Timezone\": 180, \"Version\": \"1.0\", \"pii\": {} }, { \"Data\":" + + " { \"unit\": \"sec\", \"value\": \"100\" }, \"EventID\": " + + "\"TimeSinceEngineStart\", \"PDID\": \"85e744138ccb48a996c9395bae8d2a23\"," + + " \"Timestamp\": 1475151880134, \"Timezone\": 180, \"Version\": \"1.0\"," + + " \"pii\": {} }, { \"Data\": { \"unit\": \"minute\", \"value\": \"100\" }" + + ", \"EventID\": \"TimesinceDTCscleared\", \"PDID\": " + + "\"85e744138ccb48a996c9395bae8d2a23\", \"Timestamp\": 1475151880135, " + + "\"Timezone\": 180, \"Version\": \"1.0\", \"pii\": {} }, { \"Data\":" + + " { \"unit\": \"sec\", \"value\": \"100\" }, \"EventID\": \"TotalEngineRunTime\"" + + ", \"PDID\": \"85e744138ccb48a996c9395bae8d2a23\", \"Timestamp\": " + + "1475151880135, \"Timezone\": 180, \"Version\": \"1.0\", \"pii\": {} }, " + + "{ \"Data\": { \"value\": \"0\" }, \"EventID\": \"Typeoffuel\", \"PDID\":" + + " \"85e744138ccb48a996c9395bae8d2a23\", \"Timestamp\": 1475151880137, " + + "\"Timezone\": 180, \"Version\": \"1.0\", \"pii\": {} }, { \"Data\": " + + "{ \"unit\": \"sec\", \"value\": \"100\" }, \"EventID\": \"TimeConnectivityLost\"," + + " \"PDID\": \"85e744138ccb48a996c9395bae8d2a23\", \"Timestamp\": " + + "1475151880137, \"Timezone\": 180, \"Version\": \"1.0\", \"pii\": {} }," + + " { \"Data\": { \"xvalue\": \"100\", \"yvalue\": \"100\", \"zvalue\": " + + "\"100\" }, \"EventID\": \"GyroScopeinfo\", \"PDID\": " + + "\"85e744138ccb48a996c9395bae8d2a23\", \"Timestamp\": 1475151880137, \"Timezone\": " + + "180, \"Version\": \"1.0\", \"pii\": {} }, { \"Data\": { \"xvalue\":" + + " \"100\", \"yvalue\": \"100\", \"zvalue\": \"100\" }, \"EventID\": " + + "\"AccelerometerInfo\", \"PDID\": \"85e744138ccb48a996c9395bae8d2a23\", " + + "\"Timestamp\": 1475151880138, \"Timezone\": 180, \"Version\": \"1.0\", " + + "\"pii\": {} }, { \"Data\": { \"unit\": \"mtrsPerSec\", \"value\": \"23\" }, " + + "\"EventID\": \"VehicleSpeed\", \"PDID\": \"85e744138ccb48a996c9395bae8d2a23\"," + + " \"Timestamp\": 1475151880138, \"Timezone\": 180, \"Version\": \"1.0\", " + + "\"pii\": {} }]}"; + + /** The headless event json. */ + private String headlessEventJson = "[{\"EventID\":\"AmbientAirTemp\",\"Data\":" + + "{\"value\":\"100\", \"unit\":\"Celcius\"},\"Version\":\"1.0\",\"TimeStamp\"" + + ":1443717903851,\"PDID\": \"8f30cddc15d44848ac65a676fe49b144\",\"Timezone\": " + + "330}, {\"EventID\":\"AmbientAirTemp\",\"Data\":{\"value\":\"150\", \"unit\":" + + "\"Celcius\"},\"Version\":\"1.0\",\"TimeStamp\":1443717903851,\"PDID\":" + + " \"8f30cddc15d44848ac65a676fe49b144\",\"Timezone\": 330}, { \"EventID\": " + + "\"Location\", \"Version\": \"1.1\", \"TimeStamp\":1443717903851, " + + "\"Data\": {\"heading\": \"N\", \"speed\": \"30\", \"latitude\": " + + "\"12.92458\", \"longitude\": \"77.852421\" }}]"; + + /** + * Test parse with expr. + */ + @Test + public void testParseWithExpr() { + EventParser p = new EventParser(); + EventWrapperBase w = p.parseEventMapToWrapper(eventJson.getBytes()); + Assert.assertNull(w.getParseException()); + Assert.assertNull(w.getRawEvent()); + Object v = w.getPropertyByExpr("data[EventID=EngineRPM].Data.value"); + Assert.assertNotNull(v); + GenericValue gv = new GenericValue(v); + Assert.assertEquals(Constants.LONG_111, gv.asLong()); + } + + /** + * Test parse with expr fetch map. + */ + @Test + public void testParseWithExprFetchMap() { + EventParser p = new EventParser(); + EventWrapperBase w = p.parseEventMapToWrapper(eventJson.getBytes()); + Assert.assertNull(w.getParseException()); + Assert.assertNull(w.getRawEvent()); + Object v = w.getPropertyByExpr("data[EventID=EngineRPM].Data"); + Assert.assertNotNull(v); + Map m = (Map) v; + Assert.assertEquals(new String("111"), m.get("value")); + } + + /** + * Test parse with expr fetch event. + */ + @Test + public void testParseWithExprFetchEvent() { + EventParser p = new EventParser(); + EventWrapperBase w = p.parseEventMapToWrapper(eventJson.getBytes()); + Assert.assertNull(w.getParseException()); + Assert.assertNull(w.getRawEvent()); + Object v = w.getPropertyByExpr("data[EventID=EngineRPM]"); + Assert.assertNotNull(v); + Map m = (Map) v; + Assert.assertEquals(new Long(Constants.LONG_1475151880125), + w.getProperty(m, "Timestamp")); + Assert.assertEquals(new String("111"), w.getProperty(m, "Data.value")); + } + + /** + * Test parse with expr fetch list of maps. + */ + @Test + public void testParseWithExprFetchListOfMaps() { + EventParser p = new EventParser(); + EventWrapperBase w = p.parseEventMapToWrapper(eventJson.getBytes()); + Assert.assertNull(w.getParseException()); + Assert.assertNull(w.getRawEvent()); + Object v = w.getPropertyByExpr("data[EventID=Location]"); + Assert.assertNotNull(v); + List l = (List) v; + String data1 = (String) ((Map) ((Map) l.get(0)).get("Data")).get("longitude"); + String data2 = (String) ((Map) ((Map) l.get(1)).get("Data")).get("longitude"); + Assert.assertEquals("43.852421", data1); + Assert.assertEquals("43.852432", data2); + } + + /** + * Test parse with expr for wrapped sequence. + */ + @Test + public void testParseWithExprForWrappedSequence() { + EventParser p = new EventParser(); + EventWrapperForSequence w = p.parseEventSequenceToWrapper(headlessEventJson.getBytes()); + Assert.assertNull(w.getParseException()); + Assert.assertNull(w.getRawEvent()); + Object v = w.getPropertyByExpr("[EventID=Location].Data.heading"); + Assert.assertNotNull(v); + GenericValue gv = new GenericValue(v); + Assert.assertEquals("N", gv.asString()); + } + + /** + * Test parse with expr fetch map for wrapped sequence. + */ + @Test + public void testParseWithExprFetchMapForWrappedSequence() { + EventParser p = new EventParser(); + EventWrapperForSequence w = p.parseEventSequenceToWrapper(headlessEventJson.getBytes()); + Assert.assertNull(w.getParseException()); + Assert.assertNull(w.getRawEvent()); + Object v = w.getPropertyByExpr("[EventID=Location].Data"); + Assert.assertNotNull(v); + Map m = (Map) v; + Assert.assertEquals(new String("N"), m.get("heading")); + } + + /** + * Test parse with expr fetch event for wrapped sequence. + */ + @Test + public void testParseWithExprFetchEventForWrappedSequence() { + EventParser p = new EventParser(); + EventWrapperForSequence w = p.parseEventSequenceToWrapper(headlessEventJson.getBytes()); + Assert.assertNull(w.getParseException()); + Assert.assertNull(w.getRawEvent()); + Object v = w.getPropertyByExpr("[EventID=Location]"); + Assert.assertNotNull(v); + Map m = (Map) v; + Assert.assertEquals(new Long(Constants.LONG_1443717903851), + w.getProperty(m, "TimeStamp")); + Assert.assertEquals(new String("N"), w.getProperty(m, "Data.heading")); + } + + /** + * Test parse with expr fetch events for wrapped sequence. + */ + @Test + public void testParseWithExprFetchEventsForWrappedSequence() { + EventParser p = new EventParser(); + EventWrapperForSequence w = p.parseEventSequenceToWrapper(headlessEventJson.getBytes()); + Assert.assertNull(w.getParseException()); + Assert.assertNull(w.getRawEvent()); + Object v = w.getPropertyByExpr("[EventID=AmbientAirTemp]"); + Assert.assertNotNull(v); + System.out.println(v); + List l = (List) v; + Map m = (Map) l.get(0); + Assert.assertEquals(new Long(Constants.LONG_1443717903851), + w.getProperty(m, "TimeStamp")); + Assert.assertTrue(w.getProperty(m, "Data.value").equals("100") + || w.getProperty(m, "Data.value").equals("150")); + } + + /** + * testParseWithExprFetchListOfMapsForWrappedSequence(). + */ + public void testParseWithExprFetchListOfMapsForWrappedSequence() { + EventParser p = new EventParser(); + EventWrapperForSequence w = p.parseEventSequenceToWrapper(headlessEventJson.getBytes()); + Assert.assertNull(w.getParseException()); + Assert.assertNull(w.getRawEvent()); + Object v = w.getPropertyByExpr("data[EventID=AmbientAirTemp]"); + Assert.assertNotNull(v); + List l = (List) v; + String data1 = (String) ((Map) ((Map) l.get(0)).get("Data")).get("value"); + String data2 = (String) ((Map) ((Map) l.get(1)).get("Data")).get("value"); + Assert.assertEquals("100", data1); + Assert.assertEquals("150", data2); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/DeviceMessagingAgentPreProcessorTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/DeviceMessagingAgentPreProcessorTest.java new file mode 100644 index 0000000..7dcf515 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/DeviceMessagingAgentPreProcessorTest.java @@ -0,0 +1,167 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.entities.dma.RetryRecord; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMARetryRecordDAOCacheBackedInMemoryImpl; +import org.eclipse.ecsp.stream.dma.dao.key.RetryRecordKey; +import org.eclipse.ecsp.stream.dma.handler.RetryTestEvent; +import org.eclipse.ecsp.stream.dma.handler.RetryTestKey; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Value; + +import java.util.Optional; +import java.util.Properties; + + +/** + * UT class for {@link DeviceMessagingAgentPreProcessor}. + */ +public class DeviceMessagingAgentPreProcessorTest { + + /** The dma pre processor. */ + @InjectMocks + private DeviceMessagingAgentPreProcessor dmaPreProcessor; + + /** The retry event DAO. */ + @Mock + private DMARetryRecordDAOCacheBackedInMemoryImpl retryEventDAO; + + /** The spc. */ + @Mock + private StreamProcessingContext, IgniteEvent> spc; + + /** The ignite key. */ + private RetryTestKey igniteKey; + + /** The msg id. */ + private String msgId = "msgId123"; + + /** The task id. */ + private String taskId = "00_00"; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":Ecall}") + private String serviceName; + + /** + * setup(). + * + * @throws Exception Exception + */ + @Before + public void setUp() throws Exception { + igniteKey = new RetryTestKey(); + igniteKey.setKey("VIN123"); + MockitoAnnotations.initMocks(this); + DeviceMessage entity = new DeviceMessage(); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withMessageId(msgId); + entity.setDeviceMessageHeader(header); + RetryRecord retryRecord = new RetryRecord(igniteKey, entity, System.currentTimeMillis()); + Mockito.when(retryEventDAO.get(new RetryRecordKey(msgId, taskId))).thenReturn(retryRecord); + } + + /** + * Test remove event from cache. + */ + @Test + public void testRemoveEventFromCache() { + RetryTestEvent event = new RetryTestEvent(); + event.setMessageId("msgId223"); + event.setCorrelationId(msgId); + String mapKey = RetryRecordKey.getMapKey(serviceName, taskId); + dmaPreProcessor.setMapKey(mapKey); + dmaPreProcessor.process(new Record<>(igniteKey, event, System.currentTimeMillis())); + Mockito.verify(retryEventDAO, Mockito.times(1)) + .deleteFromMap(Mockito.anyString(), Mockito.any(RetryRecordKey.class), + Mockito.any(Optional.class), Mockito.anyString()); + + ArgumentCaptor parentKeyArgument = ArgumentCaptor.forClass(String.class); + Mockito.verify(retryEventDAO).deleteFromMap(parentKeyArgument.capture(), Mockito.any(RetryRecordKey.class), + Mockito.any(Optional.class), Mockito.anyString()); + Assert.assertEquals(mapKey, parentKeyArgument.getValue()); + } + + /** + * Testnull record. + */ + @Test(expected = IllegalArgumentException.class) + public void testnullRecord() { + RetryTestEvent event = new RetryTestEvent(); + event.setMessageId("msgId223"); + event.setCorrelationId(msgId); + String mapKey = RetryRecordKey.getMapKey(serviceName, taskId); + dmaPreProcessor.setMapKey(mapKey); + dmaPreProcessor.process(null); + + } + + /** + * Testnull key. + */ + @Test(expected = RuntimeException.class) + public void testnullKey() { + RetryTestEvent event = new RetryTestEvent(); + event.setMessageId("msgId223"); + event.setCorrelationId(msgId); + String mapKey = RetryRecordKey.getMapKey(serviceName, taskId); + dmaPreProcessor.configChanged(new Properties()); + dmaPreProcessor.punctuate(TestConstants.TWELVE); + dmaPreProcessor.setMapKey(null); + dmaPreProcessor.process(new Record<>(null, event, System.currentTimeMillis())); + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/HiveMQMqttDispatcherIntegrationTopicTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/HiveMQMqttDispatcherIntegrationTopicTest.java new file mode 100644 index 0000000..a8ed9ef --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/HiveMQMqttDispatcherIntegrationTopicTest.java @@ -0,0 +1,232 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import com.hivemq.client.internal.mqtt.lifecycle.MqttClientAutoReconnectImpl; +import com.hivemq.client.mqtt.MqttClient; +import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.mqtt.MqttServer; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.DefaultMqttTopicNameGeneratorImpl; +import org.eclipse.ecsp.analytics.stream.base.utils.HiveMqMqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.MqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.RetryUtils; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.Optional; +import java.util.UUID; + + +/** + * test class HiveMQMqttDispatcherIntegrationTopicTest. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/hivemq-test-mqtt.properties") +public class HiveMQMqttDispatcherIntegrationTopicTest { + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS_SERVER. */ + @ClassRule + public static final EmbeddedRedisServer REDIS_SERVER = new EmbeddedRedisServer(); + + /** The Constant MQTT_SERVER. */ + @ClassRule + public static final MqttServer MQTT_SERVER = new MqttServer(); + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(HiveMQMqttDispatcherIntegrationTopicTest.class); + + /** The msg received. */ + boolean msgReceived = false; + + /** The mqtt topic. */ + private String mqttTopic = StringUtils.EMPTY; + + /** The mqtt dispatcher. */ + @Autowired + private MqttDispatcher mqttDispatcher; + + /** The default mqtt topic name generator impl. */ + @Autowired + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The hive mq mqtt dispatcher. */ + @Autowired + private HiveMqMqttDispatcher hiveMqMqttDispatcher; + + /** The value. */ + private DeviceMessage value; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** The subscribe mqtt client. */ + private Mqtt3AsyncClient subscribeMqttClient; + + /** + * setup(). + */ + @Before + public void setup() { + TestEvent event = new TestEvent(); + event.setPlatformId(PropertyNames.DEFAULT_PLATFORMID); + value = new DeviceMessage(); + value.setMessage(transformer.toBlob(event)); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId("test"); + value.setDeviceMessageHeader(header); + value.setEvent(event); + subscribeMqttClient = MqttClient.builder().identifier(UUID.randomUUID().toString()) + .serverHost("localhost").serverPort(Constants.INT_1883) + .useMqttVersion3().automaticReconnect(MqttClientAutoReconnectImpl.DEFAULT) + .buildAsync(); + } + + /** + * Test client connection. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testClientConnection() throws InterruptedException { + defaultMqttTopicNameGeneratorImpl.setTopicNamePrefix("haa/harman/dev/"); + TestKey key = new TestKey(); + String mqttTopicToSubscribe = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(key, + value.getDeviceMessageHeader(), null).get(); + subscribeMqttClient.connectWith().cleanSession(false).send(); + subscribeMqttClient.subscribeWith() + .topicFilter(mqttTopicToSubscribe) + .callback((publish) -> { + logger.info("Msg received:{} on topic:{}", publish.getPayload().get(), publish.getTopic()); + msgReceived = true; + mqttTopic = publish.getTopic().toString(); + }).send(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + hiveMqMqttDispatcher.dispatch(key, value); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + RetryUtils.retry(Constants.TWENTY, (v) -> { + return mqttTopic.length() > 0 ? Boolean.TRUE : null; + }); + Assert.assertEquals("haa/harman/dev/test/2d/test", mqttTopic); + Assert.assertEquals(true, msgReceived); + hiveMqMqttDispatcher.close(); + subscribeMqttClient = null; + } + + /** + * class TestKey implements IgniteKey. + */ + public class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + return "test"; + } + } + + /** + * class TestEvent extends IgniteEventImpl. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Sample"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("test"); + } + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/HiveMQMqttDispatcherWithoutToDeviceForSubServicesTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/HiveMQMqttDispatcherWithoutToDeviceForSubServicesTest.java new file mode 100644 index 0000000..6c69682 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/HiveMQMqttDispatcherWithoutToDeviceForSubServicesTest.java @@ -0,0 +1,192 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.DefaultMqttTopicNameGeneratorImpl; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.Optional; + + +/** + * class {@link HiveMQMqttDispatcherWithoutToDeviceTestForSubServices}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/hivemq-test-mqtt-sub-services.properties") +public class HiveMQMqttDispatcherWithoutToDeviceForSubServicesTest { + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS_SERVER. */ + @ClassRule + public static final EmbeddedRedisServer REDIS_SERVER = new EmbeddedRedisServer(); + + /** The value. */ + private DeviceMessage value; + + /** The default mqtt topic name generator impl. */ + @Autowired + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** + * setup(). + */ + @Before + public void setup() { + TestEvent event = new TestEvent(); + value = new DeviceMessage(); + value.setMessage(transformer.toBlob(event)); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId(event.getTargetDeviceId().get()); + value.setDeviceMessageHeader(header); + } + + /** + * Test with custom mqtttopic for sub services. + */ + @Test + public void testWithCustomMqtttopicForSubServices() { + TestEvent event = new TestEvent(); + event.setDevMsgTopicSuffix("CUSTOM/TOPIC"); + event.setDevMsgTopicPrefix("userId/"); + value = new DeviceMessage(transformer.toBlob(event), Version.V1_0, + event, "feedBackTopic", Constants.THREAD_SLEEP_TIME_60000); + + defaultMqttTopicNameGeneratorImpl.setTopicNamePrefix("userId/"); + Optional mqttTopic = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(new TestKey(), + value.getDeviceMessageHeader(), null); + String mqttExpectedTopic = "userId/device123/CUSTOM/TOPIC"; + Assert.assertEquals(mqttExpectedTopic, mqttTopic.get()); + + event = new TestEvent(); + event.setDevMsgTopicSuffix("/custom/topic"); + value = new DeviceMessage(transformer.toBlob(event), Version.V1_0, + event, "feedBackTopic", Constants.THREAD_SLEEP_TIME_60000); + + // Test whether the first occuring "/" is being removed and not + // consecutive ones + mqttTopic = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(new TestKey(), + value.getDeviceMessageHeader(), null); + mqttExpectedTopic = "userId/device123/CUSTOM/TOPIC"; + Assert.assertEquals(mqttExpectedTopic, mqttTopic.get()); + } + + /** + * inner class TestKey implements IgniteKey. + */ + public class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + + return "device123"; + } + + } + + /** + * inner class TestEvent extends IgniteEventImpl. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "testservice"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("device123"); + } + + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MessageBaseFilterImpl.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MessageBaseFilterImpl.java new file mode 100644 index 0000000..3435fac --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MessageBaseFilterImpl.java @@ -0,0 +1,65 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.springframework.stereotype.Component; + + +/** + * class MessageBaseFilterImpl implements MessageFilter. + */ +@Component +public class MessageBaseFilterImpl implements MessageFilter { + + /** + * Filter. + * + * @param igniteKey the ignite key + * @param igniteEvent the ignite event + * @return the string + */ + @Override + public String filter(IgniteKey igniteKey, IgniteEvent igniteEvent) { + return (String) igniteKey.getKey(); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MessageGenerator.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MessageGenerator.java new file mode 100644 index 0000000..d209f25 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MessageGenerator.java @@ -0,0 +1,204 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.kafka.clients.CommonClientConfigs; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.Producer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.apache.kafka.common.config.SslConfigs; +import org.apache.kafka.common.config.internals.BrokerSecurityConfigs; +import org.apache.kafka.common.serialization.Serdes; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.domain.BlobDataV1_0; +import org.eclipse.ecsp.domain.IgniteEventSource; +import org.eclipse.ecsp.entities.IgniteBlobEvent; +import org.eclipse.ecsp.serializer.IngestionSerializerFstImpl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + + +/** + * class MessageGenerator. + */ +public class MessageGenerator { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(MessageGenerator.class); + + /** The exec. */ + private static ScheduledExecutorService exec = null; + + /** + * main(). + * + * @param args args + * @throws ExecutionException ExecutionException + * @throws InterruptedException InterruptedException + */ + public static void main(String[] args) throws ExecutionException, InterruptedException { + + exec = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { + public Thread newThread(Runnable r) { + Thread t = Executors.defaultThreadFactory().newThread(r); + t.setDaemon(true); + String name = Thread.currentThread().getName(); + t.setName("pool:" + name); + t.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { + @Override + public void uncaughtException(Thread t, Throwable e) { + LOGGER.error("Uncaught exception for pool thread " + t.getName(), e); + } + }); + return t; + } + }); + // Push data to every 5 seconds + exec.scheduleWithFixedDelay(new Runnable() { + @Override + public void run() { + try { + produce(args); + } catch (Exception e) { + e.printStackTrace(); + LOGGER.error("Exception in punctuateData", e); + } + } + }, Constants.SIXTY, Constants.FIVE, TimeUnit.SECONDS); + + int argsLength = args.length; + if (argsLength != Constants.FIVE) { + LOGGER.error("Expecting 4 arguements, but received {}", argsLength); + usage(); + return; + } + produce(args); + + } + + /** + * usage(). + */ + public static void usage() { + System.out.println("Requires 5 arguements.\n" + + "1) Kafka topic name \n" + + "2) Key \n" + + "3) Value \n" + + "4) bootstrapserver \n" + + "5) sslEnabled"); + } + + /** + * produce(). + * + * @param args args + * @throws ExecutionException ExecutionException + * @throws InterruptedException InterruptedException + */ + public static void produce(String[] args) throws ExecutionException, InterruptedException { + String topicName = args[0]; + String key = args[1]; + String value = args[Constants.TWO]; + String bootstrapServers = args[Constants.THREE]; + boolean sslEnabled = false; + try { + sslEnabled = Boolean.parseBoolean(args[Constants.FOUR]); + } catch (Exception e) { + LOGGER.error("SSL enabling error"); + } + + LOGGER.info("Sending key={}, value={} to the topic {}", key, value, topicName); + + Properties producerProps = new Properties(); + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + producerProps.put(ProducerConfig.ACKS_CONFIG, "all"); + producerProps.put(ProducerConfig.RETRIES_CONFIG, 0); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + + if (sslEnabled) { + producerProps.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "SSL"); + producerProps.put(BrokerSecurityConfigs.SSL_CLIENT_AUTH_CONFIG, "required"); + producerProps.put(SslConfigs.SSL_KEYSTORE_LOCATION_CONFIG, "/kafka/ssl/kafka.client.keystore.jks"); + producerProps.put(SslConfigs.SSL_KEYSTORE_PASSWORD_CONFIG, "shcuwNHARcNuag8SgYdsG8cWuPExY3Tx"); + producerProps.put(SslConfigs.SSL_KEY_PASSWORD_CONFIG, "pUBPHXM9mP5PrRBrTEpF5cV2TpjvWtb5"); + producerProps.put(SslConfigs.SSL_TRUSTSTORE_LOCATION_CONFIG, "/kafka/ssl/kafka.client.truststore.jks"); + producerProps.put(SslConfigs.SSL_TRUSTSTORE_PASSWORD_CONFIG, "9vq9ghbSFd7JMFSgGMSCEuAzE3q27Xd3"); + } + + + IgniteBlobEvent igniteBlobData = new IgniteBlobEvent(); + igniteBlobData.setSourceDeviceId(key); + BlobDataV1_0 blobdatav10 = new BlobDataV1_0(); + blobdatav10.setEventSource(IgniteEventSource.IGNITE); + blobdatav10.setPayload(value.getBytes()); + igniteBlobData.setEventData(blobdatav10); + igniteBlobData.setVersion(org.eclipse.ecsp.domain.Version.V1_0); + igniteBlobData.setRequestId(key + "-id"); + byte[] serialedBtyes = new IngestionSerializerFstImpl().serialize(igniteBlobData); + + ProducerRecord data = new ProducerRecord( + topicName, (key).getBytes(), serialedBtyes); + + Producer producer = new KafkaProducer(producerProps); + Future future = producer.send(data); + LOGGER.info("future.get ={}, data={} to the topic {}", future.get(), data, topicName); + + } + + /** + * Instantiates a new message generator. + */ + private MessageGenerator() { + + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MessgeFilterAgentTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MessgeFilterAgentTest.java new file mode 100644 index 0000000..fcdd8b3 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MessgeFilterAgentTest.java @@ -0,0 +1,120 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.cache.GetStringRequest; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.ArgumentMatchers.any; + + +/** + * UT class {@link MessgeFilterAgentTest}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { MessageBaseFilterImpl.class }) +public class MessgeFilterAgentTest { + + /** The messge filter agent test. */ + @Spy + private MessgeFilterAgent messgeFilterAgentTest; + + /** The message filter. */ + @Autowired + private MessageFilter messageFilter; + + /** The cache. */ + @Mock + private IgniteCache cache; + + /** The ignite event. */ + private IgniteEvent igniteEvent; + + /** The ignite string key. */ + private IgniteKey igniteStringKey; + + /** + * setup(). + * + * @throws NoSuchFieldException NoSuchFieldException + * @throws SecurityException SecurityException + */ + @Before + public void setup() throws NoSuchFieldException, SecurityException { + MockitoAnnotations.initMocks(this); + igniteStringKey = new IgniteStringKey("test123"); + igniteEvent = new IgniteEventImpl(); + ReflectionTestUtils.setField(messgeFilterAgentTest, "messageFilter", messageFilter); + ReflectionTestUtils.setField(messgeFilterAgentTest, "cache", cache); + ReflectionTestUtils.setField(messgeFilterAgentTest, "serviceName", "serviceName"); + Mockito.when(cache.getString(any(GetStringRequest.class))) + .thenReturn(String.valueOf(Constants.LONG_1603946935)); + } + + /** + * Test is duplicate. + */ + @Test + public void testIsDuplicate() { + Assert.assertTrue(messgeFilterAgentTest.isDuplicate(igniteStringKey, igniteEvent)); + igniteStringKey = new IgniteStringKey("test1234"); + Mockito.when(cache.getString(any(GetStringRequest.class))).thenReturn(null); + Assert.assertFalse(messgeFilterAgentTest.isDuplicate(igniteStringKey, igniteEvent)); + + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherIntegrationTest.java new file mode 100644 index 0000000..b06cb08 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherIntegrationTest.java @@ -0,0 +1,265 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.commons.lang3.StringUtils; +import org.apache.kafka.test.TestUtils; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.DefaultMqttTopicNameGeneratorImpl; +import org.eclipse.ecsp.analytics.stream.base.utils.MqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.PahoMqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.RetryUtils; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.io.IOException; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + + +/** + * Test class to test the MqttDispatcher class functionality. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/test-mqtt.properties") +public class MqttDispatcherIntegrationTest { + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS_SERVER. */ + @ClassRule + public static final EmbeddedRedisServer REDIS_SERVER = new EmbeddedRedisServer(); + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(MqttDispatcherIntegrationTest.class); + + /** The msg received. */ + boolean msgReceived = false; + + /** The mqtt topic. */ + private String mqttTopic = StringUtils.EMPTY; + + /** The default mqtt topic name generator impl. */ + @Autowired + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The mqtt dispatcher. */ + @Autowired + private MqttDispatcher mqttDispatcher; + + /** The paho mqtt dispatcher. */ + @Autowired + private PahoMqttDispatcher pahoMqttDispatcher; + + /** The value. */ + private DeviceMessage value; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** + * Setup class. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + @BeforeClass + public static void setupClass() throws IOException { + TestUtils.startMqttServer(); + } + + /** + * Teardown class. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + @AfterClass + public static void teardownClass() throws IOException { + TestUtils.startMqttServer(); + } + + /** + * setup(). + */ + @Before + public void setup() { + TestEvent event = new TestEvent(); + value = new DeviceMessage(); + value.setMessage(transformer.toBlob(event)); + value.setEvent(event); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId("test"); + value.setDeviceMessageHeader(header); + } + + /** + * Test client connection. + * + * @throws InterruptedException the interrupted exception + * @throws MqttException the mqtt exception + */ + @Test + public void testClientConnection() throws InterruptedException, MqttException { + defaultMqttTopicNameGeneratorImpl.setTopicNamePrefix("haa/harman/dev/"); + TestKey key = new TestKey(); + + /* + * get a client to subscribe to the required topic topic + */ + String mqttTopicToSubscribe = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(key, + value.getDeviceMessageHeader(), null).get(); + MqttClient client = pahoMqttDispatcher.getMqttClient(PropertyNames.DEFAULT_PLATFORMID).get(); + client.subscribe(mqttTopicToSubscribe); + client.setCallback(new MqttCallback() { + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + LOGGER.error("Msg received:{} on topic:{}", message, topic); + msgReceived = true; + mqttTopic = topic; + + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + + } + + @Override + public void connectionLost(Throwable cause) { + + } + }); + + mqttDispatcher.dispatch(key, value); + await().atMost(TestConstants.THREAD_SLEEP_TIME_1000, TimeUnit.MILLISECONDS); + RetryUtils.retry(Constants.TWENTY, (v) -> { + return mqttTopic.length() > 0 ? Boolean.TRUE : null; + }); + Assert.assertEquals("haa/harman/dev/test/2d/test", mqttTopic); + Assert.assertEquals(true, msgReceived); + + } + + /** + * inner class TestKey implements IgniteKey. + */ + public class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + return "test"; + } + } + + /** + * inner class TestEvent extends IgniteEventImpl. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Sample"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("test"); + } + } +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherPlatformIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherPlatformIntegrationTest.java new file mode 100644 index 0000000..1357859 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherPlatformIntegrationTest.java @@ -0,0 +1,282 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.commons.lang3.StringUtils; +import org.apache.kafka.test.TestUtils; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.utils.DefaultMqttTopicNameGeneratorImpl; +import org.eclipse.ecsp.analytics.stream.base.utils.MqttConfig; +import org.eclipse.ecsp.analytics.stream.base.utils.MqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.PahoMqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.RetryUtils; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.io.IOException; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + + +/** + * Test class to test the MqttDispatcher class functionality. + * + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/test-mqtt-platform.properties") +public class MqttDispatcherPlatformIntegrationTest { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(MqttDispatcherPlatformIntegrationTest.class); + + /** The msg received. */ + boolean msgReceived = false; + + /** The mqtt topic. */ + private String mqttTopic = StringUtils.EMPTY; + + /** The default mqtt topic name generator impl. */ + @Autowired + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The mqtt dispatcher. */ + @Autowired + private MqttDispatcher mqttDispatcher; + + /** The paho mqtt dispatcher. */ + @Autowired + private PahoMqttDispatcher pahoMqttDispatcher; + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS_SERVER. */ + @ClassRule + public static final EmbeddedRedisServer REDIS_SERVER = new EmbeddedRedisServer(); + + /** + * Setup class. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + @BeforeClass + public static void setupClass() throws IOException { + TestUtils.startMqttServer(); + } + + /** + * Teardown class. + */ + @AfterClass + public static void teardownClass() { + TestUtils.stopMqttServer(); + } + + /** The value. */ + private DeviceMessage value; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** The platform ID. */ + private String platformID; + + /** + * Create setup for this integration test case. + */ + @Before + public void setup() { + platformID = "platform1"; + TestEvent event = new TestEvent(); + value = new DeviceMessage(); + value.setMessage(transformer.toBlob(event)); + value.setEvent(event); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId("test"); + value.setDeviceMessageHeader(header); + } + + /** + * Test client connection with multiple platforms. + * + * @throws InterruptedException the interrupted exception + * @throws MqttException the mqtt exception + */ + @Test + public void testClientConnectionWithMultiplePlatforms() throws InterruptedException, MqttException { + defaultMqttTopicNameGeneratorImpl.setTopicNamePrefix("haa/harman/dev/"); + TestKey key = new TestKey(); + + Optional configForPlatformOpt = pahoMqttDispatcher.getMqttConfig(platformID); + Optional configForDefaultPlatformOpt = pahoMqttDispatcher + .getMqttConfig(PropertyNames.DEFAULT_PLATFORMID); + + Assert.assertTrue("Config for platformID is not present", configForPlatformOpt.isPresent()); + Assert.assertTrue("Config for default platformID is not present", configForDefaultPlatformOpt.isPresent()); + Assert.assertNotNull("Broker URL for platform config is null", configForPlatformOpt.get().getBrokerUrl()); + Assert.assertNotNull("Broker URL for default platform config is null", + configForDefaultPlatformOpt.get().getBrokerUrl()); + /* + * get a client to subscribe to the required topic topic + */ + String mqttTopicToSubscribe = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(key, + value.getDeviceMessageHeader(), null).get(); + MqttClient client = pahoMqttDispatcher.getMqttClient(platformID).get(); + client.subscribe(mqttTopicToSubscribe); + client.setCallback(new MqttCallback() { + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + LOGGER.error("Msg received:{} on topic:{}", message, topic); + msgReceived = true; + mqttTopic = topic; + + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + + } + + @Override + public void connectionLost(Throwable cause) { + + } + }); + mqttDispatcher.dispatch(key, value); + await().atMost(TestConstants.THREAD_SLEEP_TIME_1000, TimeUnit.MILLISECONDS); + RetryUtils.retry(TestConstants.TWENTY, (v) -> { + return mqttTopic.length() > 0 ? Boolean.TRUE : null; + }); + MqttClient client2 = pahoMqttDispatcher.getMqttClient(PropertyNames.DEFAULT_PLATFORMID).get(); + Assert.assertNotNull("Client for platform is null", client); + Assert.assertNotNull("Client for default platform is null", client2); + Assert.assertEquals("haa/harman/dev/test/2d/test", mqttTopic); + Assert.assertEquals(true, msgReceived); + } + + /** + * Test IgniteKey implementation. + */ + public class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + + return "test"; + } + + } + + /** + * Test IgniteEvent. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Sample"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("test"); + } + + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherPlatformInvalidConfigIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherPlatformInvalidConfigIntegrationTest.java new file mode 100644 index 0000000..48c2d98 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherPlatformInvalidConfigIntegrationTest.java @@ -0,0 +1,303 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.commons.lang3.StringUtils; +import org.apache.kafka.test.TestUtils; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.utils.DefaultMqttTopicNameGeneratorImpl; +import org.eclipse.ecsp.analytics.stream.base.utils.MqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.NoMqttClientFoundException; +import org.eclipse.ecsp.analytics.stream.base.utils.PahoMqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.RetryUtils; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.ecsp.utils.metrics.IgniteErrorCounter; +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.io.IOException; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + + +/** + * Test class to test the MqttDispatcher class functionality. + * + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/test-mqtt-platform-invalid.properties") +public class MqttDispatcherPlatformInvalidConfigIntegrationTest { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory + .getLogger(MqttDispatcherPlatformInvalidConfigIntegrationTest.class); + + /** The msg received. */ + boolean msgReceived = false; + + /** The mqtt topic. */ + private String mqttTopic = StringUtils.EMPTY; + + /** The default mqtt topic name generator impl. */ + @Autowired + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The mqtt dispatcher. */ + @Autowired + private MqttDispatcher mqttDispatcher; + + /** The paho mqtt dispatcher. */ + @Autowired + private PahoMqttDispatcher pahoMqttDispatcher; + + /** The error counter. */ + @Autowired + private IgniteErrorCounter errorCounter; + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS_SERVER. */ + @ClassRule + public static final EmbeddedRedisServer REDIS_SERVER = new EmbeddedRedisServer(); + + /** + * Setup class. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + @BeforeClass + public static void setupClass() throws IOException { + TestUtils.startMqttServer(); + } + + /** + * Teardown class. + */ + @AfterClass + public static void teardownClass() { + TestUtils.stopMqttServer(); + } + + /** The value. */ + private DeviceMessage value; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** The platform ID. */ + private String platformID; + + /** + * Setup for this test case. + */ + @Before + public void setup() { + platformID = "platform1"; + TestEvent event = new TestEvent(); + value = new DeviceMessage(); + value.setMessage(transformer.toBlob(event)); + value.setEvent(event); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId("test"); + value.setDeviceMessageHeader(header); + } + + /** + * Test client connection with invalid config. + * + * @throws InterruptedException the interrupted exception + * @throws MqttException the mqtt exception + */ + @Test + public void testClientConnectionWithInvalidConfig() throws InterruptedException, MqttException { + defaultMqttTopicNameGeneratorImpl.setTopicNamePrefix("haa/harman/dev/"); + TestKey key = new TestKey(); + String mqttTopicToSubscribe = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(key, + value.getDeviceMessageHeader(), null).get(); + MqttClient client = pahoMqttDispatcher.getMqttClient(PropertyNames.DEFAULT_PLATFORMID).get(); + Optional clientPlatformOpt = pahoMqttDispatcher.getMqttClient(platformID); + + Assert.assertFalse("Client for platform is present", clientPlatformOpt.isPresent()); + Assert.assertNotNull("Client for platform is null", client); + + client.subscribe(mqttTopicToSubscribe); + client.setCallback(new MqttCallback() { + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + LOGGER.error("Msg received:{} on topic:{}", message, topic); + msgReceived = true; + mqttTopic = topic; + + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + + } + + @Override + public void connectionLost(Throwable cause) { + + } + }); + + mqttDispatcher.dispatch(key, value); + + await().atMost(TestConstants.THREAD_SLEEP_TIME_1000, TimeUnit.MILLISECONDS); + RetryUtils.retry(TestConstants.TWENTY, (v) -> { + return mqttTopic.length() > 0 ? Boolean.TRUE : null; + }); + Assert.assertEquals("haa/harman/dev/test/2d/test", mqttTopic); + Assert.assertEquals(true, msgReceived); + } + + /** + * Test dispatch with no client for platform. + */ + @Test + public void testDispatchWithNoClientForPlatform() { + defaultMqttTopicNameGeneratorImpl.setTopicNamePrefix("haa/harman/dev/"); + TestEvent event = new TestEvent(); + event.setPlatformId(platformID); + value = new DeviceMessage(); + value.setMessage(transformer.toBlob(event)); + value.setEvent(event); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId("test"); + value.setDeviceMessageHeader(header); + TestKey key = new TestKey(); + + Optional clientPlatformOpt = pahoMqttDispatcher.getMqttClient(platformID); + Assert.assertFalse("Client for platform is present", clientPlatformOpt.isPresent()); + + mqttDispatcher.dispatch(key, value); + double errorCount = errorCounter.getErrorCounterValue(Optional.empty(), NoMqttClientFoundException.class); + Assert.assertTrue("Count value is 0", errorCount > 0); + } + /** + * Test implementation for IgniteKey. + */ + + public class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + + return "test"; + } + + } + + /** + * Test implementation for IgniteEvent. + */ + + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Sample"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("test"); + } + + } +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherSSLIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherSSLIntegrationTest.java new file mode 100644 index 0000000..310898c --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherSSLIntegrationTest.java @@ -0,0 +1,248 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.mqtt.MqttTLSServer; +import org.eclipse.ecsp.analytics.stream.base.utils.DefaultMqttTopicNameGeneratorImpl; +import org.eclipse.ecsp.analytics.stream.base.utils.MqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.PahoMqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.RetryUtils; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + + +/** + * Test class to test the MqttDispatcher class functionality. + * + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/test-mqtt-ssl.properties") +public class MqttDispatcherSSLIntegrationTest { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(MqttDispatcherSSLIntegrationTest.class); + + /** The msg received. */ + boolean msgReceived = false; + + /** The mqtt topic. */ + private String mqttTopic = StringUtils.EMPTY; + + /** The default mqtt topic name generator impl. */ + @Autowired + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The mqtt dispatcher. */ + @Autowired + private MqttDispatcher mqttDispatcher; + + /** The paho mqtt dispatcher. */ + @Autowired + private PahoMqttDispatcher pahoMqttDispatcher; + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS_SERVER. */ + @ClassRule + public static final EmbeddedRedisServer REDIS_SERVER = new EmbeddedRedisServer(); + + /** The Constant MQTT_SERVER. */ + @ClassRule + public static final MqttTLSServer MQTT_SERVER = new MqttTLSServer(); + + /** The value. */ + private DeviceMessage value; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** + * Setup for the test case. + */ + @Before + public void setup() { + TestEvent event = new TestEvent(); + value = new DeviceMessage(); + value.setMessage(transformer.toBlob(event)); + value.setEvent(event); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId("test"); + value.setDeviceMessageHeader(header); + } + + /** + * Test client connection without topic pefix. + * + * @throws MqttException the mqtt exception + */ + @Test + public void testClientConnection_without_topic_pefix() throws MqttException { + defaultMqttTopicNameGeneratorImpl.setTopicNamePrefix(""); + TestKey key = new TestKey(); + + /* + * get a client to subscribe to the required topic topic + */ + String mqttTopicToSubscribe = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(key, + value.getDeviceMessageHeader(), null).get(); + + MqttClient client = pahoMqttDispatcher.getMqttClient(PropertyNames.DEFAULT_PLATFORMID).get(); + client.subscribe(mqttTopicToSubscribe); + client.setCallback(new MqttCallback() { + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + LOGGER.error("Msg received:{} on topic:{}", message, topic); + msgReceived = true; + mqttTopic = topic; + + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + + } + + @Override + public void connectionLost(Throwable cause) { + + } + }); + + mqttDispatcher.dispatch(key, value); + + await().atMost(TestConstants.THREAD_SLEEP_TIME_1000, TimeUnit.MILLISECONDS); + RetryUtils.retry(TestConstants.TWENTY, (v) -> mqttTopic.length() > 0 ? Boolean.TRUE : null); + + Assert.assertEquals("test/2d/test", mqttTopic); + Assert.assertEquals(true, msgReceived); + + } + + /** + * Test IgniteKey. + */ + public class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + + return "test"; + } + + } + + /** + * Test IgniteEvent. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Sample"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("test"); + } + + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherSSLPlatformIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherSSLPlatformIntegrationTest.java new file mode 100644 index 0000000..b808d7e --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherSSLPlatformIntegrationTest.java @@ -0,0 +1,250 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.mqtt.MqttTLSServer; +import org.eclipse.ecsp.analytics.stream.base.utils.DefaultMqttTopicNameGeneratorImpl; +import org.eclipse.ecsp.analytics.stream.base.utils.MqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.PahoMqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.RetryUtils; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + + +/** + * Test class to test the MqttDispatcher class functionality. + * + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/test-mqtt-ssl-platform.properties") +public class MqttDispatcherSSLPlatformIntegrationTest { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(MqttDispatcherSSLPlatformIntegrationTest.class); + + /** The msg received. */ + boolean msgReceived = false; + + /** The mqtt topic. */ + private String mqttTopic = StringUtils.EMPTY; + + /** The default mqtt topic name generator impl. */ + @Autowired + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The mqtt dispatcher. */ + @Autowired + private MqttDispatcher mqttDispatcher; + + /** The paho mqtt dispatcher. */ + @Autowired + private PahoMqttDispatcher pahoMqttDispatcher; + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS_SERVER. */ + @ClassRule + public static final EmbeddedRedisServer REDIS_SERVER = new EmbeddedRedisServer(); + + /** The Constant MQTT_SERVER. */ + @ClassRule + public static final MqttTLSServer MQTT_SERVER = new MqttTLSServer(); + + /** The value. */ + private DeviceMessage value; + + /** The platform ID. */ + private String platformID; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** + * Setup for the test case. + */ + @Before + public void setup() { + TestEvent event = new TestEvent(); + platformID = "platform1"; + value = new DeviceMessage(); + value.setMessage(transformer.toBlob(event)); + value.setEvent(event); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId("test"); + value.setDeviceMessageHeader(header); + } + + /** + * Test client connection without topic pefix. + * + * @throws MqttException the mqtt exception + */ + @Test + public void testClientConnection_without_topic_pefix() throws MqttException { + defaultMqttTopicNameGeneratorImpl.setTopicNamePrefix(""); + TestKey key = new TestKey(); + + /* + * get a client to subscribe to the required topic topic + */ + String mqttTopicToSubscribe = + defaultMqttTopicNameGeneratorImpl.getMqttTopicName(key, value.getDeviceMessageHeader(), null).get(); + + MqttClient client = pahoMqttDispatcher.getMqttClient(platformID).get(); + client.subscribe(mqttTopicToSubscribe); + client.setCallback(new MqttCallback() { + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + LOGGER.error("Msg received:{} on topic:{}", message, topic); + msgReceived = true; + mqttTopic = topic; + + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + + } + + @Override + public void connectionLost(Throwable cause) { + + } + }); + + mqttDispatcher.dispatch(key, value); + + await().atMost(TestConstants.THREAD_SLEEP_TIME_1000, TimeUnit.MILLISECONDS); + RetryUtils.retry(TestConstants.TWENTY, (v) -> mqttTopic.length() > 0 ? Boolean.TRUE : null); + + Assert.assertEquals("test/2d/test", mqttTopic); + Assert.assertEquals(true, msgReceived); + } + + /** + * Test ignite key. + */ + public class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + + return "test"; + } + + } + + /** + * Test IgniteEvent impl. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Sample"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("test"); + } + + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherWithoutToDeviceForSubServicesTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherWithoutToDeviceForSubServicesTest.java new file mode 100644 index 0000000..302aaf9 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherWithoutToDeviceForSubServicesTest.java @@ -0,0 +1,193 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.DefaultMqttTopicNameGeneratorImpl; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.Optional; + + +/** + * Test class to test the MqttDispatcher class functionality. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/test-mqtt-sub-services.properties") +public class MqttDispatcherWithoutToDeviceForSubServicesTest { + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS_SERVER. */ + @ClassRule + public static final EmbeddedRedisServer REDIS_SERVER = new EmbeddedRedisServer(); + + /** The value. */ + private DeviceMessage value; + + /** The default mqtt topic name generator impl. */ + @Autowired + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** + * setup(). + */ + @Before + public void setup() { + TestEvent event = new TestEvent(); + value = new DeviceMessage(); + value.setMessage(transformer.toBlob(event)); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId(event.getTargetDeviceId().get()); + value.setDeviceMessageHeader(header); + } + + /** + * Test with custom mqtttopic for sub services. + */ + @Test + public void testWithCustomMqtttopicForSubServices() { + TestEvent event = new TestEvent(); + event.setDevMsgTopicSuffix("CUSTOM/TOPIC"); + event.setDevMsgTopicPrefix("userId/"); + value = new DeviceMessage(transformer.toBlob(event), Version.V1_0, event, + "feedBackTopic", Constants.THREAD_SLEEP_TIME_60000); + + defaultMqttTopicNameGeneratorImpl.setTopicNamePrefix("userId/"); + Optional mqttTopic = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(new TestKey(), + value.getDeviceMessageHeader(), null); + String mqttExpectedTopic = "userId/device123/CUSTOM/TOPIC"; + Assert.assertEquals(mqttExpectedTopic, mqttTopic.get()); + + event = new TestEvent(); + event.setDevMsgTopicSuffix("/custom/topic"); + value = new DeviceMessage(transformer.toBlob(event), Version.V1_0, event, + "feedBackTopic", Constants.THREAD_SLEEP_TIME_60000); + + // Test whether the first occuring "/" is being removed and not + // consecutive ones + mqttTopic = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(new TestKey(), value.getDeviceMessageHeader(), + null); + mqttExpectedTopic = "userId/device123/CUSTOM/TOPIC"; + Assert.assertEquals(mqttExpectedTopic, mqttTopic.get()); + } + + /** + * inner class TestKey implements IgniteKey. + */ + public class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + + return "device123"; + } + + } + + /** + * inner class TestEvent extends IgniteEventImpl. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "testservice"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("device123"); + } + + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherWithoutTopicPrefixIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherWithoutTopicPrefixIntegrationTest.java new file mode 100644 index 0000000..227d86e --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MqttDispatcherWithoutTopicPrefixIntegrationTest.java @@ -0,0 +1,265 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.commons.lang3.StringUtils; +import org.apache.kafka.test.TestUtils; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.utils.DefaultMqttTopicNameGeneratorImpl; +import org.eclipse.ecsp.analytics.stream.base.utils.MqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.PahoMqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.RetryUtils; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.io.IOException; +import java.util.Optional; + + + +/** + * Test class to test the MqttDispatcher class functionality. + * + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/test-mqtt-without-topic-prefix.properties") +public class MqttDispatcherWithoutTopicPrefixIntegrationTest { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(MqttDispatcherWithoutTopicPrefixIntegrationTest.class); + + /** The msg received. */ + boolean msgReceived = false; + + /** The mqtt topic. */ + private String mqttTopic = StringUtils.EMPTY; + + /** The default mqtt topic name generator impl. */ + @Autowired + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The mqtt dispatcher. */ + @Autowired + private MqttDispatcher mqttDispatcher; + + /** The paho mqtt dispatcher. */ + @Autowired + private PahoMqttDispatcher pahoMqttDispatcher; + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS_SERVER. */ + @ClassRule + public static final EmbeddedRedisServer REDIS_SERVER = new EmbeddedRedisServer(); + + /** + * Setup class. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + @BeforeClass + public static void setupClass() throws IOException { + TestUtils.startMqttServer(); + } + + /** + * Teardown class. + */ + @AfterClass + public static void teardownClass() { + TestUtils.stopMqttServer(); + } + + /** The value. */ + private DeviceMessage value; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** + * Setup for this test class. + */ + @Before + public void setup() { + TestEvent event = new TestEvent(); + value = new DeviceMessage(); + value.setMessage(transformer.toBlob(event)); + value.setEvent(event); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId("test"); + value.setDeviceMessageHeader(header); + } + + /** + * Test client connection without topic pefix. + * + * @throws InterruptedException the interrupted exception + * @throws MqttException the mqtt exception + */ + @Test + public void testClientConnection_without_topic_pefix() throws InterruptedException, MqttException { + defaultMqttTopicNameGeneratorImpl.setTopicNamePrefix(""); + TestKey key = new TestKey(); + /* + * get a client to subscribe to the required topic topic. + */ + String mqttTopicToSubscribe = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(key, + value.getDeviceMessageHeader(), null).get(); + MqttClient client = pahoMqttDispatcher.getMqttClient(PropertyNames.DEFAULT_PLATFORMID).get(); + client.subscribe(mqttTopicToSubscribe); + client.setCallback(new MqttCallback() { + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + LOGGER.error("Msg received:{} on topic:{}", message, topic); + msgReceived = true; + mqttTopic = topic; + + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + + } + + @Override + public void connectionLost(Throwable cause) { + + } + }); + + mqttDispatcher.dispatch(key, value); + + RetryUtils.retry(TestConstants.TWENTY, (v) -> { + return mqttTopic.length() > 0 ? Boolean.TRUE : null; + }); + + Assert.assertEquals("test/2d/test", mqttTopic); + Assert.assertEquals(true, msgReceived); + + } + + /** + * Test implementation for IgniteKey. + */ + public class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + + return "test"; + } + + } + + /** + * Test implementation for IgniteEvent. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Sample"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("test"); + } + + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MsgSeqPreProcessorTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MsgSeqPreProcessorTest.java new file mode 100644 index 0000000..80c4989 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/MsgSeqPreProcessorTest.java @@ -0,0 +1,683 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import com.codahale.metrics.MetricRegistry; +import org.apache.kafka.common.serialization.Serde; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.processor.ProcessorContext; +import org.apache.kafka.streams.processor.PunctuationType; +import org.apache.kafka.streams.processor.Punctuator; +import org.apache.kafka.streams.processor.StateStore; +import org.apache.kafka.streams.processor.api.Record; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.apache.kafka.streams.state.KeyValueStore; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.jetbrains.annotations.NotNull; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import static java.util.concurrent.CompletableFuture.delayedExecutor; +import static java.util.concurrent.CompletableFuture.runAsync; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +/** + * test class for {@link MsgSeqPreProcessor}. + */ + +public class MsgSeqPreProcessorTest { + + /** The Constant TASK_ID. */ + private static final String TASK_ID = "msg-seq-task-1"; + + /** The logger. */ + private static Logger logger = LoggerFactory.getLogger(MsgSeqPreProcessorTest.class); + + /** The spc. */ + private MsgSeqStreamProcessingContext, IgniteEvent> spc; + + /** + * Setup. + */ + @Before + public void setup() { + + } + + /** + * Test discard old message. + */ + @Test + public void testDiscardOldMessage() { + MsgSeqPreProcessor msgSeqPreProcessor = new MsgSeqPreProcessor(); + spc = new MsgSeqStreamProcessingContext, IgniteEvent>(msgSeqPreProcessor); + msgSeqPreProcessor.setStateStore(new TestMsgSeqStreamStateStore()); + msgSeqPreProcessor.setMsgSeqTimeIntInMillis(Constants.THREAD_SLEEP_TIME_200); + msgSeqPreProcessor.setMsgSeqTopicName("test-seq-topic"); + msgSeqPreProcessor.setSequenceBufferImplClass("org.eclipse.ecsp.analytics." + + "stream.base.SequenceBufferTreeMapImpl"); + msgSeqPreProcessor.init(spc); + + IgniteStringKey igniteStringKey1 = new IgniteStringKey(); + igniteStringKey1.setKey("TestMsgSeq1"); + + IgniteEventImpl eventImpl1 = new IgniteEventImpl(); + eventImpl1.setTimestamp(System.currentTimeMillis()); + eventImpl1.setEventId(EventID.SPEED); + eventImpl1.setRequestId("e1"); + + msgSeqPreProcessor.process(new Record<>(igniteStringKey1, eventImpl1, System.currentTimeMillis())); + + Assert.assertEquals(0, spc.getListOfEventsAfterOrdering().size()); + } + + /** + * Test disabled ordering. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testDisabledOrdering() throws InterruptedException { + MsgSeqPreProcessor msgSeqPreProcessor = new MsgSeqPreProcessor(); + spc = new MsgSeqStreamProcessingContext, IgniteEvent>(msgSeqPreProcessor); + msgSeqPreProcessor.setStateStore(new TestMsgSeqStreamStateStore()); + msgSeqPreProcessor.setMsgSeqTimeIntInMillis(0); + msgSeqPreProcessor.setMsgSeqTopicName("test-seq-topic"); + msgSeqPreProcessor.setSequenceBufferImplClass("org.eclipse.ecsp.analytics." + + "stream.base.SequenceBufferTreeMapImpl"); + msgSeqPreProcessor.init(spc); + + long currentTime = System.currentTimeMillis(); + + IgniteStringKey igniteStringKey1 = new IgniteStringKey(); + igniteStringKey1.setKey("TestMsgSeq1"); + + IgniteEventImpl eventImpl1 = new IgniteEventImpl(); + eventImpl1.setTimestamp(currentTime + Constants.THREAD_SLEEP_TIME_100); + eventImpl1.setEventId(EventID.SPEED); + eventImpl1.setRequestId("e1"); + + IgniteStringKey igniteStringKey2 = new IgniteStringKey(); + igniteStringKey2.setKey("TestMsgSeq2"); + + IgniteEventImpl eventImpl2 = new IgniteEventImpl(); + eventImpl2.setTimestamp(currentTime + Constants.THREAD_SLEEP_TIME_200); + eventImpl2.setEventId(EventID.SPEED); + eventImpl2.setRequestId("e2"); + + IgniteStringKey igniteStringKey3 = new IgniteStringKey(); + igniteStringKey3.setKey("TestMsgSeq3"); + + IgniteEventImpl eventImpl3 = new IgniteEventImpl(); + eventImpl3.setTimestamp(currentTime + Constants.THREAD_SLEEP_TIME_300); + eventImpl3.setEventId(EventID.SPEED); + eventImpl3.setRequestId("e3"); + + IgniteStringKey igniteStringKey4 = new IgniteStringKey(); + igniteStringKey4.setKey("TestMsgSeq4"); + + IgniteEventImpl eventImpl4 = new IgniteEventImpl(); + eventImpl4.setTimestamp(currentTime + Constants.THREAD_SLEEP_TIME_4000); + eventImpl4.setEventId(EventID.SPEED); + eventImpl4.setRequestId("e4"); + + // Send messaging randomly, but it should reach in sequence e1,e2,e3,e4 + msgSeqPreProcessor.process(new Record<>(igniteStringKey4, eventImpl4, System.currentTimeMillis())); + msgSeqPreProcessor.process(new Record<>(igniteStringKey2, eventImpl2, System.currentTimeMillis())); + msgSeqPreProcessor.process(new Record<>(igniteStringKey1, eventImpl1, System.currentTimeMillis())); + msgSeqPreProcessor.process(new Record<>(igniteStringKey3, eventImpl3, System.currentTimeMillis())); + runAsync(() -> {}, delayedExecutor(Constants.THREAD_SLEEP_TIME_5000, MILLISECONDS)).join(); + + Assert.assertEquals("e4", ((IgniteEventImpl) spc.getAndRemove()).getRequestId()); + Assert.assertEquals("e2", ((IgniteEventImpl) spc.getAndRemove()).getRequestId()); + Assert.assertEquals("e1", ((IgniteEventImpl) spc.getAndRemove()).getRequestId()); + Assert.assertEquals("e3", ((IgniteEventImpl) spc.getAndRemove()).getRequestId()); + + } + + /** + * Test ordering. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testOrdering() throws InterruptedException { + logger.info("Test Ordering is started."); + MsgSeqPreProcessor msgSeqPreProcessor = new MsgSeqPreProcessor(); + spc = new MsgSeqStreamProcessingContext(msgSeqPreProcessor); + msgSeqPreProcessor.setStateStore(new TestMsgSeqStreamStateStore()); + msgSeqPreProcessor.setMsgSeqTopicName("test-seq-topic"); + msgSeqPreProcessor.setSequenceBufferImplClass("org.eclipse.ecsp.analytics." + + "stream.base.SequenceBufferTreeMapImpl"); + // Set as 3 sec, buffer interval + // Flush thread starts after 3 Sec + msgSeqPreProcessor.setMsgSeqTimeIntInMillis(Constants.THREE * Constants.THREAD_SLEEP_TIME_1000); + msgSeqPreProcessor.init(spc); + + long currentTime = System.currentTimeMillis(); + + IgniteStringKey igniteStringKey1 = new IgniteStringKey(); + igniteStringKey1.setKey("TestMsgSeq1"); + + // Start bucketing of the events between 3 secs [first bucket] + + IgniteStringKey igniteStringKey2 = new IgniteStringKey(); + igniteStringKey2.setKey("TestMsgSeq2"); + + IgniteEventImpl eventImpl2 = new IgniteEventImpl(); + + // Set event time after 1.5 sec + eventImpl2.setTimestamp(currentTime + Constants.THREAD_SLEEP_TIME_1500); + eventImpl2.setEventId(EventID.SPEED); + eventImpl2.setRequestId("e2"); + eventImpl2.setSourceDeviceId("deviceId2"); + + IgniteStringKey igniteStringKey3 = new IgniteStringKey(); + igniteStringKey3.setKey("TestMsgSeq3"); + + IgniteEventImpl eventImpl3 = new IgniteEventImpl(); + // Set event time after 2 sec + eventImpl3.setTimestamp(currentTime + Constants.THREAD_SLEEP_TIME_2000); + eventImpl3.setEventId(EventID.SPEED); + eventImpl3.setRequestId("e3"); + eventImpl3.setSourceDeviceId("deviceId3"); + // End bucketing of the events between 3 secs [first bucket] + + // Start bucketing of the events next 3 secs [Second bucket] + currentTime += Constants.THREAD_SLEEP_TIME_3000; + IgniteStringKey igniteStringKey4 = new IgniteStringKey(); + igniteStringKey4.setKey("TestMsgSeq4"); + + IgniteEventImpl eventImpl4 = new IgniteEventImpl(); + eventImpl4.setTimestamp(currentTime + Constants.THREAD_SLEEP_TIME_1000); + eventImpl4.setEventId(EventID.SPEED); + eventImpl4.setRequestId("e4"); + eventImpl4.setSourceDeviceId("deviceId4"); + + IgniteStringKey igniteStringKey5 = new IgniteStringKey(); + igniteStringKey5.setKey("TestMsgSeq5"); + + IgniteEventImpl eventImpl5 = new IgniteEventImpl(); + eventImpl5.setTimestamp(currentTime + Constants.THREAD_SLEEP_TIME_2000); + eventImpl5.setEventId(EventID.SPEED); + eventImpl5.setRequestId("e5"); + eventImpl5.setSourceDeviceId("deviceId5"); + // Start bucketing of the events next 3 secs [Second bucket] + + // Send messaging randomly, but it should reach in sequence: first + // bucket- [e1,e2,e3] second bucket- [e4,e5] + msgSeqPreProcessor.process(new Record<>(igniteStringKey4, eventImpl4, System.currentTimeMillis())); + IgniteEventImpl eventImpl1 = getIgniteEvent(currentTime); + msgSeqPreProcessor.process(new Record<>(igniteStringKey1, eventImpl1, System.currentTimeMillis())); + msgSeqPreProcessor.process(new Record<>(igniteStringKey2, eventImpl2, System.currentTimeMillis())); + + // Purposefully send the third event after 4000 milis. Bucket holds the + // events just double the configured interval, so that boundary event is + // also ordered. + runAsync(() -> {}, delayedExecutor(Constants.THREAD_SLEEP_TIME_4000, MILLISECONDS)).join(); + msgSeqPreProcessor.process(new Record<>(igniteStringKey3, eventImpl3, System.currentTimeMillis())); + msgSeqPreProcessor.process(new Record<>(igniteStringKey5, eventImpl5, System.currentTimeMillis())); + + // Flush Event will every 3 sec (as per above configuration). Flush + // event's timestamp is just behind the configured bucket interval. + // Because of that need to wait for near to double time to flush the + // events. After 6000 milis, second bucket will flush, becasuse of that + // only wait still 5900 to flush the first bucket. + runAsync(() -> {}, delayedExecutor(Constants.THREAD_SLEEP_TIME_5900, MILLISECONDS)).join(); + logger.info("Size of fowareded messages: {}", spc.getListOfEventsAfterOrdering().size()); + Assert.assertEquals("e1", ((IgniteEventImpl) spc.getAndRemove()).getRequestId()); + Assert.assertEquals("e2", ((IgniteEventImpl) spc.getAndRemove()).getRequestId()); + Assert.assertEquals("e3", ((IgniteEventImpl) spc.getAndRemove()).getRequestId()); + + // Test the second bucket is flushed or not + runAsync(() -> {}, delayedExecutor(Constants.THREAD_SLEEP_TIME_9000, MILLISECONDS)).join(); + logger.info("Size of fowareded messages: {}", spc.getListOfEventsAfterOrdering().size()); + Assert.assertEquals("e4", ((IgniteEventImpl) spc.getAndRemove()).getRequestId()); + Assert.assertEquals("e5", ((IgniteEventImpl) spc.getAndRemove()).getRequestId()); + } + + /** + * Gets the ignite event. + * + * @param currentTime the current time + * @return the ignite event + */ + @NotNull + private static IgniteEventImpl getIgniteEvent(long currentTime) { + IgniteEventImpl eventImpl1 = new IgniteEventImpl(); + // Set event time after 1 sec + eventImpl1.setTimestamp(currentTime + Constants.THREAD_SLEEP_TIME_1000); + eventImpl1.setEventId(EventID.SPEED); + eventImpl1.setRequestId("e1"); + eventImpl1.setSourceDeviceId("deviceId1"); + return eventImpl1; + } + + /** + * The Class MsgSeqStreamProcessingContext. + * + * @param the key type + * @param the value type + */ + class MsgSeqStreamProcessingContext implements StreamProcessingContext { + + /** The msg seq pre processor. */ + private MsgSeqPreProcessor msgSeqPreProcessor; + + /** The list of events after ordering. */ + private List listOfEventsAfterOrdering = new ArrayList(); + + /** + * Instantiates a new msg seq stream processing context. + * + * @param msgSeqPreProcessor the msg seq pre processor + */ + public MsgSeqStreamProcessingContext(MsgSeqPreProcessor msgSeqPreProcessor) { + this.msgSeqPreProcessor = msgSeqPreProcessor; + } + + /** + * Stream name. + * + * @return the string + */ + @Override + public String streamName() { + return "test-msg-seq-processor"; + } + + /** + * Partition. + * + * @return the int + */ + @Override + public int partition() { + return 0; + } + + /** + * Offset. + * + * @return the long + */ + @Override + public long offset() { + return 0; + } + + /** + * Checkpoint. + */ + @Override + public void checkpoint() { + + + } + + /** + * Gets the state store. + * + * @param name the name + * @return the state store + */ + @Override + public KeyValueStore getStateStore(String name) { + return new TestMsgSeqStreamStateStore(); + } + + + /** + * Forward directly. + * + * @param key the key + * @param value the value + * @param topic the topic + */ + @Override + public void forwardDirectly(String key, String value, String topic) { + } + + /** + * Forward directly. + * + * @param key the key + * @param value the value + * @param topic the topic + */ + @Override + public void forwardDirectly(@SuppressWarnings("rawtypes") IgniteKey key, IgniteEvent value, String topic) { + msgSeqPreProcessor.process(new Record<>(key, value, System.currentTimeMillis())); + } + + /** + * Gets the task ID. + * + * @return the task ID + */ + @Override + public String getTaskID() { + return TASK_ID; + } + + /** + * Gets the metric registry. + * + * @return the metric registry + */ + @Override + public MetricRegistry getMetricRegistry() { + + return null; + } + + /** + * Schedule. + * + * @param interval the interval + * @param punctuationType the punctuation type + * @param punctuator the punctuator + */ + @Override + public void schedule(long interval, PunctuationType punctuationType, Punctuator punctuator) { + + + } + + /** + * Forward. + * + * @param kafkaRecord the kafka record + */ + @Override + public void forward(Record kafkaRecord) { + K key = kafkaRecord.key(); + V value = kafkaRecord.value(); + listOfEventsAfterOrdering.add(value); + logger.info("Msg k {}, requestId {}", key, ((IgniteEventImpl) value).getRequestId()); + } + + /** + * Forward. + * + * @param kafkaRecord the kafka record + * @param name the name + */ + @Override + public void forward(Record kafkaRecord, String name) { + + + } + + /** + * Gets the list of events after ordering. + * + * @return the list of events after ordering + */ + public List getListOfEventsAfterOrdering() { + return listOfEventsAfterOrdering; + } + + /** + * Reset list of events after ordering. + * + * @param listOfEventsAfterOrdering the list of events after ordering + */ + public void resetListOfEventsAfterOrdering(List listOfEventsAfterOrdering) { + this.listOfEventsAfterOrdering.clear(); + } + + /** + * Gets the and remove. + * + * @return the and remove + */ + public Object getAndRemove() { + return listOfEventsAfterOrdering.remove(0); + } + } + + /** + * The Class TestMsgSeqStreamStateStore. + */ + class TestMsgSeqStreamStateStore extends HarmanPersistentKVStore { + + /** The name. */ + String name = "TestMsgSeqStreamStateStore"; + + /** The data. */ + Map data = new HashMap<>(); + + /** + * Instantiates a new test msg seq stream state store. + */ + public TestMsgSeqStreamStateStore() { + super("TestMsgSeqStreamStateStore", + true, null, null, new Properties()); + } + + /** + * Instantiates a new test msg seq stream state store. + * + * @param name the name + * @param changeLoggingEnabled the change logging enabled + * @param keySerde the key serde + * @param valueSerde the value serde + * @param properties the properties + */ + public TestMsgSeqStreamStateStore(String name, boolean changeLoggingEnabled, + Serde keySerde, Serde valueSerde, + Properties properties) { + super(name, changeLoggingEnabled, keySerde, valueSerde, properties); + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "TestMsgSeqStreamStateStore"; + } + + /** + * Inits the. + * + * @param context the context + * @param root the root + */ + @Override + public void init(ProcessorContext context, StateStore root) { + + } + + /** + * Flush. + */ + @Override + public void flush() { + + + } + + /** + * Close. + */ + @Override + public void close() { + + + } + + /** + * Persistent. + * + * @return true, if successful + */ + @Override + public boolean persistent() { + + return false; + } + + /** + * Checks if is open. + * + * @return true, if is open + */ + @Override + public boolean isOpen() { + + return false; + } + + /** + * Gets the. + * + * @param key the key + * @return the object + */ + @Override + public Object get(String key) { + + return data.get(key); + } + + /** + * Range. + * + * @param from the from + * @param to the to + * @return the key value iterator + */ + @Override + public KeyValueIterator range(String from, String to) { + + return null; + } + + /** + * All. + * + * @return the key value iterator + */ + @Override + public KeyValueIterator all() { + + return null; + } + + /** + * Approximate num entries. + * + * @return the long + */ + @Override + public long approximateNumEntries() { + + return 0; + } + + /** + * Put. + * + * @param key the key + * @param value the value + */ + @Override + public void put(String key, Object value) { + data.put(key, value); + } + + /** + * Put if absent. + * + * @param key the key + * @param value the value + * @return the object + */ + @Override + public Object putIfAbsent(String key, Object value) { + + return null; + } + + /** + * Put all. + * + * @param entries the entries + */ + @Override + public void putAll(List> entries) { + + + } + + /** + * Delete. + * + * @param key the key + * @return the object + */ + @Override + public Object delete(String key) { + data.remove(key); + return null; + } + + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/ProtocolTranslatorPreProcessorTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/ProtocolTranslatorPreProcessorTest.java new file mode 100644 index 0000000..7b78369 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/ProtocolTranslatorPreProcessorTest.java @@ -0,0 +1,491 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.Headers; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.header.internals.RecordHeaders; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.platform.IgnitePlatform; +import org.eclipse.ecsp.analytics.stream.base.platform.utils.PlatformUtils; +import org.eclipse.ecsp.analytics.stream.vehicleprofile.utils.VehicleProfileClientApiUtil; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.IgniteEventSource; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.transform.Transformer; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.util.Assert; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +/** + * {@link ProtocolTranslatorPreProcessorTest}. + */ +public class ProtocolTranslatorPreProcessorTest { + + /** The props. */ + Properties props; + + /** The spc. */ + @Mock + StreamProcessingContext spc; + + /** The transformer. */ + @Mock + Transformer transformer; + + /** The autowire capable bean factory. */ + @Mock + AutowireCapableBeanFactory autowireCapableBeanFactory; + + /** The protocol translator pre processor. */ + @InjectMocks + private ProtocolTranslatorPreProcessor protocolTranslatorPreProcessor; + + /** The ctx. */ + @Mock + private ApplicationContext ctx; + + /** The messge filter agent. */ + @Mock + private MessgeFilterAgent messgeFilterAgent; + + /** The platform utils. */ + @Mock + private PlatformUtils platformUtils; + + /** The vehicle profile client api util. */ + @Mock + private VehicleProfileClientApiUtil vehicleProfileClientApiUtil; + + /** + * setup(). + * + * @throws Exception Exception + */ + + @Before + public void setUp() throws Exception { + props = new Properties(); + MockitoAnnotations.openMocks(this); + } + + /** + * Test init config exception 1. + */ + //Event transformer list cannot be + @Test(expected = IllegalArgumentException.class) + public void testInitConfigException1() { + IgniteKey testKey = new ProtocolTranslatorPreProcessorTest.TestKey(); + ProtocolTranslatorPreProcessorTest.TestEvent event = new ProtocolTranslatorPreProcessorTest.TestEvent(); + event.setEventId(EventID.DELETE_SCHEDULE_EVENT); + protocolTranslatorPreProcessor.initConfig(props); + } + + /** + * Test init config exception 2. + */ + //Ignite event Serializer cannot be blank + @Test(expected = IllegalArgumentException.class) + public void testInitConfigException2() { + IgniteKey testKey = new ProtocolTranslatorPreProcessorTest.TestKey(); + ProtocolTranslatorPreProcessorTest.TestEvent event = new ProtocolTranslatorPreProcessorTest.TestEvent(); + event.setEventId(EventID.DELETE_SCHEDULE_EVENT); + props.setProperty(PropertyNames.EVENT_TRANSFORMER_CLASSES, "genericIgniteEventTransformer"); + protocolTranslatorPreProcessor.initConfig(props); + } + + /** + * Test init config exception 3. + */ + //Ignite key transformer cannot be blank + @Test(expected = IllegalArgumentException.class) + public void testInitConfigException3() { + IgniteKey testKey = new ProtocolTranslatorPreProcessorTest.TestKey(); + ProtocolTranslatorPreProcessorTest.TestEvent event = new ProtocolTranslatorPreProcessorTest.TestEvent(); + event.setEventId(EventID.DELETE_SCHEDULE_EVENT); + props.setProperty(PropertyNames.EVENT_TRANSFORMER_CLASSES, + "genericIgniteEventTransformer"); + props.setProperty(PropertyNames.INGESTION_SERIALIZER_CLASS, + "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + protocolTranslatorPreProcessor.initConfig(props); + } + + /** + * Test init config exception 4. + */ + @Test(expected = IllegalArgumentException.class) + public void testInitConfigException4() { + IgniteKey testKey = new ProtocolTranslatorPreProcessorTest.TestKey(); + ProtocolTranslatorPreProcessorTest.TestEvent event = + new ProtocolTranslatorPreProcessorTest.TestEvent(); + event.setEventId(EventID.DELETE_SCHEDULE_EVENT); + props.setProperty(PropertyNames.EVENT_TRANSFORMER_CLASSES, + "genericIgniteEventTransformer"); + props.setProperty(PropertyNames.INGESTION_SERIALIZER_CLASS, + "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + props.setProperty(PropertyNames.IGNITE_KEY_TRANSFORMER, + "org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + props.setProperty(PropertyNames.TRANSFORMER_INJECT_PROPERTY_ENABLE, "true"); + protocolTranslatorPreProcessor.initConfig(props); + } + + /** + * Test init method. + */ + @Test() + public void testInitMethod() { + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "isEnableDuplicateMessageCheck", true); + when(ctx.getBean(MessgeFilterAgent.class)).thenReturn(messgeFilterAgent); + protocolTranslatorPreProcessor.init(spc); + verify(ctx).getBean(MessgeFilterAgent.class); + Assert.notNull(ctx.getBean(MessgeFilterAgent.class), "MessgeFilterAgent must not be null"); + } + + /** + * Test process method with null key. + */ + @Test(expected = IllegalArgumentException.class) + public void testProcessMethodWithNullKey() { + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "isKafkaDataConsumptionMetricsEnabled", true); + protocolTranslatorPreProcessor.process(null); + } + + /** + * Test process method with null ignite key. + */ + @Test(expected = RuntimeException.class) + public void testProcessMethodWithNullIgniteKey() { + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "isKafkaDataConsumptionMetricsEnabled", true); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "igniteKeyTransformer", Optional.ofNullable(null)); + IgniteKey igniteKey = new TestKey(); + IgniteEvent igniteEvent = new IgniteEventImpl(); + Record kafkaRecord = + new Record<>(igniteKey.toString().getBytes(), igniteEvent.toString().getBytes(), + System.currentTimeMillis()); + protocolTranslatorPreProcessor.process(kafkaRecord); + } + + /** + * Test kafka header method. + */ + @Test + public void testKafkaHeaderMethod() { + Map map = new HashMap<>(); + map.put(IgniteEventSource.IGNITE, transformer); + IgniteEventImpl igniteEvent = new IgniteEventImpl(); + igniteEvent.setEventId("test"); + List vpPlatformIds = new ArrayList<>(); + vpPlatformIds.add("platform1"); + vpPlatformIds.add("platform2"); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "" + + "vehicleProfilePlatformIds", vpPlatformIds); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, + "isKafkaDataConsumptionMetricsEnabled", true); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, + "kafkaHeadersEnabled", true); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "ctx", ctx); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "deviceAwareEnable", true); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "transformerMap", map); + props.setProperty(PropertyNames.INGESTION_SERIALIZER_CLASS, + "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + props.setProperty(PropertyNames.EVENT_TRANSFORMER_CLASSES, "genericIgniteEventTransformer"); + props.setProperty(PropertyNames.IGNITE_KEY_TRANSFORMER, + "org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + props.setProperty(PropertyNames.TRANSFORMER_INJECT_PROPERTY_ENABLE, "true"); + props.setProperty(PropertyNames.INGESTION_SERIALIZER_CLASS, + "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + when(ctx.getAutowireCapableBeanFactory()).thenReturn(autowireCapableBeanFactory); + when(transformer.fromBlob(any(), any(Optional.class))).thenReturn(igniteEvent); + when(transformer.toBlob(any())).thenReturn(igniteEvent.toString().getBytes()); + when(autowireCapableBeanFactory.getBean(anyString(), any(Properties.class))).thenReturn(transformer); + protocolTranslatorPreProcessor.initConfig(props); + IgniteKey igniteKey = new TestKey(); + Record kafkaRecord = + new Record<>(igniteKey.toString().getBytes(), igniteEvent.toString().getBytes(), + System.currentTimeMillis()); + protocolTranslatorPreProcessor.process(kafkaRecord); + verify(spc, Mockito.times(1)).forward(any()); + } + + /** + * Test platform kafka header method. + */ + @Test + public void testPlatformKafkaHeaderMethod() { + String kafkaHeaderKey = "platformId"; + String kafkaHeaderValue = "testPlatform"; + List
    kafkaHeaders = new ArrayList<>(); + kafkaHeaders.add(new RecordHeader(kafkaHeaderKey, kafkaHeaderValue.getBytes(StandardCharsets.UTF_8))); + + Map map = new HashMap<>(); + map.put(IgniteEventSource.IGNITE, transformer); + map.put("testPlatform", transformer); + IgniteEventImpl igniteEvent = new IgniteEventImpl(); + igniteEvent.setEventId("test"); + List kafkaTopicNamePlatformPrefixes = new ArrayList<>(); + kafkaTopicNamePlatformPrefixes.add("testPlatform"); + List vpPlatformIds = new ArrayList<>(); + vpPlatformIds.add("platform1"); + vpPlatformIds.add("platform2"); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, + "vehicleProfilePlatformIds", vpPlatformIds); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, + "isKafkaDataConsumptionMetricsEnabled", true); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "kafkaHeadersEnabled", true); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "ctx", ctx); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "deviceAwareEnable", true); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "transformerMap", map); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, + "kafkaTopicNamePlatformPrefixes", kafkaTopicNamePlatformPrefixes); + props.setProperty(PropertyNames.INGESTION_SERIALIZER_CLASS, + "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + props.setProperty(PropertyNames.EVENT_TRANSFORMER_CLASSES, + "genericEventTransformer,somePlatformEventTransformer"); + props.setProperty(PropertyNames.IGNITE_KEY_TRANSFORMER, + "org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + props.setProperty(PropertyNames.TRANSFORMER_INJECT_PROPERTY_ENABLE, "true"); + props.setProperty(PropertyNames.INGESTION_SERIALIZER_CLASS, + "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + when(ctx.getAutowireCapableBeanFactory()).thenReturn(autowireCapableBeanFactory); + when(spc.streamName()).thenReturn(null); + when(transformer.fromBlob(any(), any(IgniteKey.class))).thenReturn(igniteEvent); + when(transformer.toBlob(any())).thenReturn(igniteEvent.toString().getBytes()); + when(autowireCapableBeanFactory.getBean(anyString(), any(Properties.class))).thenReturn(transformer); + protocolTranslatorPreProcessor.initConfig(props); + IgniteKey igniteKey = new TestKey(); + Headers header = new RecordHeaders(kafkaHeaders); + Record kafkaRecord = + new Record<>(igniteKey.toString().getBytes(), igniteEvent.toString().getBytes(), + System.currentTimeMillis(), header); + protocolTranslatorPreProcessor.process(kafkaRecord); + verify(spc, Mockito.times(1)).forward(any()); + } + + /** + * Test platform kafka topic. + */ + @Test + public void testPlatformKafkaTopic() { + Map map = new HashMap<>(); + map.put(IgniteEventSource.IGNITE, transformer); + map.put("platform1", transformer); + IgniteEventImpl igniteEvent = new IgniteEventImpl(); + igniteEvent.setEventId("test"); + List kafkaTopicNamePlatformPrefixes = new ArrayList<>(); + kafkaTopicNamePlatformPrefixes.add("platform1"); + List vpPlatformIds = new ArrayList<>(); + vpPlatformIds.add("platform1"); + vpPlatformIds.add("platform2"); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, + "vehicleProfilePlatformIds", vpPlatformIds); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, + "isKafkaDataConsumptionMetricsEnabled", true); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "kafkaHeadersEnabled", false); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "ctx", ctx); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "deviceAwareEnable", true); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "transformerMap", map); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, + "kafkaTopicNamePlatformPrefixes", kafkaTopicNamePlatformPrefixes); + props.setProperty(PropertyNames.INGESTION_SERIALIZER_CLASS, + "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + props.setProperty(PropertyNames.EVENT_TRANSFORMER_CLASSES, + "genericEventTransformer,somePlaformEventTransformer"); + props.setProperty(PropertyNames.IGNITE_KEY_TRANSFORMER, + "org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + props.setProperty(PropertyNames.TRANSFORMER_INJECT_PROPERTY_ENABLE, "true"); + props.setProperty(PropertyNames.INGESTION_SERIALIZER_CLASS, + "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + when(ctx.getAutowireCapableBeanFactory()).thenReturn(autowireCapableBeanFactory); + when(spc.streamName()).thenReturn("platform1-test-topic"); + when(transformer.fromBlob(any(), any(IgniteKey.class))).thenReturn(igniteEvent); + when(transformer.toBlob(any())).thenReturn(igniteEvent.toString().getBytes()); + when(autowireCapableBeanFactory.getBean(anyString(), any(Properties.class))).thenReturn(transformer); + protocolTranslatorPreProcessor.initConfig(props); + IgniteKey igniteKey = new TestKey(); + Record kafkaRecord = + new Record<>(igniteKey.toString().getBytes(), igniteEvent.toString().getBytes(), + System.currentTimeMillis()); + protocolTranslatorPreProcessor.process(kafkaRecord); + verify(spc, Mockito.times(1)).forward(any()); + } + + /** + * Test platform service implementation. + */ + public void testPlatformServiceImpl() { + Map map = new HashMap<>(); + map.put(IgniteEventSource.IGNITE, transformer); + map.put("platform1", transformer); + map.put("platform2", transformer); + IgniteEventImpl igniteEvent = new IgniteEventImpl(); + igniteEvent.setEventId("test"); + String sourceDeviceId = "test123"; + igniteEvent.setSourceDeviceId(sourceDeviceId); + List kafkaTopicNamePlatformPrefixes = new ArrayList<>(); + kafkaTopicNamePlatformPrefixes.add("platform2"); + List vpPlatformIds = new ArrayList<>(); + vpPlatformIds.add("platform1"); + vpPlatformIds.add("platform2"); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, + "vehicleProfilePlatformIds", vpPlatformIds); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, + "isKafkaDataConsumptionMetricsEnabled", true); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "kafkaHeadersEnabled", false); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "ctx", ctx); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "deviceAwareEnable", true); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "transformerMap", map); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, + "kafkaTopicNamePlatformPrefixes", kafkaTopicNamePlatformPrefixes); + IgnitePlatformImpl platformImpl = new IgnitePlatformImpl(); + ReflectionTestUtils.setField(protocolTranslatorPreProcessor, "platformIdServiceImpl", + platformImpl.getClass().getCanonicalName()); + props.setProperty(PropertyNames.INGESTION_SERIALIZER_CLASS, + "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + props.setProperty(PropertyNames.EVENT_TRANSFORMER_CLASSES, + "genericEventTransformer,somePlatformEventTransformer"); + props.setProperty(PropertyNames.IGNITE_KEY_TRANSFORMER, + "org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + props.setProperty(PropertyNames.TRANSFORMER_INJECT_PROPERTY_ENABLE, "true"); + props.setProperty(PropertyNames.INGESTION_SERIALIZER_CLASS, + "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + when(ctx.getAutowireCapableBeanFactory()).thenReturn(autowireCapableBeanFactory); + when(spc.streamName()).thenReturn("platform-test-topic"); + when(transformer.fromBlob(any(), any(IgniteKey.class))).thenReturn(igniteEvent); + when(transformer.toBlob(any())).thenReturn(igniteEvent.toString().getBytes()); + when(autowireCapableBeanFactory.getBean(anyString(), any(Properties.class))).thenReturn(transformer); + when(platformUtils.getInstanceByClassName(platformImpl.getClass().getCanonicalName())).thenReturn(platformImpl); + when(vehicleProfileClientApiUtil.callVehicleProfile(sourceDeviceId)).thenReturn("VIN123"); + protocolTranslatorPreProcessor.initConfig(props); + IgniteKey igniteKey = new TestKey(); + Record kafkaRecord = + new Record<>(igniteKey.toString().getBytes(), igniteEvent.toString().getBytes(), + System.currentTimeMillis()); + protocolTranslatorPreProcessor.process(kafkaRecord); + verify(spc, Mockito.times(1)).forward(any()); + } + + /** + * The Class TestKey. + */ + private class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + + return "Vehicle12345"; + } + } + + /** + * inner class TestEvent extends IgniteEventImpl. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("test"); + } + } + + /** + * The Class IgnitePlatformImpl. + */ + private class IgnitePlatformImpl implements IgnitePlatform { + + /** + * Gets the platform id. + * + * @param cxt the cxt + * @param arg0 the arg 0 + * @return the platform id + */ + @Override + public String getPlatformId(StreamProcessingContext, IgniteEvent> cxt, + Record, IgniteEvent> arg0) { + return "testPlatform"; + } + } +} + diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/SchedulerAgentPostProcessorTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/SchedulerAgentPostProcessorTest.java new file mode 100644 index 0000000..2c56731 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/SchedulerAgentPostProcessorTest.java @@ -0,0 +1,357 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.domain.DeviceMessageFailureEventDataV1_0; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.SpeedV1_0; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.events.scheduler.CreateScheduleEventData; +import org.eclipse.ecsp.events.scheduler.DeleteScheduleEventData; +import org.eclipse.ecsp.events.scheduler.ScheduleNotificationEventData; +import org.eclipse.ecsp.events.scheduler.ScheduleOpStatusEventData; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntry; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntryDAOMongoImpl; +import org.eclipse.ecsp.stream.dma.handler.DeviceMessageUtils; +import org.eclipse.ecsp.stream.dma.scheduler.DeviceMessagingEventScheduler; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.Properties; + + +/** + * UT class for {@link SchedulerAgentPostProcessor}. + */ +public class SchedulerAgentPostProcessorTest { + + /** The Constant FEEDBACK_TOPIC. */ + private static final String FEEDBACK_TOPIC = "testTopic"; + + /** The scheduler agent post processor. */ + @InjectMocks + private SchedulerAgentPostProcessor schedulerAgentPostProcessor; + + /** The ctxt. */ + @Mock + private StreamProcessingContext, IgniteEvent> ctxt; + + /** The event scheduler. */ + @Mock + private DeviceMessagingEventScheduler eventScheduler; + + /** The offline buffer DAO. */ + @Mock + private DMOfflineBufferEntryDAOMongoImpl offlineBufferDAO; + + /** The device message utils. */ + @Mock + private DeviceMessageUtils deviceMessageUtils; + + /** + * to initialize properties. + * + * @throws Exception Exception + */ + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + + ReflectionTestUtils.setField(schedulerAgentPostProcessor, "schedulerAgentTopic", + "scheduler"); + ReflectionTestUtils.setField(schedulerAgentPostProcessor, "ttlExpiryNotificationEnabled", + "true"); + ReflectionTestUtils.setField(schedulerAgentPostProcessor, "dmaEnabled", + "true"); + ReflectionTestUtils.setField(schedulerAgentPostProcessor, "removeOnTtlExpiryEnabled", + "true"); + } + + /** + * Test process schedule notification event without removal. + */ + @Test + public void testProcess_ScheduleNotificationEventWithoutRemoval() { + ReflectionTestUtils.setField(schedulerAgentPostProcessor, "removeOnTtlExpiryEnabled", + "false"); + DMOfflineBufferEntry entry = new DMOfflineBufferEntry(); + DeviceMessage deviceMessage = new DeviceMessage(); + deviceMessage.setEvent(new TestEvent()); + deviceMessage.setFeedBackTopic(FEEDBACK_TOPIC); + IgniteKey key = new IgniteStringKey("testKey"); + entry.setEvent(deviceMessage); + entry.setIgniteKey(key); + entry.setTtlNotifProcessed(false); + List offlineEntries = new ArrayList<>(); + offlineEntries.add(entry); + + TestEvent event = new TestEvent(); + event.setEventId(EventID.SCHEDULE_NOTIFICATION_EVENT); + ScheduleNotificationEventData eventData = new ScheduleNotificationEventData(); + eventData.setScheduleIdId("test1"); + eventData.setTriggerTimeMs(1L); + event.setEventData(eventData); + IgniteKey testKey = new TestKey(); + Mockito.when(offlineBufferDAO.getOfflineBufferEntriesWithExpiredTtl()).thenReturn(offlineEntries); + + schedulerAgentPostProcessor.process(new Record<>(testKey, event, System.currentTimeMillis())); + + Mockito.verify(deviceMessageUtils, Mockito.times(1)) + .postFailureEvent(ArgumentMatchers.any(DeviceMessageFailureEventDataV1_0.class), + ArgumentMatchers.any(IgniteKey.class), + ArgumentMatchers.any(StreamProcessingContext.class), ArgumentMatchers.contains(FEEDBACK_TOPIC)); + Assert.assertEquals(true, entry.isTtlNotifProcessed()); + + Mockito.verify(offlineBufferDAO, Mockito.times(0)).removeOfflineBufferEntry(ArgumentMatchers.any()); + Mockito.verify(offlineBufferDAO, Mockito.times(1)).update(ArgumentMatchers.any(DMOfflineBufferEntry.class)); + Mockito.verify(ctxt, Mockito.times(1)).forward(ArgumentMatchers., IgniteEvent>>any()); + Mockito.verify(eventScheduler, Mockito.times(1)) + .scheduleEvent(ArgumentMatchers.any(StreamProcessingContext.class)); + } + + /** + * Test process create schedule event. + */ + @Test + public void testProcess_CreateScheduleEvent() { + IgniteKey testKey = new TestKey(); + TestEvent event = new TestEvent(); + event.setEventId(EventID.CREATE_SCHEDULE_EVENT); + + CreateScheduleEventData createScheduleEventData = new CreateScheduleEventData(); + event.setEventData(createScheduleEventData); + + schedulerAgentPostProcessor.process(new Record<>(testKey, event, System.currentTimeMillis())); + + Mockito.verify(ctxt, + Mockito.times(1)).forwardDirectly(ArgumentMatchers.any(TestKey.class), + ArgumentMatchers.any(TestEvent.class), + ArgumentMatchers.any(String.class)); + + Mockito.verify(ctxt, Mockito.times(1)).forward(ArgumentMatchers., IgniteEvent>>any()); + + } + + /** + * Test process schedule op status event. + */ + @Test + public void testProcess_ScheduleOpStatusEvent() { + TestEvent event = new TestEvent(); + event.setEventId(EventID.SCHEDULE_OP_STATUS_EVENT); + + ScheduleOpStatusEventData eventData = new ScheduleOpStatusEventData(); + eventData.setValid(true); + eventData.setScheduleId("testId"); + event.setEventData(eventData); + IgniteKey testKey = new TestKey(); + schedulerAgentPostProcessor.process(new Record<>(testKey, event, System.currentTimeMillis())); + + Mockito.verify(ctxt, Mockito.times(1)).forward(ArgumentMatchers., IgniteEvent>>any()); + } + + /** + * Test process schedule notification event. + */ + @Test + public void testProcess_ScheduleNotificationEvent() { + + List offlineEntries = new ArrayList<>(); + + for (int i = 0; i < Constants.THREE; i++) { + DMOfflineBufferEntry entry = new DMOfflineBufferEntry(); + + DeviceMessage deviceMessage = new DeviceMessage(); + deviceMessage.setEvent(new TestEvent()); + deviceMessage.setFeedBackTopic(FEEDBACK_TOPIC); + + entry.setEvent(deviceMessage); + IgniteKey key = new IgniteStringKey(i + ""); + entry.setIgniteKey(key); + offlineEntries.add(entry); + } + + TestEvent event = new TestEvent(); + event.setEventId(EventID.SCHEDULE_NOTIFICATION_EVENT); + ScheduleNotificationEventData eventData = new ScheduleNotificationEventData(); + eventData.setScheduleIdId("test1"); + eventData.setTriggerTimeMs(1L); + event.setEventData(eventData); + + Mockito.when(offlineBufferDAO.getOfflineBufferEntriesWithExpiredTtl()).thenReturn(offlineEntries); + IgniteKey testKey = new TestKey(); + schedulerAgentPostProcessor.process(new Record<>(testKey, event, System.currentTimeMillis())); + Mockito.verify(deviceMessageUtils, Mockito.times(Constants.THREE)) + .postFailureEvent(ArgumentMatchers.any(DeviceMessageFailureEventDataV1_0.class), + ArgumentMatchers.any(IgniteKey.class), ArgumentMatchers.any(StreamProcessingContext.class), + ArgumentMatchers.contains(FEEDBACK_TOPIC)); + for (int i = 0; i < Constants.THREE; i++) { + Mockito.verify(offlineBufferDAO, Mockito.times(1)) + .removeOfflineBufferEntry(ArgumentMatchers.contains(offlineEntries.get(i).getId())); + } + Mockito.verify(ctxt, Mockito.times(1)).forward(ArgumentMatchers., IgniteEvent>>any()); + Mockito.verify(eventScheduler, Mockito.times(1)) + .scheduleEvent(ArgumentMatchers.any(StreamProcessingContext.class)); + } + + /** + * Test init. + */ + @Test + public void testInit() { + + schedulerAgentPostProcessor.init(ctxt); + Mockito.verify(eventScheduler, Mockito.times(1)) + .scheduleEvent(ArgumentMatchers.any(StreamProcessingContext.class)); + } + + /** + * Test process delete schedule event. + */ + @Test + public void testProcess_DeleteScheduleEvent() { + IgniteKey testKey = new TestKey(); + TestEvent event = new TestEvent(); + event.setEventId(EventID.DELETE_SCHEDULE_EVENT); + + String scheduleId = "123"; + DeleteScheduleEventData eventData = new DeleteScheduleEventData(scheduleId); + event.setEventData(eventData); + + schedulerAgentPostProcessor.process(new Record<>(testKey, event, System.currentTimeMillis())); + + Mockito.verify(ctxt, + Mockito.times(1)).forwardDirectly(ArgumentMatchers.any(TestKey.class), + ArgumentMatchers.any(TestEvent.class), ArgumentMatchers.any(String.class)); + + Mockito.verify(ctxt, Mockito.times(1)).forward(ArgumentMatchers., IgniteEvent>>any()); + + } + + /** + * Test process non scheduler events. + */ + @Test + public void testProcess_NonSchedulerEvents() { + IgniteKey testKey = new TestKey(); + TestEvent event = new TestEvent(); + SpeedV1_0 speed = new SpeedV1_0(); + event.setEventData(speed); + schedulerAgentPostProcessor.process(new Record<>(testKey, event, System.currentTimeMillis())); + Mockito.verify(ctxt, + Mockito.times(0)).forwardDirectly(ArgumentMatchers.any(TestKey.class), + ArgumentMatchers.any(TestEvent.class), ArgumentMatchers.any(String.class)); + Mockito.verify(ctxt, Mockito.times(1)).forward(ArgumentMatchers., IgniteEvent>>any()); + } + + /** + * Test process misc methods. + */ + @Test + public void testProcess_MiscMethods() { + schedulerAgentPostProcessor.init(ctxt); + schedulerAgentPostProcessor.initConfig(new Properties()); + schedulerAgentPostProcessor.configChanged(new Properties()); + Assert.assertEquals("SchedulerAgent", schedulerAgentPostProcessor.name()); + schedulerAgentPostProcessor.punctuate(new Date().getTime()); + schedulerAgentPostProcessor.createStateStore(); + schedulerAgentPostProcessor.close(); + } + + /** + * The Class TestKey. + */ + private class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + + return "Vehicle12345"; + } + } + + /** + * inner class TestEvent extends IgniteEventImpl. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("test"); + } + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/TestStreamProcessor.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/TestStreamProcessor.java new file mode 100644 index 0000000..2ac50e2 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/TestStreamProcessor.java @@ -0,0 +1,208 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Properties; + + +/** + * class {@link TestStreamProcessor} implements {@link IgniteEventStreamProcessor}. + */ +@Component +public class TestStreamProcessor implements IgniteEventStreamProcessor { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(TestStreamProcessor.class); + + /** The ctxt. */ + private StreamProcessingContext, IgniteEvent> ctxt; + + /** The source topics. */ + @Value("${source.topic.name}") + private String[] sourceTopics; + + /** The sink topics. */ + @Value("${sink.topic.name}") + private String[] sinkTopics; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.ctxt = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "test-stream-processor"; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("source topics {}", Arrays.toString(sourceTopics)); + } + return sourceTopics; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("sink topics {}", Arrays.toString(sinkTopics)); + } + return sinkTopics; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + + IgniteKey key = kafkaRecord.key(); + IgniteEvent value = kafkaRecord.value(); + + if (null == key) { + LOGGER.error("Key is null, no further processing."); + } + + if (null == value) { + LOGGER.error("Value is null, no further processing."); + } + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Key={},Value={}", key.toString(), value.toString()); + } + + // do processing and then forward the event. + //Give the topic name. In this streaam processsor we have only one topic + this.ctxt.forward(kafkaRecord, sinkTopics[0]); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + } + + /** + * Close. + */ + @Override + public void close() { + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + } + + /** + * Inits the config. + * + * @param props the props + */ + @Override + public void initConfig(Properties props) { + String sourceTopicNames = (String) props.get("source.topic.name"); + sourceTopics = sourceTopicNames.split(","); + LOGGER.info("source topic list {}", sourceTopicNames); + + String sinkTopicNames = (String) props.get("sink.topic.name"); + sinkTopics = sinkTopicNames.split(","); + LOGGER.info("sink topic list {}", sinkTopicNames); + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/TestStreamProcessorIntegrationTesting.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/TestStreamProcessorIntegrationTesting.java new file mode 100644 index 0000000..46fcd31 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/TestStreamProcessorIntegrationTesting.java @@ -0,0 +1,239 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.Serdes; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.json.JSONException; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.skyscreamer.jsonassert.JSONAssert; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import static java.util.concurrent.CompletableFuture.delayedExecutor; +import static java.util.concurrent.CompletableFuture.runAsync; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + + +/** + * class {@link TestStreamProcessorIntegrationTesting} extends {@link KafkaStreamsApplicationTestBase}. + */ + +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@EnableRuleMigrationSupport +@TestPropertySource("/integration-test-application.properties") +public class TestStreamProcessorIntegrationTesting extends KafkaStreamsApplicationTestBase { + + /** The source topic name. */ + private static String sourceTopicName; + + /** The sink topic name. */ + private static String sinkTopicName; + + /** The i. */ + private static int i = 0; + + /** The key. */ + String key = "Device123"; + + /** + * Setup. + * + * @throws Exception the exception + */ + @Override + @Before + public void setup() throws Exception { + super.setup(); + i++; + sourceTopicName = "sourceTopic" + i; + sinkTopicName = "sinkTopic" + i; + sinkTopicName = "sinkTopic" + i; + createTopics(sourceTopicName, sinkTopicName); + + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "tc-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, PropBasedDiscoveryServiceImpl.class.getName()); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, sourceTopicName); + ksProps.put("sink.topic.name", sinkTopicName); + ksProps.put(PropertyNames.APPLICATION_ID, "pt"); + } + + /** + * testTestBasicStreamProcessor(). + * + * @throws Exception Exception + * @throws ExecutionException ExecutionException + * @throws InterruptedException InterruptedException + * @throws TimeoutException TimeoutException + * @throws JSONException JSONException + */ + @org.junit.Test + public void testTestBasicStreamProcessor() + throws Exception, ExecutionException, InterruptedException, TimeoutException, JSONException { + + + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, TestStreamProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + + launchApplication(); + + // Using Message Generator send the data to the Kafka. + String[] args = new String[Constants.FOUR]; + args[0] = sourceTopicName; + args[1] = key; + String speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":20.0}}"; + args[Constants.TWO] = speedEvent; + args[Constants.THREE] = KAFKA_CLUSTER.bootstrapServers(); + args[Constants.FOUR] = "false"; + MessageGenerator.produce(args); + runAsync(() -> {}, delayedExecutor(TestConstants.THREAD_SLEEP_TIME_10000, MILLISECONDS)).join(); + List messages = KafkaTestUtils.getMessages(sinkTopicName, consumerProps, 1, + Constants.THREAD_SLEEP_TIME_10000); + Assert.assertEquals(key, messages.get(0)[0]); + + // [Device123, + // {"EventId":"Speed","Version":"1.0","Timestamp":0,"Data":{"value":20.0}, + // "RequestId":"Device123-id","SourceDeviceId":"Device123"}] + String expectedValue = "{\"EventID\":\"Speed\",\"Version\":\"1.0\",\"Timestamp\":0,\"Data\":" + + "{\"value\":20.0},\"RequestId\":\"Device123-id\",\"SourceDeviceId\":\"Device123\"}"; + JSONAssert.assertEquals(expectedValue, messages.get(0)[1], false); + shutDownApplication(); + + } + + /** + * This stream processor forwards messages to same topic multiple times as well as to multiple topics. + * + * @throws Exception the exception + * @throws ExecutionException ExecutionException + * @throws InterruptedException InterruptedException + * @throws TimeoutException TimeoutException + * @throws JSONException JSONException + */ + @Test + public void testStreamProcessorWithMultipleForwardsToMultipleTopics() + throws Exception, ExecutionException, InterruptedException, TimeoutException, JSONException { + + String newSinkTopic = "sink2"; + ksProps.put("sink.topic.name", sinkTopicName + "," + newSinkTopic); + ksProps.put(PropertyNames.PRE_PROCESSORS, + "org.eclipse.ecsp.analytics.stream.base.processors.TaskContextInitializer," + + "org.eclipse.ecsp.analytics.stream.base.processors.ProtocolTranslatorPreProcessor"); + ksProps.put(PropertyNames.POST_PROCESSORS, + "org.eclipse.ecsp.analytics.stream.base.processors.DeviceMessagingAgentPostProcessor," + + "org.eclipse.ecsp.analytics.stream.base.processors.ProtocolTranslatorPostProcessor"); + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, TestStreamProcessorMultiForwards.class.getName()); + + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + + launchApplication(); + + // Using Message Generator send the data to the Kafka. + String[] args = new String[Constants.FIVE]; + args[0] = sourceTopicName; + args[1] = key; + String speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":20.0}}"; + args[Constants.TWO] = speedEvent; + args[Constants.THREE] = KAFKA_CLUSTER.bootstrapServers(); + args[Constants.FOUR] = "false"; + MessageGenerator.produce(args); + runAsync(() -> {}, delayedExecutor(TestConstants.THREAD_SLEEP_TIME_10000, MILLISECONDS)).join(); + List messages = KafkaTestUtils.getMessages(sinkTopicName, consumerProps, 1, + Constants.THREAD_SLEEP_TIME_10000); + + // we should get 3 msgs because we did 3 times forward + Assert.assertEquals(Constants.THREE, messages.size()); + + // [Device123, + // {"EventId":"Speed","Version":"1.0","Timestamp":0,"Data":{"value":20.0}, + // "RequestId":"Device123-id","SourceDeviceId":"Device123"}] + String expectedValue = "{\"EventID\":\"Speed\",\"Version\":\"1.0\",\"Timestamp\":0,\"Data\":" + + "{\"value\":20.0},\"RequestId\":\"Device123-id\",\"SourceDeviceId\":\"Device123\"}"; + + JSONAssert.assertEquals(expectedValue, messages.get(0)[1], false); + JSONAssert.assertEquals(expectedValue, messages.get(1)[1], false); + JSONAssert.assertEquals(expectedValue, messages.get(Constants.TWO)[1], false); + + messages = KafkaTestUtils.getMessages(newSinkTopic, consumerProps, 1, Constants.THREAD_SLEEP_TIME_10000); + + // we should get 3 msgs because we did 3 times forward + Assert.assertEquals(1, messages.size()); + + // [Device123, + // {"EventId":"Speed","Version":"1.0","Timestamp":0,"Data":{"value":20.0}, + // "RequestId":"Device123-id","SourceDeviceId":"Device123"}] + expectedValue = "{\"EventID\":\"Speed\",\"Version\":\"1.0\",\"Timestamp\":0,\"Data\"" + + ":{\"value\":20.0},\"RequestId\":\"Device123-id\",\"SourceDeviceId\":\"Device123\"}"; + + JSONAssert.assertEquals(expectedValue, messages.get(0)[1], false); + shutDownApplication(); + + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/TestStreamProcessorMultiForwards.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/TestStreamProcessorMultiForwards.java new file mode 100644 index 0000000..05ae19f --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/processors/TestStreamProcessorMultiForwards.java @@ -0,0 +1,221 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.processors; + +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Properties; + + +/** + * class {@link TestStreamProcessorMultiForwards} implements {@link IgniteEventStreamProcessor}. + */ +@Component +public class TestStreamProcessorMultiForwards implements IgniteEventStreamProcessor { + + /** The log. */ + private final Logger log = LoggerFactory.getLogger(TestStreamProcessor.class); + + /** The props. */ + private Properties props; + + /** The ctxt. */ + private StreamProcessingContext, IgniteEvent> ctxt; + + /** The source topics. */ + @Value("${source.topic.name}") + private String[] sourceTopics; + + /** The sink topics. */ + @Value("${sink.topic.name}") + private String[] sinkTopics; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.ctxt = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "test-stream-processor"; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + if (log.isDebugEnabled()) { + log.debug("source topics {}", Arrays.toString(sourceTopics)); + } + return sourceTopics; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + if (log.isDebugEnabled()) { + log.debug("sink topics {}", Arrays.toString(sinkTopics)); + } + return sinkTopics; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + + IgniteKey key = kafkaRecord.key(); + IgniteEvent value = kafkaRecord.value(); + if (null == key) { + log.error("Key is null, no further processing."); + } + + if (null == value) { + log.error("Value is null, no further processing."); + } + + if (log.isDebugEnabled()) { + log.debug("Key={},Value={}", key.toString(), value.toString()); + } + + // do processing and then forward the event. + //Give the topic name. In this streaam processsor we have only one topic + log.info("Sending key value pair first time"); + this.ctxt.forward(kafkaRecord, sinkTopics[0]); + + log.info("Sending key value pair second time"); + this.ctxt.forward(kafkaRecord, sinkTopics[0]); + + log.info("Sending key value pair third time"); + this.ctxt.forward(kafkaRecord, sinkTopics[0]); + + log.info("Sending key value pair to a different topic "); + this.ctxt.forward(kafkaRecord, sinkTopics[1]); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + } + + /** + * Close. + */ + @Override + public void close() { + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + } + + /** + * Inits the config. + * + * @param props the props + */ + @Override + public void initConfig(Properties props) { + this.props = props; + String sourceTopicNames = (String) props.get("source.topic.name"); + sourceTopics = sourceTopicNames.split(","); + log.info("source topic list {}", sourceTopicNames); + + String sinkTopicNames = (String) props.get("sink.topic.name"); + sinkTopics = sinkTopicNames.split(","); + log.info("sink topic list {}", sinkTopicNames); + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheBypassIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheBypassIntegrationTest.java new file mode 100644 index 0000000..a2f463a --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheBypassIntegrationTest.java @@ -0,0 +1,467 @@ +package org.eclipse.ecsp.analytics.stream.base.stores; + +import dev.morphia.AdvancedDatastore; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.MutationId; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.cache.GetEntityRequest; +import org.eclipse.ecsp.cache.GetMapOfEntitiesRequest; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.stream.dma.dao.DMCacheEntityDAOMongoImpl; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.util.ReflectionTestUtils; +import redis.embedded.RedisServer408; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.PriorityBlockingQueue; + + +/** + * Integration test case for {@link CacheBypass} use case of stream-base library. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@TestPropertySource("/cache-bypass-test.properties") +public class CacheBypassIntegrationTest extends KafkaStreamsApplicationTestBase { + + /** The mutation id. */ + private Optional mutationId = Optional.empty(); + + /** The id. */ + private String id = "test_id"; + + /** The queue. */ + private BlockingQueue queue; + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(CacheBypassIntegrationTest.class); + + /** The mongo server. */ + @ClassRule + public static EmbeddedMongoDB mongoServer = new EmbeddedMongoDB(); + + /** The redis server. */ + @ClassRule + public static EmbeddedRedisServer redisServer = new EmbeddedRedisServer(); + + /** The bypass. */ + @Autowired + private CacheBypass bypass; + + /** The dm cache entity DAO. */ + @Autowired + private DMCacheEntityDAOMongoImpl dmCacheEntityDAO; + + /** The ds. */ + @Autowired + private AdvancedDatastore ds; + + /** The cache. */ + @Autowired + private IgniteCache cache; + + /** + * Setup. + * + * @throws Exception the exception + * @throws MqttException the mqtt exception + */ + @Before + public void setup() throws Exception, MqttException { + super.setup(); + queue = new LinkedBlockingDeque(); + } + + /** + * Test populate queue. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testPopulateQueue() throws InterruptedException { + bypass.setDmCacheEntityDao(dmCacheEntityDAO); + + for (int i = 0; i < TestConstants.FOUR; i++) { + CacheEntity entityi = new CacheEntity<>(); + IgniteEventImpl eventi = new IgniteEventImpl(); + eventi.setEventId("testId"); + entityi.withKey(new StringKey("xyz")).withValue(eventi).withMapKey("abc_" + i).withMutationId(mutationId); + switch (i) { + case 0: + entityi.withOperation(Operation.PUT); + entityi.setLastUpdatedTime(LocalDateTime.parse("2020-03-12T12:30:38.839")); + break; + case 1: + entityi.withOperation(Operation.DEL); + entityi.setLastUpdatedTime(LocalDateTime.parse("2020-03-12T12:30:30.839")); + break; + case TestConstants.TWO: + entityi.withOperation(Operation.DEL_FROM_MAP); + entityi.setLastUpdatedTime(LocalDateTime.parse("2020-03-12T12:40:38.839")); + break; + case TestConstants.THREE: + entityi.withOperation(Operation.PUT_TO_MAP); + entityi.setLastUpdatedTime(LocalDateTime.parse("2020-03-12T10:30:38.839")); + break; + default: + //Nothing to do. + } + dmCacheEntityDAO.save(entityi); + } + bypass.setup(); + Assert.assertTrue(!bypass.getQueue().isEmpty()); + } + + /** + * Test save to mongo. + */ + @Test + public void testSaveToMongo() { + BlockingQueue queue = new PriorityBlockingQueue<>(TestConstants.INT_30, + (CacheEntity e1, CacheEntity e2) -> e1.getLastUpdatedTime().compareTo(e2.getLastUpdatedTime())); + for (int i = 0; i < TestConstants.FOUR; i++) { + CacheEntity entityi = new CacheEntity<>(); + IgniteEventImpl eventi = new IgniteEventImpl(); + eventi.setEventId("testId"); + entityi.withKey(new StringKey("xyz")).withValue(eventi).withMapKey("abc_" + i).withMutationId(mutationId); + entityi.setLastUpdatedTime(LocalDateTime.now()); + setOperationOnEntity(entityi, i); + queue.add(entityi); + } + bypass.setQueue(queue); + bypass.close(); + List list = dmCacheEntityDAO.findAll(); + dmCacheEntityDAO.deleteAll(); + int putOperationCounter = 0; + int putToMapOperationCounter = 0; + int delOperationCounter = 0; + int delFromMapOperationCounter = 0; + + for (CacheEntity entity : list) { + Assert.assertEquals("xyz", entity.getKey().convertToString()); + IgniteEventImpl event = (IgniteEventImpl) entity.getValue(); + Assert.assertEquals("testId", event.getEventId()); + Operation op = entity.getOperation(); + switch (op) { + case PUT: + putOperationCounter++; + break; + case DEL: + delOperationCounter++; + break; + case DEL_FROM_MAP: + delFromMapOperationCounter++; + break; + case PUT_TO_MAP: + putToMapOperationCounter++; + break; + default: + //Nothing to do. + } + } + Assert.assertEquals(TestConstants.FOUR, list.size()); + Assert.assertTrue( + putOperationCounter == 1 + && putToMapOperationCounter == 1 + && delOperationCounter == 1 + && delFromMapOperationCounter == 1); + } + + /** + * Sets the operation on entity. + * + * @param entityi the entityi + * @param i the i + */ + private void setOperationOnEntity(CacheEntity entityi, int i) { + switch (i) { + case 0: + entityi.withOperation(Operation.PUT); + break; + case 1: + entityi.withOperation(Operation.DEL); + break; + case TestConstants.TWO: + entityi.withOperation(Operation.DEL_FROM_MAP); + break; + case TestConstants.THREE: + entityi.withOperation(Operation.PUT_TO_MAP); + break; + default: + //Nothing to do. + } + } + + /** + * Test cache bypass if redis unavailable. + * + * @throws Exception the exception + */ + @Test + public void testCacheBypassIfRedisUnavailable() throws Exception { + RedisServer408 redis = (RedisServer408) ReflectionTestUtils.getField(redisServer, "redis"); + redis.stop(); + //bypass.setup(id); + for (int i = 0; i < TestConstants.TWO; i++) { + CacheEntity entityi = new CacheEntity<>(); + IgniteEventImpl eventi = new IgniteEventImpl(); + eventi.setEventId("testId" + i); + entityi.withKey(new StringKey("xyz" + i)).withValue(eventi).withMapKey("abc_" + i) + .withMutationId(mutationId); + entityi.setLastUpdatedTime(LocalDateTime.now()); + try { + if (i % TestConstants.FOUR == 0) { + entityi.withOperation(Operation.PUT); + bypass.processEvents(entityi); + } + if (i % TestConstants.FOUR == TestConstants.ONE) { + entityi.withOperation(Operation.PUT_TO_MAP); + bypass.processEvents(entityi); + } + } catch (Exception e) { + logger.error("*********Redis error encountered**********"); + } + } + redis.start(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + Assert.assertEquals(TestConstants.TWO, getRecordsFromRedis().size()); + } + + /** + * Gets the records from redis. + * + * @return the records from redis + */ + private List getRecordsFromRedis() { + List entities = new ArrayList<>(); + GetEntityRequest request = new GetEntityRequest(); + request.withKey("xyz0"); + entities.add(cache.getEntity(request)); + GetMapOfEntitiesRequest requestWithMapKey = new GetMapOfEntitiesRequest(); + requestWithMapKey.withKey("abc_1"); + entities.add(cache.getEntity(request)); + return entities; + } + + /** + * Test cache bypass with load if redis unavailable. + * + * @throws Exception the exception + */ + @Test + public void testCacheBypassWithLoadIfRedisUnavailable() throws Exception { + RedisServer408 redis = (RedisServer408) ReflectionTestUtils.getField(redisServer, "redis"); + redis.stop(); + //bypass.setup(id); + startWorkerThreads(); + Thread.sleep(TestConstants.LONG_11000); + redis.start(); + logger.debug("********REDIS IS NOW UP***********"); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + Assert.assertEquals(0, bypass.getQueue().size()); + ExecutorService executorService = (ExecutorService) + ReflectionTestUtils.getField(bypass, "cacheBypassExecutorService"); + Assert.assertTrue(executorService.isShutdown()); + /* + * Simulating the normal flow after Redis is back up. + */ + for (int i = 0; i < TestConstants.TWO; i++) { + CacheEntity entityi = new CacheEntity<>(); + IgniteEventImpl eventi = new IgniteEventImpl(); + eventi.setEventId("testId" + i); + entityi.withKey(new StringKey("xyz" + i)).withValue(eventi).withMapKey("abc_" + i) + .withMutationId(mutationId); + entityi.setLastUpdatedTime(LocalDateTime.now()); + if (i % TestConstants.FOUR == 0) { + entityi.withOperation(Operation.PUT); + bypass.processEvents(entityi); + } + if (i % TestConstants.FOUR == TestConstants.ONE) { + entityi.withOperation(Operation.PUT_TO_MAP); + bypass.processEvents(entityi); + } + } + Assert.assertEquals(TestConstants.TWO, getRecordsFromRedis().size()); + } + + /** + * Start worker threads. + */ + private void startWorkerThreads() { + Thread t1 = getThread1(); + t1.start(); + logger.debug("Thread 1 started"); + Thread t2 = getThread2(); + t2.start(); + logger.debug("Thread 2 started"); + } + + /** + * Gets the thread 1. + * + * @return the thread 1 + */ + private Thread getThread1() { + Thread t1 = new Thread() { + @Override + public void run() { + for (int i = 0; i <= TestConstants.INT_499; i++) { + CacheEntity entityi = new CacheEntity<>(); + IgniteEventImpl eventi = new IgniteEventImpl(); + eventi.setEventId("testId" + i); + entityi.withKey(new StringKey("xyz")).withValue(eventi).withMapKey("abc_" + i) + .withMutationId(mutationId); + entityi.setLastUpdatedTime(LocalDateTime.now()); + try { + if (i % TestConstants.FOUR == 0) { + entityi.withOperation(Operation.PUT); + bypass.processEvents(entityi); + } + if (i % TestConstants.FOUR == TestConstants.ONE) { + entityi.withOperation(Operation.PUT_TO_MAP); + bypass.processEvents(entityi); + } + if (i % TestConstants.FOUR == TestConstants.TWO) { + entityi.withOperation(Operation.DEL); + bypass.processEvents(entityi); + } + if (i % TestConstants.FOUR == TestConstants.THREE) { + entityi.withOperation(Operation.DEL_FROM_MAP); + bypass.processEvents(entityi); + } + } catch (Exception e) { + logger.error("*********Redis error encountered**********"); + } + } + } + }; + return t1; + } + + /** + * Gets the thread 2. + * + * @return the thread 2 + */ + private Thread getThread2() { + Thread t2 = new Thread() { + @Override + public void run() { + for (int i = TestConstants.INT_500; i < TestConstants.INT_1000; i++) { + CacheEntity entityi = new CacheEntity<>(); + IgniteEventImpl eventi = new IgniteEventImpl(); + eventi.setEventId("testId" + i); + entityi.withKey(new StringKey("xyz")).withValue(eventi).withMapKey("abc_" + i) + .withMutationId(mutationId); + entityi.setLastUpdatedTime(LocalDateTime.now()); + try { + if (i % TestConstants.FOUR == 0) { + entityi.withOperation(Operation.PUT); + bypass.processEvents(entityi); + } + if (i % TestConstants.FOUR == TestConstants.ONE) { + entityi.withOperation(Operation.PUT_TO_MAP); + bypass.processEvents(entityi); + } + if (i % TestConstants.FOUR == TestConstants.TWO) { + entityi.withOperation(Operation.DEL); + bypass.processEvents(entityi); + } + if (i % TestConstants.FOUR == TestConstants.THREE) { + entityi.withOperation(Operation.DEL_FROM_MAP); + bypass.processEvents(entityi); + } + } catch (Exception e) { + logger.error("*********Redis error encountered**********"); + } + } + } + }; + return t2; + } + + /** + * Test implementation for IgniteKey for this test class. + */ + public static class StringKey implements CacheKeyConverter { + + /** + * Instantiates a new string key. + */ + public StringKey() { + } + + /** + * Instantiates a new string key. + * + * @param key the key + */ + public StringKey(String key) { + this.key = key; + } + + /** The key. */ + private String key; + + /** + * Gets the key. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Sets the key. + * + * @param key the new key + */ + public void setKey(String key) { + this.key = key; + } + + /** + * Convert from. + * + * @param key the key + * @return the string key + */ + @Override + public StringKey convertFrom(String key) { + return new StringKey(key); + } + + /** + * Convert to string. + * + * @return the string + */ + @Override + public String convertToString() { + return key; + } + + } +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheBypassTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheBypassTest.java new file mode 100644 index 0000000..4bb6ada --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheBypassTest.java @@ -0,0 +1,481 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.result.DeleteResult; +import dev.morphia.AdvancedDatastore; +import dev.morphia.mapping.Mapper; +import dev.morphia.query.Query; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.MutationId; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.cache.DeleteEntryRequest; +import org.eclipse.ecsp.cache.DeleteMapOfEntitiesRequest; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.cache.PutEntityRequest; +import org.eclipse.ecsp.cache.PutMapOfEntitiesRequest; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.stream.dma.dao.DMCacheEntityDAOMongoImpl; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.BlockingDeque; +import java.util.concurrent.LinkedBlockingDeque; + + +/** + * Test class for {@link CacheBypass}. + */ +public class CacheBypassTest { + + /** The thrown. */ + @Rule + public ExpectedException thrown = ExpectedException.none(); + + /** The ignite event. */ + private IgniteEventImpl igniteEvent; + + /** The mutation id. */ + private Optional mutationId = Optional.empty(); + + /** The id. */ + private String id = "test_id"; + + /** The queue. */ + private BlockingDeque> queue; + + /** The entity. */ + private CacheEntity entity; + + /** The bypass. */ + @InjectMocks + private CacheBypass bypass; + + /** The bypass mock. */ + @Mock + private CacheBypass bypassMock; + + /** The cache. */ + @Mock + private IgniteCache cache; + + /** The ds. */ + @Mock + private AdvancedDatastore ds; + + /** The mongo database. */ + @Mock + private MongoDatabase mongoDatabase; + + /** The mongo collection. */ + @Mock + private MongoCollection mongoCollection; + + /** The mapper. */ + @Mock + private Mapper mapper; + + /** The query. */ + @Mock + private Query query; + + /** The write result. */ + @Mock + private DeleteResult writeResult; + + /** The dm cache entity dao. */ + @Mock + private DMCacheEntityDAOMongoImpl dmCacheEntityDao; + + /** The collection. */ + private String collection; + + /** + * setup method is for setting up igniteEvent just after the class initialization. + */ + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + collection = dmCacheEntityDao.getOverridingCollectionName(); + + igniteEvent = new IgniteEventImpl(); + igniteEvent.setEventId("test"); + + queue = new LinkedBlockingDeque>(); + bypass.setCache(cache); + bypass.setDmCacheEntityDao(dmCacheEntityDao); + bypass.setQueueCapacity(Constants.THREAD_SLEEP_TIME_1000); + + entity = new CacheEntity<>(); + IgniteEventImpl event = new IgniteEventImpl(); + event.setEventId("test"); + entity.withKey(new StringKey("xyz")).withValue(event).withMutationId(mutationId).withMapKey("abc_1"); + entity.setLastUpdatedTime(LocalDateTime.now()); + + Mockito.when(ds.find(collection, CacheEntity.class)) + .thenReturn(query); + Mockito.when(ds.getMapper()).thenReturn(mapper); + Mockito.when(ds.getDatabase()) + .thenReturn(mongoDatabase); + Mockito.when(mongoDatabase.getCollection(collection, CacheEntity.class)) + .thenReturn(mongoCollection); + Mockito.when(ds.delete(query)) + .thenReturn(writeResult); + } + + /** + * Test sort. + */ + @Test + public void testSort() { + List entitiesList = new ArrayList<>(); + + for (int i = 0; i < TestConstants.FOUR; i++) { + CacheEntity entityi = new CacheEntity<>(); + IgniteEventImpl eventi = new IgniteEventImpl(); + eventi.setEventId("testId" + i); + entityi.withKey(new StringKey("xyz" + i)).withValue(eventi) + .withMapKey("abc_" + i).withMutationId(mutationId); + switch (i) { + case 0: + entityi.withOperation(Operation.PUT); + entityi.setLastUpdatedTime(LocalDateTime.parse("2020-03-12T12:30:38.839")); + break; + case TestConstants.ONE: + entityi.withOperation(Operation.DEL); + entityi.setLastUpdatedTime(LocalDateTime.parse("2020-03-12T12:30:30.839")); + break; + case TestConstants.TWO: + entityi.withOperation(Operation.DEL_FROM_MAP); + entityi.setLastUpdatedTime(LocalDateTime.parse("2020-03-12T12:40:38.839")); + break; + case TestConstants.THREE: + entityi.withOperation(Operation.PUT_TO_MAP); + entityi.setLastUpdatedTime(LocalDateTime.parse("2020-03-12T10:30:38.839")); + break; + default: + break; + } + entitiesList.add(entityi); + } + entitiesList = bypass.sort(entitiesList); + + for (int i = 0; i < entitiesList.size() - 1; i++) { + Assert.assertTrue(entitiesList.get(i).getLastUpdatedTime() + .isBefore(entitiesList.get(i + 1).getLastUpdatedTime())); + } + } + + /** + * Test populate queue. + */ + @Test + public void testPopulateQueue() { + BlockingDeque> queue = new LinkedBlockingDeque<>(); + for (int i = 0; i < TestConstants.FOUR; i++) { + CacheEntity entityi = new CacheEntity<>(); + IgniteEventImpl eventi = new IgniteEventImpl(); + eventi.setEventId("testId" + i); + entityi.withKey(new StringKey("xyz" + i)).withValue(eventi) + .withMapKey("abc_" + i).withMutationId(mutationId); + entityi.setLastUpdatedTime(LocalDateTime.now()); + switch (i) { + case 0: + entityi.withOperation(Operation.PUT); + break; + case TestConstants.ONE: + entityi.withOperation(Operation.DEL); + break; + case TestConstants.TWO: + entityi.withOperation(Operation.DEL_FROM_MAP); + break; + case TestConstants.THREE: + entityi.withOperation(Operation.PUT_TO_MAP); + break; + default: + break; + } + dmCacheEntityDao.save(entityi); + } + + Mockito.verify(dmCacheEntityDao, Mockito.times(TestConstants.FOUR)).save(Mockito.any(CacheEntity.class)); + + bypass.setQueue(queue); + ReflectionTestUtils.setField(bypass, "numCacheBypassThreads", TestConstants.ONE); + ReflectionTestUtils.setField(bypass, "waitTime", TestConstants.INT_1000); + bypass.setup(); + } + + /** + * Test cache bypass thread for put operation. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testCacheBypassThreadForPutOperation() throws InterruptedException { + entity.withOperation(Operation.PUT); + bypass.setQueueCapacity(Constants.THREAD_SLEEP_TIME_1000); + ReflectionTestUtils.setField(bypass, "numCacheBypassThreads", TestConstants.ONE); + ReflectionTestUtils.setField(bypass, "waitTime", TestConstants.INT_1000); + bypass.setup(); + bypass.processEvents(entity); + ArgumentCaptor putRequestArgument = ArgumentCaptor.forClass(PutEntityRequest.class); + Mockito.verify(cache, Mockito.times(1)).putEntity(putRequestArgument.capture()); + + PutEntityRequest putRequest = putRequestArgument.getValue(); + String actualKey = putRequest.getKey(); + IgniteEventImpl actualValue = (IgniteEventImpl) putRequest.getValue(); + + Assert.assertEquals(entity.getKey().convertToString(), actualKey); + Assert.assertEquals(entity.getValue(), actualValue); + } + + /** + * Test cache bypass thread for put to map operation. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testCacheBypassThreadForPutToMapOperation() throws InterruptedException { + entity.withOperation(Operation.PUT_TO_MAP); + ReflectionTestUtils.setField(bypass, "numCacheBypassThreads", TestConstants.ONE); + ReflectionTestUtils.setField(bypass, "waitTime", TestConstants.INT_1000); + bypass.setup(); + bypass.processEvents(entity); + ArgumentCaptor putRequestArgument + = ArgumentCaptor.forClass(PutMapOfEntitiesRequest.class); + Mockito.verify(cache, Mockito.times(1)).putMapOfEntities(putRequestArgument.capture()); + + PutMapOfEntitiesRequest putRequest = putRequestArgument.getValue(); + String actualMapKey = putRequest.getKey(); + + Assert.assertEquals(entity.getMapKey(), actualMapKey); + } + + /** + * Test cache bypass thread for delete operation. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testCacheBypassThreadForDeleteOperation() throws InterruptedException { + entity.withOperation(Operation.DEL); + ReflectionTestUtils.setField(bypass, "numCacheBypassThreads", TestConstants.ONE); + ReflectionTestUtils.setField(bypass, "waitTime", TestConstants.INT_1000); + bypass.setup(); + bypass.processEvents(entity); + ArgumentCaptor deleteRequestArgument = ArgumentCaptor.forClass(DeleteEntryRequest.class); + Mockito.verify(cache, Mockito.times(1)).delete(deleteRequestArgument.capture()); + + DeleteEntryRequest deleteRequest = deleteRequestArgument.getValue(); + String actualKey = deleteRequest.getKey(); + + Assert.assertEquals(entity.getKey().convertToString(), actualKey); + + } + + /** + * Test cache bypass thread for delete from map operation. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testCacheBypassThreadForDeleteFromMapOperation() throws InterruptedException { + entity.withOperation(Operation.DEL_FROM_MAP); + ReflectionTestUtils.setField(bypass, "numCacheBypassThreads", TestConstants.ONE); + ReflectionTestUtils.setField(bypass, "waitTime", TestConstants.INT_1000); + bypass.setup(); + bypass.processEvents(entity); + ArgumentCaptor deleteRequestArgument + = ArgumentCaptor.forClass(DeleteMapOfEntitiesRequest.class); + Mockito.verify(cache, Mockito.times(1)).deleteMapOfEntities(deleteRequestArgument.capture()); + + DeleteMapOfEntitiesRequest deleteRequest = deleteRequestArgument.getValue(); + String actualMapKey = deleteRequest.getKey(); + + Assert.assertEquals(entity.getMapKey(), actualMapKey); + } + + /** + * Test setup with negative queue capacity. + */ + @Test(expected = IllegalArgumentException.class) + public void testSetupWithNegativeQueueCapacity() { + bypass.setQueueCapacity(-Constants.THREAD_SLEEP_TIME_1000); + bypass.setup(); + String message = "Queue capacity should be greater than 100."; + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage(message); + } + + /** + * Inner Class {@link org.eclipse.ecsp.analytics.stream.base.stores.CacheBypassIntegrationTest.StringKey}. + * Implements {@link CacheKeyConverter}. + */ + public class StringKey implements CacheKeyConverter { + + /** The key. */ + private String key; + + /** + * Instantiates a new string key. + */ + public StringKey() { + } + + /** + * Instantiates a new string key. + * + * @param key the key + */ + public StringKey(String key) { + this.key = key; + } + + /** + * Gets the key. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Sets the key. + * + * @param key the new key + */ + public void setKey(String key) { + this.key = key; + } + + /** + * Convert from. + * + * @param key the key + * @return the string key + */ + @Override + public StringKey convertFrom(String key) { + return new StringKey(key); + } + + /** + * Convert to string. + * + * @return the string + */ + @Override + public String convertToString() { + return key; + } + + /** + * Hash code. + * + * @return the int + */ + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + getOuterType().hashCode(); + result = prime * result + ((key == null) ? 0 : key.hashCode()); + return result; + } + + /** + * Equals. + * + * @param obj the obj + * @return true, if successful + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + StringKey other = (StringKey) obj; + if (!getOuterType().equals(other.getOuterType())) { + return false; + } + if (key == null) { + if (other.key != null) { + return false; + } + } else if (!key.equals(other.key)) { + return false; + } + return true; + } + + /** + * Gets the outer type. + * + * @return the outer type + */ + private CacheBypassTest getOuterType() { + return CacheBypassTest.this; + } + + } +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheEntityTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheEntityTest.java new file mode 100644 index 0000000..c5bebe0 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/CacheEntityTest.java @@ -0,0 +1,294 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.MutationId; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.OffsetMetadata; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.util.Optional; + + +/** + * UT class {@link CacheEntityTest} for {@link CacheEntity}. + */ +public class CacheEntityTest { + + /** The key 1. */ + private String key1 = "xyz"; + + /** The key 2. */ + private String key2 = "pqr"; + + /** The string key 1. */ + private StringKey stringKey1; + + /** The string key 2. */ + private StringKey stringKey2; + + /** The event 1. */ + private IgniteEventImpl event1; + + /** The event 2. */ + private IgniteEventImpl event2; + + /** The map key 1. */ + private String mapKey1 = "abc_1"; + + /** The map key 2. */ + private String mapKey2 = "abc_2"; + + /** The mutation id. */ + private Optional mutationId = Optional.empty(); + + /** The op 1. */ + private Operation op1 = Operation.PUT; + + /** The op 2. */ + private Operation op2 = Operation.DEL_FROM_MAP; + + /** The entity 1. */ + private CacheEntity entity1; + + /** The entity 2. */ + private CacheEntity entity2; + + /** + * setup method to create {@link CacheEntity}. + */ + @Before + public void setup() { + stringKey1 = new StringKey(key1); + stringKey2 = new StringKey(key2); + event1 = new IgniteEventImpl(); + event1.setEventId("test"); + event2 = new IgniteEventImpl(); + event2.setEventId("test2"); + entity1 = new CacheEntity<>(); + entity2 = new CacheEntity<>(); + } + + /** + * Test with key. + */ + @Test + public void testWithKey() { + entity1.withKey(stringKey1); + Assert.assertEquals(key1, entity1.getKey().convertToString()); + } + + /** + * Test with value. + */ + @Test + public void testWithValue() { + entity1.withValue(event1); + Assert.assertEquals(event1.getEventId(), entity1.getValue().getEventId()); + } + + /** + * Test with map key. + */ + @Test + public void testWithMapKey() { + entity1.withMapKey(mapKey1); + Assert.assertEquals(mapKey1, entity1.getMapKey()); + } + + /** + * Test with mutation id. + */ + @Test + public void testWithMutationId() { + OffsetMetadata metadata = new OffsetMetadata(null, TestConstants.THREAD_SLEEP_TIME_5000); + entity1.withMutationId(Optional.of(metadata)); + Assert.assertTrue(entity1.getMutationId().isPresent()); + } + + /** + * Test with operation. + */ + @Test + public void testWithOperation() { + entity1.withOperation(Operation.PUT); + Assert.assertSame(Operation.PUT, entity1.getOperation()); + + entity1.withOperation(Operation.PUT_TO_MAP); + Assert.assertSame(Operation.PUT_TO_MAP, entity1.getOperation()); + + entity1.withOperation(Operation.DEL); + Assert.assertSame(Operation.DEL, entity1.getOperation()); + + entity1.withOperation(Operation.DEL_FROM_MAP); + Assert.assertSame(Operation.DEL_FROM_MAP, entity1.getOperation()); + } + + /** + * Test all fields. + */ + @Test + public void testAllFields() { + entity2.withKey(stringKey2).withValue(event2).withMutationId(mutationId).withOperation(Operation.DEL); + Assert.assertTrue(entity2.getKey() == stringKey2 && entity2.getValue().getEventId() == event2.getEventId() + && entity2.getMutationId().isEmpty() && entity2.getOperation() == Operation.DEL); + } + + /** + * Test get key. + */ + @Test + public void testGetKey() { + entity1.withKey(stringKey1); + entity2.withKey(stringKey2); + Assert.assertEquals("xyz", entity1.getKey().convertToString()); + Assert.assertEquals("pqr", entity2.getKey().convertToString()); + } + + /** + * Test get value. + */ + @Test + public void testGetValue() { + entity1.withValue(event1); + entity2.withValue(event2); + Assert.assertEquals(event1.getEventId(), entity1.getValue().getEventId()); + Assert.assertEquals(event2.getEventId(), entity2.getValue().getEventId()); + } + + /** + * Test get map key. + */ + @Test + public void testGetMapKey() { + entity1.withMapKey(mapKey1); + entity2.withMapKey(mapKey2); + Assert.assertEquals("abc_1", entity1.getMapKey()); + Assert.assertEquals("abc_2", entity2.getMapKey()); + } + + /** + * Test get mutation id. + */ + @Test + public void testGetMutationId() { + entity1.withMutationId(mutationId); + Assert.assertEquals(Optional.empty(), mutationId); + } + + /** + * Test get operation. + */ + @Test + public void testGetOperation() { + entity1.withOperation(op1); + entity2.withOperation(op2); + Assert.assertEquals(Operation.PUT, entity1.getOperation()); + Assert.assertEquals(Operation.DEL_FROM_MAP, entity2.getOperation()); + } + + /** + * StringKey implements {@link CacheKeyConverter}. + */ + public class StringKey implements CacheKeyConverter { + + /** The key. */ + private String key; + + /** + * Instantiates a new string key. + */ + public StringKey() { + } + + /** + * Instantiates a new string key. + * + * @param key the key + */ + public StringKey(String key) { + this.key = key; + } + + /** + * Gets the key. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Sets the key. + * + * @param key the new key + */ + public void setKey(String key) { + this.key = key; + } + + /** + * Convert from. + * + * @param key the key + * @return the string key + */ + @Override + public StringKey convertFrom(String key) { + return new StringKey(key); + } + + /** + * Convert to string. + * + * @return the string + */ + @Override + public String convertToString() { + return key; + } + + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanRocksDBStoreTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanRocksDBStoreTest.java new file mode 100644 index 0000000..c177abf --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/stores/HarmanRocksDBStoreTest.java @@ -0,0 +1,268 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.stores; + +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Headers; +import org.apache.kafka.common.header.internals.RecordHeaders; +import org.apache.kafka.common.metrics.Sensor.RecordingLevel; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.common.utils.Bytes; +import org.apache.kafka.streams.StreamsConfig; +import org.apache.kafka.streams.processor.ProcessorContext; +import org.apache.kafka.streams.processor.StateStore; +import org.apache.kafka.streams.processor.StateStoreContext; +import org.apache.kafka.streams.processor.TaskId; +import org.apache.kafka.streams.state.internals.metrics.RocksDBMetricsRecorder; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.context.StreamBaseSpringContext; +import org.eclipse.ecsp.analytics.stream.base.metrics.reporter.HarmanRocksDBMetricsExporter; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanRocksDBStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.rocksdb.RocksDB; +import org.springframework.context.ApplicationContext; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import static org.apache.kafka.streams.StreamsConfig.METRICS_RECORDING_LEVEL_CONFIG; +import static org.junit.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +/** + * {@link HarmanRocksDBStoreTest} UT class. + */ +public class HarmanRocksDBStoreTest { + + /** The metrics recorder. */ + RocksDBMetricsRecorder metricsRecorder = mock(RocksDBMetricsRecorder.class); + + /** The processor context. */ + @Mock + ProcessorContext processorContext; + + /** The state store context. */ + @Mock + StateStoreContext stateStoreContext; + + /** The state store. */ + @Mock + StateStore stateStore; + + /** The state store config. */ + @Mock + Properties stateStoreConfig; + + /** The context. */ + @Mock + ApplicationContext context; + + /** The harman rocks DB metrics exporter. */ + @Mock + HarmanRocksDBMetricsExporter harmanRocksDBMetricsExporter; + + /** The harman rocks DB store. */ + @InjectMocks + private HarmanRocksDBStore harmanRocksDBStore = new HarmanRocksDBStore("rocksdb-test", "rocksdb", + Serdes.Bytes(), + Serdes.ByteArray(), new Properties(), metricsRecorder); + + /** + * Setup. + */ + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + RocksDB.loadLibrary(); + } + + /** + * Test rocks DB with metrics disabled. + */ + @Test + public void testRocksDBWithMetricsDisabled() { + Properties properties = new Properties(); + properties.setProperty(PropertyNames.ROCKSDB_METRICS_ENABLED, "false"); + properties.put(StreamsConfig.STATE_DIR_CONFIG, "/tmp/kafka-streams"); + harmanRocksDBStore.setProperties(properties); + when(stateStoreConfig.isEmpty()).thenReturn(false); + when(stateStoreContext.stateDir()).thenReturn(new File("/tmp/kafka-streams")); + harmanRocksDBStore.init(stateStoreContext, stateStore); + harmanRocksDBStore.close(); + verify(metricsRecorder, Mockito.times(0)).addValueProviders(any(), any(), any(), any()); + verify(metricsRecorder, Mockito.times(0)).removeValueProviders(any()); + verify(metricsRecorder, Mockito.times(0)).init(any(), any()); + } + + /** + * Test rocks DB with unsupported exception. + */ + @Test(expected = UnsupportedOperationException.class) + public void testRocksDBWithUnsupportedException() { + Properties properties = new Properties(); + properties.setProperty(PropertyNames.ROCKSDB_METRICS_ENABLED, "false"); + properties.put(StreamsConfig.STATE_DIR_CONFIG, "/tmp/kafka-streams"); + harmanRocksDBStore.setProperties(properties); + when(stateStoreConfig.isEmpty()).thenReturn(false); + when(stateStoreContext.stateDir()).thenReturn(new File("/tmp/kafka-streams")); + harmanRocksDBStore.init(processorContext, stateStore); + harmanRocksDBStore.close(); + verify(metricsRecorder, Mockito.times(0)).addValueProviders(any(), any(), any(), any()); + verify(metricsRecorder, Mockito.times(0)).removeValueProviders(any()); + verify(metricsRecorder, Mockito.times(0)).init(any(), any()); + } + + /** + * Test rocks DB with metrics enabled. + */ + @Test + public void testRocksDBWithMetricsEnabled() { + + Properties properties = new Properties(); + properties.setProperty(PropertyNames.ROCKSDB_METRICS_ENABLED, "true"); + harmanRocksDBStore.setProperties(properties); + Mockito.when(stateStoreConfig.isEmpty()).thenReturn(false); + doNothing().when(metricsRecorder).init(any(), any()); + Mockito.when(stateStoreContext.taskId()) + .thenReturn(new TaskId(Constants.TWO, 1)); + Map config = new HashMap<>(); + config.put(METRICS_RECORDING_LEVEL_CONFIG, RecordingLevel.DEBUG.toString()); + config.put(StreamsConfig.STATE_DIR_CONFIG, "/tmp/kafka-streams"); + Mockito.when(stateStoreContext.appConfigs()).thenReturn(config); + StreamBaseSpringContext streamBaseSpringContext = new StreamBaseSpringContext(); + streamBaseSpringContext.setApplicationContext(context); + when(context.getBean(HarmanRocksDBMetricsExporter.class)).thenReturn(harmanRocksDBMetricsExporter); + when(stateStoreContext.stateDir()).thenReturn(new File("/tmp/kafka-streams")); + harmanRocksDBStore.init(stateStoreContext, stateStore); + harmanRocksDBStore.close(); + verify(metricsRecorder, Mockito.times(1)).init(any(), any()); + verify(metricsRecorder, Mockito.times(1)).addValueProviders(any(), any(), any(), any()); + verify(metricsRecorder, Mockito.times(1)).removeValueProviders(any()); + } + + /** + * Test rocks DB restore batch. + */ + @Test + public void testRocksDBRestoreBatch() { + + String key = "key1"; + byte[] keyArr = key.getBytes(StandardCharsets.UTF_8); + String value = "value1"; + byte[] valueArr = key.getBytes(StandardCharsets.UTF_8); + Headers header = new RecordHeaders(); + + ConsumerRecord record1 = new + ConsumerRecord("testTopic", 0, 0, keyArr, valueArr); + List> consumerRecords = new ArrayList<>(); + consumerRecords.add(record1); + + Properties properties = new Properties(); + properties.setProperty(PropertyNames.ROCKSDB_METRICS_ENABLED, "true"); + properties.put(StreamsConfig.STATE_DIR_CONFIG, "/tmp/kafka-streams"); + harmanRocksDBStore.setProperties(properties); + Mockito.when(stateStoreConfig.isEmpty()).thenReturn(false); + doNothing().when(metricsRecorder).init(any(), any()); + Mockito.when(stateStoreContext.taskId()).thenReturn(new TaskId(Constants.TWO, 1)); + Map config = new HashMap<>(); + config.put(METRICS_RECORDING_LEVEL_CONFIG, RecordingLevel.DEBUG.toString()); + Mockito.when(stateStoreContext.appConfigs()).thenReturn(config); + when(stateStoreContext.stateDir()).thenReturn(new File("/tmp/kafka-streams")); + StreamBaseSpringContext streamBaseSpringContext = new StreamBaseSpringContext(); + streamBaseSpringContext.setApplicationContext(context); + when(context.getBean(HarmanRocksDBMetricsExporter.class)).thenReturn(harmanRocksDBMetricsExporter); + + harmanRocksDBStore.init(stateStoreContext, stateStore); + harmanRocksDBStore.restoreBatch(consumerRecords); + + assertNotNull("Position is null", harmanRocksDBStore.getPosition()); + + harmanRocksDBStore.close(); + } + + /** + * Test rocks DB with metrics enabled with path traversal attack attempt. + */ + @Test + public void testRocksDBWithMetricsEnabledWithPathTraversalAttackAttempt() { + + harmanRocksDBStore = new HarmanRocksDBStore("..\\..\\rocksdb-test", "rocksdb", + Serdes.Bytes(), + Serdes.ByteArray(), new Properties(), metricsRecorder); + + Properties properties = new Properties(); + properties.setProperty(PropertyNames.ROCKSDB_METRICS_ENABLED, "true"); + harmanRocksDBStore.setProperties(properties); + Mockito.when(stateStoreConfig.isEmpty()) + .thenReturn(false); + doNothing().when(metricsRecorder).init(any(), any()); + Mockito.when(stateStoreContext.taskId()) + .thenReturn(new TaskId(Constants.TWO, 1)); + Map config = new HashMap<>(); + config.put(METRICS_RECORDING_LEVEL_CONFIG, RecordingLevel.DEBUG.toString()); + config.put(StreamsConfig.STATE_DIR_CONFIG, "/tmp/kafka-streams"); + Mockito.when(stateStoreContext.appConfigs()).thenReturn(config); + StreamBaseSpringContext streamBaseSpringContext = new StreamBaseSpringContext(); + streamBaseSpringContext.setApplicationContext(context); + when(context.getBean(HarmanRocksDBMetricsExporter.class)).thenReturn(harmanRocksDBMetricsExporter); + when(stateStoreContext.stateDir()).thenReturn(new File("/tmp/kafka-streams")); + harmanRocksDBStore.init(stateStoreContext, stateStore); + harmanRocksDBStore.close(); + verify(metricsRecorder, Mockito.times(1)).init(any(), any()); + verify(metricsRecorder, Mockito.times(1)).addValueProviders(any(), any(), any(), any()); + verify(metricsRecorder, Mockito.times(1)).removeValueProviders(any()); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/CompressionJackTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/CompressionJackTest.java new file mode 100644 index 0000000..e287b1d --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/CompressionJackTest.java @@ -0,0 +1,175 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.utils.CompressionJack; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.junit.Assert; +import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.zip.Deflater; +import java.util.zip.GZIPOutputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + + + +/** + * CompressionJackTest UT class for {@link CompressionJack}. + */ + +public class CompressionJackTest { + + /** The compression jack. */ + private final CompressionJack compressionJack = new CompressionJack(); + + /** + * Before. + */ + @BeforeEach + public void before() { + CompressionJack.setThresholdSize(TestConstants.ONE_BILLION); + } + + /** + * Test decompression G zip. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + @Test + public void testDecompressionGZip() throws IOException { + String testString = "test-Subject"; + ByteArrayOutputStream obj = new ByteArrayOutputStream(); + GZIPOutputStream gzip = new GZIPOutputStream(obj); + gzip.write(testString.getBytes(StandardCharsets.UTF_8)); + gzip.flush(); + gzip.close(); + obj.close(); + byte[] outputByteArray = compressionJack.decompress(obj.toByteArray()); + Assert.assertNotNull(outputByteArray); + Assert.assertEquals(testString, new String(outputByteArray, StandardCharsets.UTF_8)); + } + + /** + * Test decompression B zip 2. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + @Test(expected = UnsupportedOperationException.class) + public void testDecompressionBZip2() throws IOException { + String testString = "test-subject"; + ByteArrayOutputStream obj = new ByteArrayOutputStream(); + BZip2CompressorOutputStream bzip = new BZip2CompressorOutputStream(obj); + bzip.write(testString.getBytes(StandardCharsets.UTF_8)); + bzip.close(); + obj.close(); + compressionJack.decompress(obj.toByteArray()); + } + + /** + * Test decompression default. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + @Test + public void testDecompressionDefault() throws IOException { + String testString = "test-subject"; + byte[] outputByteArray = compressionJack.decompress(testString.getBytes(StandardCharsets.UTF_8)); + Assert.assertNotNull(outputByteArray); + Assert.assertEquals(testString, new String(outputByteArray, StandardCharsets.UTF_8)); + } + + /** + * Test decompression zlib. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + @Test + public void testDecompressionZlib() throws IOException { + String testString = "test-subject"; + Deflater deflater = new Deflater(); + deflater.setInput(testString.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream obj = new ByteArrayOutputStream(testString.length()); + deflater.finish(); + byte[] buffer = new byte[Constants.BYTE_1024]; + while (!deflater.finished()) { + int count = deflater.deflate(buffer); + obj.write(buffer, 0, count); + } + obj.close(); + byte[] outputByteArray = compressionJack.decompress(obj.toByteArray()); + Assert.assertNotNull(outputByteArray); + Assert.assertEquals(testString, new String(outputByteArray, StandardCharsets.UTF_8)); + } + + /** + * Test decompression zip. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + @Test + public void testDecompressionZip() throws IOException { + String testString = "test-subject"; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zipOut = new ZipOutputStream(baos); + ByteArrayInputStream bais = new ByteArrayInputStream(testString.getBytes()); + ZipEntry zipEntry = new ZipEntry("TestResults.xml"); + zipOut.putNextEntry(zipEntry); + byte[] bytes = new byte[Constants.BYTE_1024]; + int length; + while ((length = bais.read(bytes)) >= 0) { + zipOut.write(bytes, 0, length); + } + zipOut.close(); + bais.close(); + baos.close(); + byte[] outputByteArray = compressionJack.decompress(baos.toByteArray()); + Assert.assertNotNull(outputByteArray); + Assert.assertEquals(testString, new String(outputByteArray, StandardCharsets.UTF_8)); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/CompressionJackWithThresholdTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/CompressionJackWithThresholdTest.java new file mode 100644 index 0000000..3d24ff4 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/CompressionJackWithThresholdTest.java @@ -0,0 +1,87 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.exception.InputStreamMaxSizeExceededException; +import org.eclipse.ecsp.analytics.stream.base.utils.CompressionJack; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + + +/** + * Test class for {@link CompressionJack}. + */ +public class CompressionJackWithThresholdTest { + + /** The compression jack. */ + private final CompressionJack compressionJack = new CompressionJack(); + + /** + * Test decompression zip with threshold reached. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + @Test(expected = InputStreamMaxSizeExceededException.class) + public void testDecompressionZipWithThresholdReached() throws IOException { + CompressionJack.setThresholdSize(TestConstants.TWENTY_FIVE); + String testString = "test-subject-string-looooooooooooooooooooooooooooooooooooong"; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zipOut = new ZipOutputStream(baos); + ByteArrayInputStream bais = new ByteArrayInputStream(testString.getBytes()); + ZipEntry zipEntry = new ZipEntry("TestResults.xml"); + zipOut.putNextEntry(zipEntry); + byte[] bytes = new byte[TestConstants.INT_1024]; + int length; + while ((length = bais.read(bytes)) >= 0) { + zipOut.write(bytes, 0, length); + } + zipOut.close(); + bais.close(); + baos.close(); + CompressionJack.setThresholdSize(TestConstants.TWENTY_FIVE); + compressionJack.decompress(baos.toByteArray()); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQHandlerTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQHandlerTest.java new file mode 100644 index 0000000..261d188 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQHandlerTest.java @@ -0,0 +1,319 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamBaseConstant; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.domain.AbstractBlobEventData.Encoding; +import org.eclipse.ecsp.domain.BlobDataV1_0; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.IgniteEventSource; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteBlobEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.serializer.IngestionSerializerFstImpl; +import org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.List; +import java.util.Properties; + +import static org.mockito.MockitoAnnotations.initMocks; + + +/** + * IT test class for {@link DLQHandler}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/stream-base-test.properties") +public class DLQHandlerTest extends KafkaStreamsApplicationTestBase { + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(DLQHandlerTest.class); + + /** The in topic name. */ + private static String inTopicName = "service-test"; + + /** The device ID. */ + private static String deviceID = "DeviceId-1"; + + /** The request id. */ + private static String requestId = "req-1"; + + /** The vehicle id. */ + private static String vehicleId = "vehicle-1"; + + /** The service name. */ + private static String serviceName = "Ecall"; + + /** The dql topic name. */ + private static String dqlTopicName = serviceName + StreamBaseConstant.DLQ_TOPIC_POSFIX; + + /** The value ser. */ + private static IngestionSerializerFstImpl valueSer = new IngestionSerializerFstImpl(); + + /** The key ser. */ + private static IgniteKeyTransformerStringImpl keySer = new IgniteKeyTransformerStringImpl(); + + /** The mapper. */ + private ObjectMapper mapper = new ObjectMapper(); + + /** + * Setup. + * + * @throws Exception the exception + */ + @Override + @Before + public void setup() throws Exception { + super.setup(); + initMocks(this); + createTopics(inTopicName); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "tc-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, + PropBasedDiscoveryServiceImpl.class.getName()); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, inTopicName); + ksProps.put(PropertyNames.APPLICATION_ID, "dlq"); + + ksProps.put("event.transformer.classes", "genericIgniteEventTransformer"); + ksProps.put("ignite.key.transformer.class", "org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + ksProps.put("ingestion.serializer.class", "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + + } + + /** + * Test ignite exception. + * + * @throws Exception the exception + */ + @Test + public void testIgniteException() throws Exception { + ksProps.put(PropertyNames.PRE_PROCESSORS, + "org.eclipse.ecsp.analytics.stream.base.processors.TaskContextInitializer," + + "org.eclipse.ecsp.analytics.stream.base.processors.ProtocolTranslatorPreProcessor"); + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, DLQServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + launchApplication(); + IgniteStringKey igniteStringKey = new IgniteStringKey(); + igniteStringKey.setKey("dlqkey1"); + + IgniteBlobEvent event = getDummyIgniteBlobEvent(); + KafkaTestUtils.sendMessages(inTopicName, producerProps, keySer.toBlob(igniteStringKey), + valueSer.serialize(event)); + // dlq-service-test-dlq + List messages = KafkaTestUtils.getMessages(dqlTopicName, consumerProps, 1, + Constants.THREAD_SLEEP_TIME_10000); + try { + + IgniteStringKey key = mapper.readValue(messages.get(0)[0], IgniteStringKey.class); + LOGGER.info("DLQ message {}", messages); + + Assert.assertEquals("dlqkey1", key.getKey()); + } catch (Exception e) { + e.printStackTrace(); + } + + } + + /** + * Gets the dummy ignite blob event. + * + * @return the dummy ignite blob event + */ + private IgniteBlobEvent getDummyIgniteBlobEvent() { + + IgniteBlobEvent igniteBlobEvent = new IgniteBlobEvent(); + igniteBlobEvent.setSourceDeviceId(deviceID); + igniteBlobEvent.setEventId(EventID.BLOBDATA); + igniteBlobEvent.setRequestId(requestId); + igniteBlobEvent.setSchemaVersion(Version.V1_0); + igniteBlobEvent.setTimestamp(System.currentTimeMillis()); + igniteBlobEvent.setVehicleId(vehicleId); + igniteBlobEvent.setVersion(Version.V1_0); + BlobDataV1_0 eventData = new BlobDataV1_0(); + eventData.setEncoding(Encoding.JSON); + eventData.setEventSource(IgniteEventSource.IGNITE); + String speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":20.0}}"; + eventData.setPayload(speedEvent.getBytes()); + igniteBlobEvent.setEventData(eventData); + return igniteBlobEvent; + } + + /** + * innner class DLQServiceProcessor implements {@link StreamProcessor}. + */ + public static final class DLQServiceProcessor implements + StreamProcessor, IgniteEvent, IgniteKey, IgniteEvent> { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return serviceName; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + + IgniteKey key = kafkaRecord.key(); + IgniteEvent value = kafkaRecord.value(); + LOGGER.info("Test DLQ ---> Key {} Value {} event id {}", key, value, value.getEventId()); + throw new RuntimeException("DLQ testing"); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + } + + /** + * Close. + */ + @Override + public void close() { + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { inTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] { "test-dlq" }; + } + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQReprocessingTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQReprocessingTest.java new file mode 100644 index 0000000..f20a910 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQReprocessingTest.java @@ -0,0 +1,233 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.IgniteBaseException; +import org.eclipse.ecsp.domain.IgniteExceptionDataV1_1; +import org.eclipse.ecsp.domain.SpeedV1_0; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.transform.GenericIgniteEventTransformer; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.TestPropertySource; + +import java.util.HashMap; +import java.util.Map; + + +/** + * This class tests the DLQ re-processing logic. + * + * @param the key type + * @param the value type + */ +@TestPropertySource("/dlq-reprocessing-test.properties") +public class DLQReprocessingTest { + + /** The mockito rule. */ + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + /** The dlq handler. */ + @InjectMocks + private DLQHandler dlqHandler; + + /** The value. */ + private IgniteEventImpl value; + + /** The ignite event impl. */ + private IgniteEventImpl igniteEventImpl; + + /** The ignite exception data. */ + private IgniteExceptionDataV1_1 igniteExceptionData; + + /** The retryable ignite base exception. */ + private IgniteBaseException retryableIgniteBaseException; + + /** The non retryable ignite base exception. */ + private IgniteBaseException nonRetryableIgniteBaseException; + + /** The max retyr count. */ + @Value("${" + PropertyNames.DLQ_MAX_RETRY_COUNT + ":5}") + private int maxRetyrCount = 5; + + /** The service context. */ + private Map serviceContext; + + /** The transformer. */ + private GenericIgniteEventTransformer transformer; + + /** The key. */ + private K key; + + /** The exception message. */ + private String exceptionMessage; + + /** The deatiled exception message. */ + private String deatiledExceptionMessage; + + /** The internal exception. */ + private Exception internalException; + + /** The speed. */ + private SpeedV1_0 speed; + + /** The time zone. */ + private short timeZone; + + /** + * setUp(). + * + * @throws Exception Exception + */ + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + timeZone = Constants.TEN; + exceptionMessage = "Stream closed (through reference chain ...)"; + deatiledExceptionMessage = "Json mapping exception error"; + serviceContext = new HashMap(); + speed = new SpeedV1_0(); + value = new IgniteEventImpl(); + igniteEventImpl = new IgniteEventImpl(); + igniteExceptionData = new IgniteExceptionDataV1_1(); + transformer = new GenericIgniteEventTransformer(); + key = (K) new String("key"); + internalException = new RuntimeException(deatiledExceptionMessage); + retryableIgniteBaseException = new IgniteBaseException(exceptionMessage, true, internalException, + null, serviceContext); + nonRetryableIgniteBaseException = new IgniteBaseException(exceptionMessage, false, internalException); + speed.setValue(Constants.TEN); + serviceContext.put("someProperty", "somePropertyContextInfo"); + value.setEventId(EventID.SPEED); + value.setVersion(Version.V1_0); + igniteEventImpl.setEventId(EventID.SPEED); + igniteEventImpl.setVersion(Version.V1_0); + igniteEventImpl.setRequestId("Request-1"); + igniteEventImpl.setBizTransactionId("bizTransactionId-1"); + igniteEventImpl.setMessageId("messageId-1"); + igniteEventImpl.setVehicleId("vehicleId-1"); + igniteEventImpl.setTimestamp(System.currentTimeMillis()); + igniteEventImpl.setTimezone(timeZone); + igniteEventImpl.setEventData(speed); + igniteExceptionData.setIgniteEvent(igniteEventImpl); + dlqHandler.setReprocessingEnabled(true); + dlqHandler.setMaxRetryCount(maxRetyrCount); + } + + /** + * Tests success for DLQReprocessing criteria. + */ + @Test + public void checkIfDLQReprocessingRequiredTestForSuccess() { + Assert.assertTrue(dlqHandler.checkIfDLQReprocessingRequired(key, + igniteEventImpl, retryableIgniteBaseException)); + } + + /** + * Tests DLQReprocessing criteria where event is retried once. + */ + @Test + public void checkIfDLQReprocessingRequiredTestForRetryCountOne() { + igniteExceptionData.setRetryCount(1); + value.setEventData(igniteExceptionData); + dlqHandler.setMaxRetryCount(maxRetyrCount); + Assert.assertTrue(dlqHandler.checkIfDLQReprocessingRequired(key, value, retryableIgniteBaseException)); + } + + /** + * Tests for the failed scenario where exception is retried once and doesn't + * require further re-processing (based on some business + * logic) or non-retryable exception occurred. + */ + @Test + public void checkIfDLQReprocessingRequiredTestForNonRetryableException() { + igniteExceptionData.setRetryCount(1); + value.setEventData(igniteExceptionData); + Assert.assertFalse(dlqHandler.checkIfDLQReprocessingRequired(key, value, nonRetryableIgniteBaseException)); + } + + /** + * This method tests for DLQ re-processing for null key. + */ + @Test + public void testDLQReprocessingForNullKey() { + igniteExceptionData.setRetryCount(Constants.TWO); + value.setEventData(igniteExceptionData); + Assert.assertFalse(dlqHandler.checkIfDLQReprocessingRequired(null, value, retryableIgniteBaseException)); + } + + /** + * This method tests for DLQ reprocessing for null value. + */ + @Test + public void testDLQReprocessingForNullValue() { + Assert.assertFalse(dlqHandler.checkIfDLQReprocessingRequired(key, null, retryableIgniteBaseException)); + } + + /** + * Tests the DLQ re-processing where the max retry attempt is exceeded. + */ + @Test + public void testDLQReprocessingTestForRetryCountMax() { + igniteExceptionData.setRetryCount(maxRetyrCount); + value.setEventData(igniteExceptionData); + Assert.assertFalse(dlqHandler.checkIfDLQReprocessingRequired(key, value, retryableIgniteBaseException)); + } + + /** + * This method tests the backward compatibility for the services which are not using this feature. + */ + @Test + public void testNonDLQReprocessingExceptionCase() { + Assert.assertFalse(dlqHandler.checkIfDLQReprocessingRequired(key, value, internalException)); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQRetryHandlerFailureTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQRetryHandlerFailureTest.java new file mode 100644 index 0000000..418f3e2 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQRetryHandlerFailureTest.java @@ -0,0 +1,669 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamBaseConstant; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.domain.AbstractBlobEventData.Encoding; +import org.eclipse.ecsp.domain.BlobDataV1_0; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.IgniteBaseException; +import org.eclipse.ecsp.domain.IgniteEventSource; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteBlobEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.serializer.IngestionSerializerFstImpl; +import org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.Properties; + + +/** + * Integration test case for DLQ retry handler failure scenario. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/dlq-reprocessing-test.properties") +public class DLQRetryHandlerFailureTest extends KafkaStreamsApplicationTestBase { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(DLQRetryHandlerFailureTest.class); + + /** The in topic name. */ + private static String inTopicName = "service-test"; + + /** The device ID. */ + private static String deviceID = "DeviceId-1"; + + /** The request id. */ + private static String requestId = "req-1"; + + /** The vehicle id. */ + private static String vehicleId = "vehicle-1"; + + /** The service name. */ + private static String serviceName = "Ecall"; + + /** The dql topic name. */ + private static String dqlTopicName = "ecall" + StreamBaseConstant.DLQ_TOPIC_POSFIX; + + /** The value ser. */ + private static IngestionSerializerFstImpl valueSer = new IngestionSerializerFstImpl(); + + /** The key ser. */ + private static IgniteKeyTransformerStringImpl keySer = new IgniteKeyTransformerStringImpl(); + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The mapper. */ + private ObjectMapper mapper = new ObjectMapper(); + + /** The toggle DLQ. */ + private static boolean toggleDLQ = true; + + /** + * Setup. + * + * @throws Exception the exception + */ + @Override + @Before + public void setup() throws Exception { + super.setup(); + createTopics(inTopicName); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "tc-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, PropBasedDiscoveryServiceImpl.class.getName()); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, inTopicName); + ksProps.put(PropertyNames.APPLICATION_ID, "dlq"); + + ksProps.put("event.transformer.classes", "genericIgniteEventTransformer"); + ksProps.put("ignite.key.transformer.class", "org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + ksProps.put("ingestion.serializer.class", "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + + } + + /** + * Tests the DLQ re-processing flow logic for faliure with dlq reprocessing + * performed max retry attempt. + * + * @throws Exception exception + */ + + @Test + public void testDLQReprocessingForFailure() throws Exception { + + // cleanup + DLQReprocessingPreProcessorOne.count = 0; + DLQReprocessingPreProcessorTwo.count = 0; + DLQReprocessingPostProcessorOne.count = 0; + DLQReprocessingPostProcessorTwo.count = 0; + ksProps.put(PropertyNames.PRE_PROCESSORS, + "org.eclipse.ecsp.analytics.stream.base.processors.ProtocolTranslatorPreProcessor," + + DLQReprocessingPreProcessorOne.class.getName() + "," + + DLQReprocessingPreProcessorTwo.class.getName()); + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, DLQReprocessingMaxRetryServiceProcessor.class.getName()); + ksProps.put(PropertyNames.POST_PROCESSORS, + DLQReprocessingPostProcessorOne.class.getName() + "," + + DLQReprocessingPostProcessorTwo.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + launchApplication(); + IgniteStringKey igniteStringKey = new IgniteStringKey(); + igniteStringKey.setKey("key1"); + IgniteBlobEvent event = getDummyIgniteBlobEvent(); + KafkaTestUtils.sendMessages(inTopicName, producerProps, keySer.toBlob(igniteStringKey), + valueSer.serialize(event)); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_60000); + KafkaTestUtils.getMessages("test-dlq", consumerProps, 1, TestConstants.TEN_THOUSAND); + try { + Assert.assertEquals(1, DLQReprocessingPreProcessorOne.count); + Assert.assertEquals(1, DLQReprocessingPreProcessorTwo.count); + Assert.assertEquals(0, DLQReprocessingPostProcessorOne.count); + Assert.assertEquals(0, DLQReprocessingPostProcessorTwo.count); + } catch (Exception e) { + e.printStackTrace(); + } + shutDown(); + + } + + /** + * Gets the dummy ignite blob event. + * + * @return the dummy ignite blob event + */ + private IgniteBlobEvent getDummyIgniteBlobEvent() { + + IgniteBlobEvent igniteBlobEvent = new IgniteBlobEvent(); + igniteBlobEvent.setSourceDeviceId(deviceID); + igniteBlobEvent.setEventId(EventID.BLOBDATA); + igniteBlobEvent.setRequestId(requestId); + igniteBlobEvent.setSchemaVersion(Version.V1_0); + igniteBlobEvent.setTimestamp(System.currentTimeMillis()); + igniteBlobEvent.setVehicleId(vehicleId); + igniteBlobEvent.setVersion(Version.V1_0); + BlobDataV1_0 eventData = new BlobDataV1_0(); + eventData.setEncoding(Encoding.JSON); + eventData.setEventSource(IgniteEventSource.IGNITE); + String speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":20.0}}"; + eventData.setPayload(speedEvent.getBytes()); + igniteBlobEvent.setEventData(eventData); + return igniteBlobEvent; + } + /** + * Test implementation. + */ + + public static final class DLQReprocessingMaxRetryServiceProcessor + implements StreamProcessor, IgniteEvent, IgniteKey, IgniteEvent> { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "dlq-reprocessing-max-retry-test-processor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + IgniteEvent value = kafkaRecord.value(); + LOGGER.info("Test DLQ Reprocessing ---> Key {} Value {} event id {}", kafkaRecord.key(), + value, value.getEventId()); + throw new IgniteBaseException("Exception occured in DLQReprocessingServiceProcessor", true, + new RuntimeException("Connection refused.")); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + } + + /** + * Close. + */ + @Override + public void close() { + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { inTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] { "test-dlq" }; + } + } + + /** + * Test implementation. + */ + public static final class DLQReprocessingPreProcessorOne + implements StreamProcessor, IgniteEvent, IgniteKey, IgniteEvent> { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(DLQReprocessingPreProcessorTwo.class); + + /** The count. */ + private static int count; + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "DLQReprocessingPreProcessorOne"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + LOGGER.info("DLQReprocessingPreProcessorOne : Process {}"); + count++; + this.spc.forward(kafkaRecord); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + } + + /** + * Close. + */ + @Override + public void close() { + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + } + + /** + * Test implementation. + */ + public static final class DLQReprocessingPreProcessorTwo + implements StreamProcessor, IgniteEvent, IgniteKey, IgniteEvent> { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(DLQReprocessingPreProcessorTwo.class); + + /** The count. */ + private static int count; + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "DLQReprocessingPreProcessorTwor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + LOGGER.info("DLQReprocessingPreProcessorTwo : Process{}"); + count++; + this.spc.forward(kafkaRecord); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + } + + /** + * Close. + */ + @Override + public void close() { + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + } + + /** + * Test implementation. + */ + public static final class DLQReprocessingPostProcessorOne + implements StreamProcessor, IgniteEvent, IgniteKey, IgniteEvent> { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(DLQReprocessingPostProcessorOne.class); + + /** The count. */ + private static int count; + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "DLQReprocessingPostProcessorOne"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + LOGGER.info("DLQReprocessingPostProcessorOne : Process{}"); + count++; + this.spc.forward(kafkaRecord); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + } + + /** + * Close. + */ + @Override + public void close() { + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + } + + /** + * Test implementation. + */ + public static final class DLQReprocessingPostProcessorTwo + implements StreamProcessor, IgniteEvent, IgniteKey, IgniteEvent> { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(DLQReprocessingPostProcessorTwo.class); + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** The count. */ + private static int count; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "DLQReprocessingPostProcessorTwo"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + LOGGER.info("DLQReprocessingPostProcessorTwo : Process{}"); + count++; + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + } + + /** + * Close. + */ + @Override + public void close() { + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQRetryHandlerTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQRetryHandlerTest.java new file mode 100644 index 0000000..09ef1c4 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DLQRetryHandlerTest.java @@ -0,0 +1,912 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamBaseConstant; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.domain.AbstractBlobEventData.Encoding; +import org.eclipse.ecsp.domain.BlobDataV1_0; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.IgniteBaseException; +import org.eclipse.ecsp.domain.IgniteEventSource; +import org.eclipse.ecsp.domain.IgniteExceptionDataV1_1; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteBlobEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.serializer.IngestionSerializerFstImpl; +import org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.List; +import java.util.Properties; + + +/** + * IT test class {@link DLQRetryHandlerTest}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/dlq-reprocessing-test.properties") +public class DLQRetryHandlerTest extends KafkaStreamsApplicationTestBase { + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(DLQRetryHandlerTest.class); + + /** The in topic name. */ + private static String inTopicName = "service-test"; + + /** The device ID. */ + private static String deviceID = "DeviceId-1"; + + /** The request id. */ + private static String requestId = "req-1"; + + /** The vehicle id. */ + private static String vehicleId = "vehicle-1"; + + /** The service name. */ + private static String serviceName = "Ecall"; + + /** The dql topic name. */ + private static String dqlTopicName = "ecall" + StreamBaseConstant.DLQ_TOPIC_POSFIX; + + /** The value ser. */ + private static IngestionSerializerFstImpl valueSer = new IngestionSerializerFstImpl(); + + /** The key ser. */ + private static IgniteKeyTransformerStringImpl keySer = new IgniteKeyTransformerStringImpl(); + + /** The toggle DLQ. */ + private static boolean toggleDLQ = true; + + /** The mapper. */ + private ObjectMapper mapper = new ObjectMapper(); + + /** + * Setup. + * + * @throws Exception the exception + */ + @Override + @Before + public void setup() throws Exception { + super.setup(); + createTopics(inTopicName); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "tc-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + ksProps.put(PropertyNames.DISCOVERY_SERVICE_IMPL, + PropBasedDiscoveryServiceImpl.class.getName()); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, inTopicName); + ksProps.put(PropertyNames.APPLICATION_ID, "dlq"); + + ksProps.put("event.transformer.classes", "genericIgniteEventTransformer"); + ksProps.put("ignite.key.transformer.class", "org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl"); + ksProps.put("ingestion.serializer.class", "org.eclipse.ecsp.serializer.IngestionSerializerFstImpl"); + + } + + /** + * Tests the DLQ re-processing flow logic for max retry times and finally is forwarded to dlq. + * + * @throws Exception Exception + */ + @Test + public void testperformDLQReprocessingFailedAndFwdToDLQ() throws Exception { + DLQReprocessingPreProcessorOne.count = 0; + DLQReprocessingPreProcessorTwo.count = 0; + DLQReprocessingPostProcessorOne.count = 0; + DLQReprocessingPostProcessorTwo.count = 0; + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, DLQReprocessingMaxRetryServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + launchApplication(); + IgniteStringKey igniteStringKey = new IgniteStringKey(); + igniteStringKey.setKey("key1"); + IgniteBlobEvent event = getDummyIgniteBlobEvent(); + KafkaTestUtils.sendMessages(inTopicName, producerProps, keySer.toBlob(igniteStringKey), + valueSer.serialize(event)); + Thread.sleep(Constants.THREAD_SLEEP_TIME_60000); + List messages = KafkaTestUtils.getMessages(dqlTopicName, consumerProps, 1, + TestConstants.TEN_THOUSAND); + try { + IgniteStringKey key = mapper.readValue(messages.get(0)[0], IgniteStringKey.class); + LOGGER.info("DLQ Reprocessing message {}", messages); + Assert.assertEquals("key1", key.getKey()); + } catch (Exception e) { + e.printStackTrace(); + } + shutDown(); + + } + + /** + * Tests the DLQ re-processing flow logic for successfully completions with dlq reprocessing performed once. + * + * @throws Exception Exception + */ + + @Test + public void testDLQReprocessingForSuccess() throws Exception { + // cleanup + DLQReprocessingPreProcessorOne.count = 0; + DLQReprocessingPreProcessorTwo.count = 0; + DLQReprocessingPostProcessorOne.count = 0; + DLQReprocessingPostProcessorTwo.count = 0; + ksProps.put(PropertyNames.PRE_PROCESSORS, + "org.eclipse.ecsp.analytics.stream.base.processors.ProtocolTranslatorPreProcessor," + + DLQReprocessingPreProcessorOne.class.getName() + "," + + DLQReprocessingPreProcessorTwo.class.getName()); + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, DLQReprocessingServiceProcessor.class.getName()); + ksProps.put(PropertyNames.POST_PROCESSORS, + DLQReprocessingPostProcessorOne.class.getName() + "," + + DLQReprocessingPostProcessorTwo.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "chaining" + System.currentTimeMillis()); + launchApplication(); + IgniteStringKey igniteStringKey = new IgniteStringKey(); + igniteStringKey.setKey("key1"); + IgniteBlobEvent event = getDummyIgniteBlobEvent(); + KafkaTestUtils.sendMessages(inTopicName, producerProps, keySer.toBlob(igniteStringKey), + valueSer.serialize(event)); + Thread.sleep(Constants.THREAD_SLEEP_TIME_60000); + try { + Assert.assertEquals(1, DLQReprocessingPreProcessorOne.count); + Assert.assertEquals(1, DLQReprocessingPreProcessorTwo.count); + } catch (Exception e) { + e.printStackTrace(); + } + shutDown(); + } + + /** + * Gets the dummy ignite blob event. + * + * @return the dummy ignite blob event + */ + private IgniteBlobEvent getDummyIgniteBlobEvent() { + + IgniteBlobEvent igniteBlobEvent = new IgniteBlobEvent(); + igniteBlobEvent.setSourceDeviceId(deviceID); + igniteBlobEvent.setEventId(EventID.BLOBDATA); + igniteBlobEvent.setRequestId(requestId); + igniteBlobEvent.setSchemaVersion(Version.V1_0); + igniteBlobEvent.setTimestamp(System.currentTimeMillis()); + igniteBlobEvent.setVehicleId(vehicleId); + igniteBlobEvent.setVersion(Version.V1_0); + BlobDataV1_0 eventData = new BlobDataV1_0(); + eventData.setEncoding(Encoding.JSON); + eventData.setEventSource(IgniteEventSource.IGNITE); + String speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":20.0}}"; + eventData.setPayload(speedEvent.getBytes()); + igniteBlobEvent.setEventData(eventData); + return igniteBlobEvent; + } + + /** + * inner class {@link DLQServiceProcessor}. + */ + public static final class DLQServiceProcessor implements + StreamProcessor, IgniteEvent, IgniteKey, IgniteEvent> { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return serviceName; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + + IgniteKey key = kafkaRecord.key(); + IgniteEvent value = kafkaRecord.value(); + LOGGER.info("Test DLQ ---> Key {} Value {} event id {}", key, value, value.getEventId()); + throw new RuntimeException("DLQ testing"); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + } + + /** + * Close. + */ + @Override + public void close() { + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { inTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] { "test-dlq" }; + } + } + + /** + * inner class {@link DLQReprocessingServiceProcessor}. + */ + public static final class DLQReprocessingServiceProcessor + implements StreamProcessor, IgniteEvent, IgniteKey, IgniteEvent> { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "DLQReprocessingServiceProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + + IgniteKey key = kafkaRecord.key(); + IgniteEvent value = kafkaRecord.value(); + if (toggleDLQ) { + toggleDLQ = false; + LOGGER.info("Test DLQ Reprocessing ---> Key {} Value {} event id {}", + key, value, value.getEventId()); + throw new IgniteBaseException("Exception occured in DLQReprocessingServiceProcessor", true, + new RuntimeException("Connection refused.")); + } else { + IgniteEvent updatedValue = new IgniteEventImpl(); + if (value.getEventData() instanceof IgniteExceptionDataV1_1) { + updatedValue = ((IgniteExceptionDataV1_1) value.getEventData()).getIgniteEvent(); + } + Record, IgniteEvent> kafkaRecord1 = kafkaRecord; + kafkaRecord1.withKey(key); + kafkaRecord1.withValue(updatedValue); + this.spc.forward(kafkaRecord1); + } + + this.spc.forward(kafkaRecord); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + } + + /** + * Close. + */ + @Override + public void close() { + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { inTopicName }; + } + + } + + /** + * inner class DLQReprocessingMaxRetryServiceProcessor implements StreamProcessor. + */ + public static final class DLQReprocessingMaxRetryServiceProcessor + implements StreamProcessor, IgniteEvent, IgniteKey, IgniteEvent> { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "dlq-reprocessing-max-retry-test-processor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + IgniteEvent value = kafkaRecord.value(); + LOGGER.info("Test DLQ Reprocessing ---> Key {} Value {} event id {}", + kafkaRecord.key(), value, value.getEventId()); + throw new IgniteBaseException("Exception occured in DLQReprocessingServiceProcessor", true, + new RuntimeException("Connection refused.")); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + } + + /** + * Close. + */ + @Override + public void close() { + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { inTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] { "test-dlq" }; + } + } + + /** + * inner class {@link DLQReprocessingPreProcessorOne}. + */ + public static final class DLQReprocessingPreProcessorOne + implements StreamProcessor, IgniteEvent, IgniteKey, IgniteEvent> { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(DLQReprocessingPreProcessorTwo.class); + + /** The count. */ + private static int count; + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "DLQReprocessingPreProcessorOne"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + LOGGER.info("DLQReprocessingPreProcessorOne : Process {}", kafkaRecord); + count++; + this.spc.forward(kafkaRecord); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + } + + /** + * Close. + */ + @Override + public void close() { + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + } + + /** + * inner class {@link DLQReprocessingPreProcessorTwo}. + */ + public static final class DLQReprocessingPreProcessorTwo + implements StreamProcessor, IgniteEvent, IgniteKey, IgniteEvent> { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(DLQReprocessingPreProcessorTwo.class); + + /** The count. */ + private static int count; + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "DLQReprocessingPreProcessorTwor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + LOGGER.info("DLQReprocessingPreProcessorTwo : Process {}", kafkaRecord); + count++; + this.spc.forward(kafkaRecord); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + } + + /** + * Close. + */ + @Override + public void close() { + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + } + + /** + * inner class {@link DLQReprocessingPostProcessorOne}. + */ + public static final class DLQReprocessingPostProcessorOne + implements StreamProcessor, IgniteEvent, IgniteKey, IgniteEvent> { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(DLQReprocessingPostProcessorOne.class); + + /** The count. */ + private static int count; + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "DLQReprocessingPostProcessorOne"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + LOGGER.info("DLQReprocessingPostProcessorOne : Process {}", kafkaRecord); + count++; + this.spc.forward(kafkaRecord); + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + } + + /** + * Close. + */ + @Override + public void close() { + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + } + + /** + * inner class {@link DLQReprocessingPostProcessorTwo}. + */ + public static final class DLQReprocessingPostProcessorTwo + implements StreamProcessor, IgniteEvent, IgniteKey, IgniteEvent> { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(DLQReprocessingPostProcessorTwo.class); + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** The count. */ + private static int count; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "DLQReprocessingPostProcessorTwo"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + LOGGER.info("DLQReprocessingPostProcessorTwo : Process {}", kafkaRecord); + count++; + + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + } + + /** + * Close. + */ + @Override + public void close() { + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DeviceConnectionStatusRetrieverTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DeviceConnectionStatusRetrieverTest.java new file mode 100644 index 0000000..c05ccef --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/DeviceConnectionStatusRetrieverTest.java @@ -0,0 +1,169 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.analytics.stream.base.http.HttpClient; +import org.eclipse.ecsp.analytics.stream.base.parser.DeviceConnectionStatusParser; +import org.eclipse.ecsp.domain.DeviceConnStatusV1_0.ConnectionStatus; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdStatus; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusAPIInMemoryService; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.context.ApplicationContext; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.HashMap; +import java.util.concurrent.ConcurrentHashMap; + + +/** + * class DeviceConnectionStatusRetrieverTest. + */ +public class DeviceConnectionStatusRetrieverTest { + + /** The status retriever. */ + @InjectMocks + private DefaultDeviceConnectionStatusRetriever statusRetriever = new DefaultDeviceConnectionStatusRetriever(); + + /** The http client. */ + @Mock + HttpClient httpClient; + + /** The parser. */ + @Mock + DeviceConnectionStatusParser parser; + + /** The status service. */ + @Mock + DeviceStatusAPIInMemoryService statusService; + + /** The ctx. */ + @Mock + ApplicationContext ctx; + + /** + * Test get connection status data. + */ + @Test + public void testGetConnectionStatusData() { + httpClient = Mockito.mock(HttpClient.class); + parser = Mockito.mock(DeviceConnectionStatusParser.class); + statusService = Mockito.mock(DeviceStatusAPIInMemoryService.class); + + ReflectionTestUtils.setField(statusRetriever, "httpClient", httpClient); + ReflectionTestUtils.setField(statusRetriever, "parser", parser); + ReflectionTestUtils.setField(statusRetriever, "apiUrl", "test/url"); + ReflectionTestUtils.setField(statusRetriever, "deviceServiceInMemory", statusService); + + Mockito.when(httpClient.invokeJsonResource(Mockito.any(), Mockito.anyString(), + Mockito.anyMap(), Mockito.anyMap(), Mockito.anyInt(), Mockito.anyLong())).thenReturn(new HashMap<>()); + Mockito.when(parser.getConnectionStatus(Mockito.anyMap())).thenReturn(DMAConstants.ACTIVE); + ConcurrentHashMap map = new ConcurrentHashMap<>(); + String requestId = "request1"; + String vehicleId = "vin123"; + String deviceId = "device123"; + map.put(deviceId, ConnectionStatus.ACTIVE); + VehicleIdDeviceIdStatus mapping = new VehicleIdDeviceIdStatus(Version.V1_0, map); + + Mockito.when(statusService.get(vehicleId)).thenReturn(mapping); + mapping = statusRetriever.getConnectionStatusData(requestId, vehicleId, deviceId); + Assert.assertEquals(DMAConstants.ACTIVE, mapping.getDeviceIds().get(deviceId).getConnectionStatus()); + } + + /** + * Test append to URL. + */ + @Test + public void testAppendToURL() { + String vehicleId = "vehicle1234"; + String expectedUrl = "http://test-url.com/api/devices/" + vehicleId; + String actualUrl = ""; + String urlWithForwardSlash = "http://test-url.com/api/devices/"; + ReflectionTestUtils.setField(statusRetriever, "apiUrl", urlWithForwardSlash); + actualUrl = (String) ReflectionTestUtils.invokeMethod(statusRetriever, "appendToURL", vehicleId); + Assert.assertEquals(expectedUrl, actualUrl); + + String vehicleId2 = "vin123"; + expectedUrl = "http://test-url.com/api/devices/" + vehicleId2; + actualUrl = (String) ReflectionTestUtils.invokeMethod(statusRetriever, "appendToURL", vehicleId2); + Assert.assertEquals(expectedUrl, actualUrl); + + String urlWithoutForwardSlash = "http://test-url.com/api/devices"; + expectedUrl = "http://test-url.com/api/devices/" + vehicleId; + ReflectionTestUtils.setField(statusRetriever, "apiUrl", urlWithoutForwardSlash); + actualUrl = (String) ReflectionTestUtils.invokeMethod(statusRetriever, "appendToURL", vehicleId); + Assert.assertEquals(expectedUrl, actualUrl); + } + + /** + * Test get connection status data with empty API url. + */ + @Test(expected = IllegalArgumentException.class) + public void testGetConnectionStatusDataWithEmptyAPIUrl() { + ReflectionTestUtils.setField(statusRetriever, "apiUrl", ""); + statusRetriever.getConnectionStatusData("requestId", "vehicleId", "deviceId"); + } + + /** + * Test validate. + */ + @Test(expected = IllegalArgumentException.class) + public void testValidate() { + ReflectionTestUtils.setField(statusRetriever, "apiUrl", "test/url"); + ReflectionTestUtils.setField(statusRetriever, "apiMaxRetryCount", Constants.INT_MINUS_TWO); + ReflectionTestUtils.setField(statusRetriever, "apiRetryIntervalMs", Constants.INT_MINUS_TWO); + ReflectionTestUtils.invokeMethod(statusRetriever, "setup", new Object[0]); + } + + /** + * Test load connection status parser. + */ + @Test + public void testLoadConnectionStatusParser() { + ctx = Mockito.mock(ApplicationContext.class); + ReflectionTestUtils.setField(statusRetriever, "ctx", ctx); + ReflectionTestUtils.invokeMethod(statusRetriever, "setup", new Object[0]); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/EmbeddedMQTTServer.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/EmbeddedMQTTServer.java new file mode 100644 index 0000000..65d97b3 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/EmbeddedMQTTServer.java @@ -0,0 +1,223 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import de.flapdoodle.embed.process.runtime.Network; +import io.moquette.broker.Server; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; +import org.junit.rules.ExternalResource; + +import java.io.IOException; +import java.util.Properties; +import java.util.UUID; + + +/** + * class {@link EmbeddedMQTTServer} extends {@link ExternalResource}. + */ +public class EmbeddedMQTTServer extends ExternalResource { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(EmbeddedMQTTServer.class); + + /** The embed MQTT server. */ + private Server embedMQTTServer; + + /** The subscribe mqtt client. */ + private MqttClient subscribeMqttClient; + + /** The publish mqtt client. */ + private MqttClient publishMqttClient; + + /** The mqtt connection. */ + private MqttConnectOptions mqttConnection; + + // RTC-155383 - Running Kafka and Zookeeper on dynamic ports to resolve bind + /** The mqtt port. */ + // address issue in streambase project + private int mqttPort; + + /** The web socket port. */ + private int webSocketPort; + + /** The ssl port. */ + private int sslPort; + + /** + * subscribeToTopic(). + */ + public EmbeddedMQTTServer() { + // RTC-155383 - Running Kafka and Zookeeper on dynamic ports to resolve + // bind + // address issue in streambase project + try { + mqttPort = Network.getFreeServerPort(); + } catch (IOException e1) { + throw new RuntimeException("Not able to get available mqtt port to run embedded mqtt server"); + } + + try { + webSocketPort = Network.getFreeServerPort(); + } catch (IOException e1) { + throw new RuntimeException("Not able to get available webSocketPort port to run embedded mqtt server"); + } + + try { + sslPort = Network.getFreeServerPort(); + } catch (IOException e1) { + throw new RuntimeException("Not able to get available ssl port to run embedded mqtt server"); + } + + try { + String mqttConnectionString = getConnectionString(); + MqttDispatcher.overriddenMqttBrokerUrl = mqttConnectionString; + logger.info("Mqtt connection string:{}", mqttConnectionString); + subscribeMqttClient = new MqttClient(mqttConnectionString, UUID.randomUUID().toString(), + new MemoryPersistence()); + publishMqttClient = new MqttClient(mqttConnectionString, UUID.randomUUID().toString(), + new MemoryPersistence()); + } catch (MqttException e) { + throw new RuntimeException("Failed to create MQTT client", e); + } + mqttConnection = new MqttConnectOptions(); + mqttConnection.setCleanSession(true); + mqttConnection.setPassword("simulator16".toCharArray()); + mqttConnection.setUserName("8146ccc47e84ac1e43de623403133d55"); + } + + /** + * Before. + * + * @throws Throwable the throwable + */ + @Override + protected void before() throws Throwable { + super.before(); + if (null != embedMQTTServer) { + embedMQTTServer.stopServer(); + } + embedMQTTServer = new Server(); + Properties configProps = new Properties(); + configProps.load(EmbeddedMQTTServer.class.getResourceAsStream("/moquette.conf")); + + // RTC-155383 - Running Kafka and Zookeeper on dynamic ports to + // resolve bind + // address issue in streambase project while running test cases + // setting the "port" property in server to point to new dynamic port + configProps.setProperty("port", String.valueOf(mqttPort)); + configProps.setProperty("websocket_port", String.valueOf(webSocketPort)); + configProps.setProperty("ssl_port", String.valueOf(sslPort)); + + embedMQTTServer.startServer(configProps); + logger.info("MQTT Server started"); + } + + /** + * After. + */ + @Override + protected void after() { + super.after(); + if (null != embedMQTTServer) { + embedMQTTServer.stopServer(); + } + if (null != subscribeMqttClient) { + try { + subscribeMqttClient.disconnect(); + subscribeMqttClient.close(true); + publishMqttClient.disconnect(); + publishMqttClient.close(true); + } catch (MqttException e) { + logger.error("Failed to close MQTT Client"); + } + } + logger.info("MQTT Server stopped"); + } + + /** + * subscribeToTopic(). + * + * @param topic topic + * @param mqttCallback mqttCallback + * @throws MqttException MqttException + */ + public void subscribeToTopic(String topic, MqttCallback mqttCallback) throws MqttException { + if (!subscribeMqttClient.isConnected()) { + subscribeMqttClient.connect(mqttConnection); + } + subscribeMqttClient.subscribe(topic); + subscribeMqttClient.setCallback(mqttCallback); + logger.info("Subscribed to topic {}", topic); + } + + /** + * publishToTopic(). + * + * @param topic topic + * @param payload payload + * @throws MqttException MqttException + */ + public void publishToTopic(String topic, byte[] payload) throws MqttException { + if (!publishMqttClient.isConnected()) { + publishMqttClient.connect(mqttConnection); + } + MqttMessage mqttMessage = new MqttMessage(); + mqttMessage.setPayload(payload); + publishMqttClient.publish(topic, mqttMessage); + logger.info("Published to topic {}", topic); + } + + /** + * Gets the connection string. + * + * @return the connection string + */ + public String getConnectionString() { + return "tcp://" + "127.0.0.1" + ":" + + mqttPort; + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherHealthMontiorIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherHealthMontiorIntegrationTest.java new file mode 100644 index 0000000..d2e98b6 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherHealthMontiorIntegrationTest.java @@ -0,0 +1,214 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import com.hivemq.client.internal.mqtt.lifecycle.MqttClientAutoReconnectImpl; +import com.hivemq.client.mqtt.MqttClient; +import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.mqtt.MqttServer; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.Optional; +import java.util.UUID; + + +/** + * test class HiveMQMqttDispatcherHealthMontiorIntegrationTest. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/hivemq-mqtt-health-monitor.properties") +public class HiveMQMqttDispatcherHealthMontiorIntegrationTest { + + /** The Constant LOGGER. */ + private static final IgniteLogger LOGGER = IgniteLoggerFactory + .getLogger(HiveMQMqttDispatcherHealthMontiorIntegrationTest.class); + + /** The Constant MQTT_SERVER. */ + @ClassRule + public static final MqttServer MQTT_SERVER = new MqttServer(); + + /** The mqtt dispatcher. */ + @Autowired + MqttDispatcher mqttDispatcher; + + /** The default mqtt topic name generator impl. */ + @Autowired + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS_SERVER. */ + @ClassRule + public static final EmbeddedRedisServer REDIS_SERVER = new EmbeddedRedisServer(); + + /** The msg received. */ + boolean msgReceived = false; + + /** The mqtt topic. */ + private String mqttTopic = StringUtils.EMPTY; + + /** The forced check value. */ + private DeviceMessage forcedCheckValue; + + /** The forced check key. */ + private IgniteStringKey forcedCheckKey; + + /** The subscribe mqtt client. */ + private Mqtt3AsyncClient subscribeMqttClient; + + /** + * setup(): to initialize the properties. + */ + @Before + public void setup() { + + forcedCheckKey = new IgniteStringKey(); + forcedCheckKey.setKey(Constants.FORCED_HEALTH_CHECK_DEVICE_ID); + forcedCheckValue = new DeviceMessage(); + forcedCheckValue.setMessage("forcedHealthCheckDummyMsg".getBytes()); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId(Constants.FORCED_HEALTH_CHECK_DEVICE_ID); + forcedCheckValue.setDeviceMessageHeader(header); + defaultMqttTopicNameGeneratorImpl.setTopicNamePrefix("haa/harman/dev/"); + subscribeMqttClient = MqttClient.builder().identifier(UUID.randomUUID().toString()) + .serverHost("localhost").serverPort(Constants.INT_1883) + .useMqttVersion3().automaticReconnect(MqttClientAutoReconnectImpl.DEFAULT) + .buildAsync(); + } + + /** + * Test mqtt health monitor integration. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testMqttHealthMonitorIntegration() throws InterruptedException { + String mqttTopicToSubscribe = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(forcedCheckKey, + forcedCheckValue.getDeviceMessageHeader(), null).get(); + subscribeMqttClient.connectWith().cleanSession(false).send(); + subscribeMqttClient.subscribeWith() + .topicFilter(mqttTopicToSubscribe) + .callback(callback -> { + LOGGER.error("Msg received:{} on topic:{}", callback.getPayload(), callback.getTopic()); + msgReceived = true; + mqttTopic = callback.getTopic().toString(); + }).send(); + + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + Assert.assertEquals(false, mqttDispatcher.isHealthy(false)); + RetryUtils.retry(Constants.TWENTY, (v) -> { + return mqttTopic.length() > 0 ? Boolean.TRUE : null; + }); + Assert.assertEquals(false, msgReceived); + Assert.assertEquals("", mqttTopic); + Assert.assertEquals(true, mqttDispatcher.isHealthy(true)); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + RetryUtils.retry(Constants.TWENTY, (v) -> { + return mqttTopic.length() > 0 ? Boolean.TRUE : null; + }); + + Assert.assertEquals(true, mqttDispatcher.isHealthy(false)); + Assert.assertEquals("haa/harman/dev/testDevice123/2d/test", mqttTopic); + Assert.assertEquals(true, msgReceived); + mqttDispatcher.close(); + subscribeMqttClient = null; + } + + /** + * inner class TestEvent extends IgniteEventImpl. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "testHealthMonitorService"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("testDevice123"); + } + + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherHealthMontiorMultipleDispatcherIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherHealthMontiorMultipleDispatcherIntegrationTest.java new file mode 100644 index 0000000..f4a735b --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherHealthMontiorMultipleDispatcherIntegrationTest.java @@ -0,0 +1,114 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.MqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.MqttHealthMonitor; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + + +/** + * class HiveMQMqttDispatcherHealthMontiorMultipleDispatcherIntegrationTest + * extends KafkaStreamsApplicationTestBase. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/hivemq-mqtt-health-monitor.properties") +public class HiveMQMqttDispatcherHealthMontiorMultipleDispatcherIntegrationTest extends + KafkaStreamsApplicationTestBase { + + /** The monitor. */ + @Autowired + MqttHealthMonitor monitor; + + /** The Mqtt dispatcher one. */ + @Autowired + MqttDispatcher MqttDispatcherOne; + + /** The Mqtt dispatcher two. */ + @Autowired + MqttDispatcher MqttDispatcherTwo; + + /** + * Setup. + * + * @throws Exception the exception + */ + @Before + public void setup() throws Exception { + super.setup(); + } + + /** + * Test mqtt health monitor integration. + */ + @Test + public void testMqttHealthMonitorIntegration() { + + List dispatchers = monitor.getDispatchers(); + Assert.assertEquals(Constants.THREE, monitor.getDispatchers().size()); + MqttDispatcher mqttDispatcherOne = dispatchers.get(0); + MqttDispatcher mqttDispatcherTwo = dispatchers.get(1); + if (mqttDispatcherOne.isHealthy(false) && mqttDispatcherTwo.isHealthy(false)) { + Assert.assertEquals(true, monitor.isHealthy(false)); + } else { + Assert.assertEquals(false, monitor.isHealthy(false)); + } + ReflectionTestUtils.setField(MqttDispatcherOne, "healthy", false); + Assert.assertFalse(monitor.isHealthy(false)); + Assert.assertTrue(monitor.isHealthy(true)); + Assert.assertEquals(true, ReflectionTestUtils.getField(MqttDispatcherOne, "healthy")); + + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherHealthMontiorTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherHealthMontiorTest.java new file mode 100644 index 0000000..f476c6e --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherHealthMontiorTest.java @@ -0,0 +1,259 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import com.hivemq.client.mqtt.MqttClientState; +import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient; +import com.hivemq.client.mqtt.mqtt3.Mqtt3ClientConfig; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.platform.MqttTopicNameGenerator; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + + +/** + * {@link HiveMQMqttDispatcherTestHealthMontior}. + */ + +public class HiveMQMqttDispatcherHealthMontiorTest { + + /** The mqtt health monitor. */ + @Spy + private MqttHealthMonitor mqttHealthMonitor; + + /** The mqtt dispatcher one. */ + @Spy + private HiveMqMqttDispatcher mqttDispatcherOne; + + /** The mqtt dispatcher two. */ + @Spy + private HiveMqMqttDispatcher mqttDispatcherTwo; + + /** The mqtt client one. */ + @Mock + private Mqtt3AsyncClient mqttClientOne; + + /** The mqtt client two. */ + @Mock + private Mqtt3AsyncClient mqttClientTwo; + + /** The mqtt client map one. */ + @Mock + Map mqttClientMapOne; + + /** The mqtt client map two. */ + @Mock + Map mqttClientMapTwo; + + /** The mqtt 3 client config. */ + @Mock + Mqtt3ClientConfig mqtt3ClientConfig; + + /** The forced check key. */ + private IgniteStringKey forcedCheckKey; + + /** The forced check value. */ + private DeviceMessage forcedCheckValue; + + /** The name generator. */ + @Mock + private MqttTopicNameGenerator nameGenerator; + + /** + * setUp(). + */ + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + ReflectionTestUtils.setField(mqttHealthMonitor, "mqttHealthMonitorEnabled", true); + ReflectionTestUtils.setField(mqttDispatcherOne, "retryCount", 1); + ReflectionTestUtils.setField(mqttDispatcherTwo, "retryCount", 1); + ReflectionTestUtils.setField(mqttHealthMonitor, "dispatchers", + Arrays.asList(mqttDispatcherOne, mqttDispatcherTwo)); + forcedCheckKey = new IgniteStringKey(); + forcedCheckKey.setKey(Constants.FORCED_HEALTH_CHECK_DEVICE_ID); + ReflectionTestUtils.setField(mqttDispatcherOne, "forcedCheckKey", forcedCheckKey); + ReflectionTestUtils.setField(mqttDispatcherTwo, "forcedCheckKey", forcedCheckKey); + forcedCheckValue = new DeviceMessage(); + IgniteEventImpl event = new IgniteEventImpl(); + event.setPlatformId(PropertyNames.DEFAULT_PLATFORMID); + forcedCheckValue.setEvent(event); + forcedCheckValue.setMessage("forcedHealthCheckDummyMsg".getBytes()); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId(Constants.FORCED_HEALTH_CHECK_DEVICE_ID); + forcedCheckValue.setDeviceMessageHeader(header); + ReflectionTestUtils.setField(mqttDispatcherOne, "forcedCheckValue", forcedCheckValue); + ReflectionTestUtils.setField(mqttDispatcherTwo, "forcedCheckValue", forcedCheckValue); + Mockito.doReturn(MqttClientState.CONNECTED).when(mqttClientOne).getState(); + Mockito.doReturn(MqttClientState.CONNECTED).when(mqttClientTwo).getState(); + Mockito.doReturn(Optional.of(mqttClientTwo)) + .when(mqttDispatcherOne).getMqttClient(PropertyNames.DEFAULT_PLATFORMID); + Mockito.doReturn(Optional.of(mqttClientOne)) + .when(mqttDispatcherTwo).getMqttClient(PropertyNames.DEFAULT_PLATFORMID); + Mockito.doNothing().when(mqttDispatcherOne) + .publishMessageToMqttTopic(any(), eq(false), eq(PropertyNames.DEFAULT_PLATFORMID)); + Mockito.doNothing().when(mqttDispatcherTwo) + .publishMessageToMqttTopic(any(), eq(false), eq(PropertyNames.DEFAULT_PLATFORMID)); + when(mqttClientOne.getConfig()).thenReturn(mqtt3ClientConfig); + when(mqttClientTwo.getConfig()).thenReturn(mqtt3ClientConfig); + + Mockito.when(mqttClientMapOne.get(PropertyNames.DEFAULT_PLATFORMID)).thenReturn(mqttClientOne); + Mockito.when(mqttClientMapTwo.get(PropertyNames.DEFAULT_PLATFORMID)).thenReturn(mqttClientTwo); + ReflectionTestUtils.setField(mqttDispatcherOne, "mqttClientMap", mqttClientMapOne); + ReflectionTestUtils.setField(mqttDispatcherTwo, "mqttClientMap", mqttClientMapTwo); + } + + /** + * Test is healthy. + */ + @Test + public void testIsHealthy() { + + ReflectionTestUtils.setField(mqttDispatcherOne, "healthy", false); + ReflectionTestUtils.setField(mqttDispatcherTwo, "healthy", true); + + ReflectionTestUtils.setField(mqttDispatcherTwo, "mqttTopicNameGenerator", nameGenerator); + ReflectionTestUtils.setField(mqttDispatcherOne, "mqttTopicNameGenerator", nameGenerator); + when(nameGenerator.getMqttTopicName(any(), any(), any())).thenReturn(Optional.of("topic")); + Assert.assertEquals(false, mqttDispatcherOne.isHealthy(false)); + Assert.assertEquals(true, mqttDispatcherTwo.isHealthy(false)); + Assert.assertEquals(false, mqttHealthMonitor.isHealthy(false)); + + Mockito.verify(mqttDispatcherOne, + Mockito.times(0)).dispatch(forcedCheckKey, forcedCheckValue); + Mockito.verify(mqttDispatcherTwo, + Mockito.times(0)).dispatch(forcedCheckKey, forcedCheckValue); + Assert.assertEquals(true, mqttHealthMonitor.isHealthy(true)); + + Mockito.verify(mqttDispatcherOne, + Mockito.times(1)).dispatch(forcedCheckKey, forcedCheckValue); + Mockito.verify(mqttDispatcherTwo, + Mockito.times(1)).dispatch(forcedCheckKey, forcedCheckValue); + Assert.assertEquals(true, mqttDispatcherOne.isHealthy(false)); + Assert.assertEquals(true, mqttDispatcherTwo.isHealthy(false)); + + ReflectionTestUtils.setField(mqttDispatcherTwo, "healthy", false); + Assert.assertEquals(true, mqttDispatcherOne.isHealthy(false)); + Assert.assertEquals(false, mqttDispatcherTwo.isHealthy(false)); + Assert.assertEquals(false, mqttHealthMonitor.isHealthy(false)); + + Assert.assertEquals(true, mqttHealthMonitor.isHealthy(true)); + Assert.assertEquals(true, mqttDispatcherOne.isHealthy(false)); + Assert.assertEquals(true, mqttDispatcherTwo.isHealthy(false)); + + } + + /** + * Test monitor name. + */ + @Test + public void testMonitorName() { + Assert.assertEquals("MQTT_HEALTH_MONITOR", mqttHealthMonitor.monitorName()); + } + + /** + * Test metric name. + */ + @Test + public void testMetricName() { + String metricName = mqttHealthMonitor.metricName(); + Assert.assertEquals("MQTT_HEALTH_GUAGE", metricName); + } + + /** + * Test is enabled. + */ + @Test + public void testIsEnabled() { + boolean isEnabled = mqttHealthMonitor.isEnabled(); + Assert.assertEquals(true, isEnabled); + + } + + /** + * Test needs restart on failure. + */ + @Test + public void testNeedsRestartOnFailure() { + ReflectionTestUtils.setField(mqttHealthMonitor, "mqttRestartOnFailure", true); + Assert.assertEquals(true, mqttHealthMonitor.needsRestartOnFailure()); + + } + + /** + * Test initialize forced health check event. + */ + @Test + public void testInitializeForcedHealthCheckEvent() { + mqttDispatcherOne.initializeForcedHealthCheckEvent(); + IgniteStringKey igniteStringKey = (IgniteStringKey) ReflectionTestUtils.getField(mqttDispatcherOne, + "forcedCheckKey"); + Assert.assertEquals(Constants.FORCED_HEALTH_CHECK_DEVICE_ID, igniteStringKey.getKey()); + DeviceMessage forcedCheckValue = (DeviceMessage) ReflectionTestUtils.getField(mqttDispatcherOne, + "forcedCheckValue"); + Assert.assertEquals(Constants.FORCED_HEALTH_CHECK_DEVICE_ID, + forcedCheckValue.getDeviceMessageHeader().getTargetDeviceId()); + Assert.assertEquals(Constants.FORCED_HEALTH_DEFAULT_TEST_TOPIC_NAME, + forcedCheckValue.getDeviceMessageHeader().getDevMsgTopicSuffix()); + Assert.assertEquals(Constants.FORCED_HEALTH_DEFAULT_TEST_TOPIC_NAME, + forcedCheckValue.getEvent().getDevMsgTopicSuffix().get()); + Assert.assertEquals(Constants.FORCED_HEALTH_CHECK_DEVICE_ID, + forcedCheckValue.getEvent().getTargetDeviceId().get()); + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherIntegrationSSLTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherIntegrationSSLTest.java new file mode 100644 index 0000000..55ceb1a --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherIntegrationSSLTest.java @@ -0,0 +1,225 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import com.hivemq.client.internal.mqtt.lifecycle.MqttClientAutoReconnectImpl; +import com.hivemq.client.mqtt.MqttClient; +import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.mqtt.MqttTLSServer; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import java.util.Optional; +import java.util.UUID; + + +/** + * Test class to test the MqttDispatcher class functionality. + * + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/hivemq-test-ssl-mqtt.properties") +public class HiveMQMqttDispatcherIntegrationSSLTest { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(HiveMQMqttDispatcherIntegrationSSLTest.class); + + /** The msg received. */ + boolean msgReceived = false; + + /** The mqtt topic. */ + private String mqttTopic = StringUtils.EMPTY; + + /** The mqtt dispatcher. */ + @Autowired + private MqttDispatcher mqttDispatcher; + + /** The hive mq mqtt dispatcher. */ + @Autowired + private HiveMqMqttDispatcher hiveMqMqttDispatcher; + + /** The default mqtt topic name generator impl. */ + @Autowired + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS_SERVER. */ + @ClassRule + public static final EmbeddedRedisServer REDIS_SERVER = new EmbeddedRedisServer(); + + /** The Constant MQTT_SERVER. */ + @ClassRule + public static final MqttTLSServer MQTT_SERVER = new MqttTLSServer(); + + /** The value. */ + private DeviceMessage value; + + /** The subscribe mqtt client. */ + private Mqtt3AsyncClient subscribeMqttClient; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** + * Setup for the test class. + */ + @Before + public void setup() { + TestEvent event = new TestEvent(); + value = new DeviceMessage(); + value.setMessage(transformer.toBlob(event)); + value.setEvent(event); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId("test"); + value.setDeviceMessageHeader(header); + subscribeMqttClient = MqttClient.builder().identifier(UUID.randomUUID().toString()) + .serverHost("localhost").serverPort(TestConstants.PORT) + .useMqttVersion3().automaticReconnect(MqttClientAutoReconnectImpl.DEFAULT) + .buildAsync(); + } + + /** + * Test client connection without topic pefix. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testClientConnection_without_topic_pefix() throws InterruptedException { + hiveMqMqttDispatcher.isHealthy(true); + defaultMqttTopicNameGeneratorImpl.setTopicNamePrefix(""); + TestKey key = new TestKey(); + String mqttTopicToSubscribe = defaultMqttTopicNameGeneratorImpl + .getMqttTopicName(key, value.getDeviceMessageHeader(), null).get(); + subscribeMqttClient.connectWith().cleanSession(false).send(); + subscribeMqttClient.subscribeWith() + .topicFilter(mqttTopicToSubscribe) + .callback((publish) -> { + logger.info("Msg received:{} on topic:{}", publish.getPayload().get(), publish.getTopic()); + msgReceived = true; + mqttTopic = publish.getTopic().toString(); + }).send(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + hiveMqMqttDispatcher.dispatch(key, value); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + RetryUtils.retry(TestConstants.TWENTY, (v) -> mqttTopic.length() > 0 ? Boolean.TRUE : null); + + Assert.assertEquals("test/2d/test", mqttTopic); + Assert.assertEquals(true, msgReceived); + subscribeMqttClient = null; + hiveMqMqttDispatcher.close(); + } + + /** + * Test IgniteKey. + */ + public class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + return "test"; + } + } + + /** + * Test IgniteEvent. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Sample"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("test"); + } + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherIntegrationTest.java new file mode 100644 index 0000000..4e0c9bc --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherIntegrationTest.java @@ -0,0 +1,223 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import com.hivemq.client.internal.mqtt.lifecycle.MqttClientAutoReconnectImpl; +import com.hivemq.client.mqtt.MqttClient; +import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.mqtt.MqttServer; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.junit.Assert; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.Optional; +import java.util.UUID; + + +/** + * Test class to test the MqttDispatcher class functionality. + * + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/hivemq-test-mqtt-without-topic-prefix.properties") +public class HiveMQMqttDispatcherIntegrationTest { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(HiveMQMqttDispatcherIntegrationTest.class); + + /** The msg received. */ + boolean msgReceived = false; + + /** The mqtt topic. */ + private String mqttTopic = StringUtils.EMPTY; + + /** The mqtt dispatcher. */ + @Autowired + private MqttDispatcher mqttDispatcher; + + /** The default mqtt topic name generator impl. */ + @Autowired + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS_SERVER. */ + @ClassRule + public static final EmbeddedRedisServer REDIS_SERVER = new EmbeddedRedisServer(); + + /** The Constant MQTT_SERVER. */ + @ClassRule + public static final MqttServer MQTT_SERVER = new MqttServer(); + + /** The value. */ + private DeviceMessage value; + + /** The subscribe mqtt client. */ + private Mqtt3AsyncClient subscribeMqttClient; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** + * setup(). + */ + @Before + public void setup() { + TestEvent event = new TestEvent(); + value = new DeviceMessage(); + value.setMessage(transformer.toBlob(event)); + value.setEvent(event); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId("test"); + value.setDeviceMessageHeader(header); + subscribeMqttClient = MqttClient.builder().identifier(UUID.randomUUID().toString()) + .serverHost("localhost").serverPort(Constants.INT_1883) + .useMqttVersion3().automaticReconnect(MqttClientAutoReconnectImpl.DEFAULT) + .buildAsync(); + } + + /** + * Test client connection without topic pefix. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testClientConnection_without_topic_pefix() throws InterruptedException { + defaultMqttTopicNameGeneratorImpl.setTopicNamePrefix(""); + TestKey key = new TestKey(); + String mqttTopicToSubscribe = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(key, + value.getDeviceMessageHeader(), null).get(); + subscribeMqttClient.connectWith().cleanSession(false).send(); + subscribeMqttClient.subscribeWith() + .topicFilter(mqttTopicToSubscribe) + .callback((publish) -> { + logger.info("Msg received:{} on topic:{}", publish.getPayload().get(), publish.getTopic()); + + msgReceived = true; + mqttTopic = publish.getTopic().toString(); + }).send(); + mqttDispatcher.dispatch(key, value); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_1000); + RetryUtils.retry(TestConstants.TWENTY, (v) -> { + return mqttTopic.length() > 0 ? Boolean.TRUE : null; + }); + + Assert.assertEquals("test/2d/test", mqttTopic); + Assert.assertEquals(true, msgReceived); + subscribeMqttClient = null; + mqttDispatcher.close(); + } + + /** + * class TestKey implements IgniteKey. + */ + public class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + return "test"; + } + } + + /** + * class TestEvent extends IgniteEventImpl. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Sample"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("test"); + } + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherTest.java new file mode 100644 index 0000000..dea8f08 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/HiveMQMqttDispatcherTest.java @@ -0,0 +1,421 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import com.hivemq.client.mqtt.MqttClientState; +import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient; +import com.hivemq.client.mqtt.mqtt3.Mqtt3ClientConfig; +import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3Publish; +import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3PublishBuilder; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.platform.MqttTopicNameGenerator; +import org.eclipse.ecsp.domain.BlobDataV1_0; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteBlobEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.serializer.IngestionSerializer; +import org.eclipse.ecsp.stream.dma.handler.DeviceMessageUtils; +import org.eclipse.ecsp.utils.metrics.IgniteErrorCounter; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +/** + * test class HiveMQMqttDispatcherTest. + */ +public class HiveMQMqttDispatcherTest { + + /** The mockito rule. */ + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + /** The mqtt dispatcher. */ + @InjectMocks + private MqttDispatcher mqttDispatcher = new HiveMqMqttDispatcher(); + + /** The client. */ + @Mock + private Mqtt3AsyncClient client; + + /** The default mqtt topic name generator impl. */ + @Mock + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The transformer. */ + @Mock + private IngestionSerializer transformer; + + /** The hive mq mqtt dispatcher. */ + @InjectMocks + HiveMqMqttDispatcher hiveMqMqttDispatcher; + + /** The mqtt 3 client config. */ + @Mock + Mqtt3ClientConfig mqtt3ClientConfig; + + /** The device message utils. */ + @Mock + DeviceMessageUtils deviceMessageUtils; + + /** The error counter. */ + @Mock + IgniteErrorCounter errorCounter; + + /** The mqtt 3 publish. */ + @Mock + Mqtt3PublishBuilder.Send> mqtt3Publish; + + /** The mqtt client map. */ + Map mqttClientMap; + + /** The mqtt platform config map. */ + Map mqttPlatformConfigMap; + + /** The mqtt topic name generator. */ + @Mock + MqttTopicNameGenerator mqttTopicNameGenerator; + + /** + * Setup for this test case. + */ + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mqttClientMap = new HashMap<>(); + mqttClientMap.put("default", client); + ReflectionTestUtils.setField(mqttDispatcher, "mqttClientMap", mqttClientMap); + + mqttPlatformConfigMap = new HashMap<>(); + MqttConfig config = new MqttConfig(); + config.setMqttQosValue(0); + mqttPlatformConfigMap.put(PropertyNames.DEFAULT_PLATFORMID, config); + ReflectionTestUtils.setField(mqttDispatcher, "mqttPlatformConfigMap", mqttPlatformConfigMap); + ReflectionTestUtils.setField(mqttDispatcher, "mqttTopicNameGenerator", mqttTopicNameGenerator); + when(mqttTopicNameGenerator.getMqttTopicName(any(), any(), any())).thenReturn(Optional.of("topic")); + } + + /** + * Test wrapevent frequency validation. + */ + @Test(expected = IllegalArgumentException.class) + public void testWrapeventFrequencyValidation() { + mqttDispatcher.setEventWrapFrequency(0); + mqttDispatcher.validateEventWrapFrequency(); + } + + /** + * Test wrapevent. + */ + /* + * Happy flow where wrap event is set to true and frequency is 1 so all + * events will be wrapped. + */ + @Test + public void testWrapevent() { + String input = "input"; + String transformed = "transformed"; + DeviceMessage entity = new DeviceMessage(input.getBytes(), Version.V1_0, + new TestEvent(), "topic", Constants.THREAD_SLEEP_TIME_60000); + entity.getDeviceMessageHeader().withDevMsgGlobalTopic("globaltopic"); + entity.getDeviceMessageHeader().withTargetDeviceId("td"); + entity.getDeviceMessageHeader().withVehicleId("vd"); + entity.getDeviceMessageHeader().withRequestId("rd"); + when(transformer.serialize(any())).thenReturn(transformed.getBytes()); + mqttClientMap.put(PropertyNames.DEFAULT_PLATFORMID, client); + hiveMqMqttDispatcher.setMqttClientMap(mqttClientMap); + when(client.getState()).thenReturn(MqttClientState.valueOf("CONNECTED")); + when(client.getConfig()).thenReturn(mqtt3ClientConfig); + mqttDispatcher.setEventWrapFrequency(1); + mqttDispatcher.setWrapDispatchEvent(true); + mqttDispatcher.setTransformer(transformer); + mqttDispatcher.dispatch(new TestKey(), entity); + + Mockito.verify(transformer, Mockito.times(1)).serialize(any()); + + ArgumentCaptor keyArgument = ArgumentCaptor.forClass(IgniteBlobEvent.class); + Mockito.verify(transformer).serialize(keyArgument.capture()); + IgniteBlobEvent actualKey = keyArgument.getValue(); + BlobDataV1_0 blobDataV10 = (BlobDataV1_0) actualKey.getEventData(); + Assert.assertEquals(new String(blobDataV10.getPayload()), new String(input.getBytes())); + + } + + /** + * Test wrapevent false. + */ + /* + * Here the flaf wrap event is set to false hence event should not be + * wrapped + */ + @Test + public void testWrapeventFalse() { + String input = "input"; + String transformed = "transformed"; + DeviceMessage entity = new DeviceMessage(input.getBytes(), Version.V1_0, + new TestEvent(), "topic", Constants.THREAD_SLEEP_TIME_60000); + entity.getDeviceMessageHeader().withDevMsgGlobalTopic("globaltopic"); + entity.getDeviceMessageHeader().withTargetDeviceId("td"); + entity.getDeviceMessageHeader().withVehicleId("vd"); + entity.getDeviceMessageHeader().withRequestId("rd"); + when(client.getState()).thenReturn(MqttClientState.valueOf("CONNECTED")); + when(client.getConfig()).thenReturn(mqtt3ClientConfig); + when(transformer.serialize(any())).thenReturn(transformed.getBytes()); + mqttClientMap.put(PropertyNames.DEFAULT_PLATFORMID, client); + hiveMqMqttDispatcher.setMqttClientMap(mqttClientMap); + mqttDispatcher.setEventWrapFrequency(1); + mqttDispatcher.setWrapDispatchEvent(false); + mqttDispatcher.setTransformer(transformer); + mqttDispatcher.dispatch(new TestKey(), entity); + + Mockito.verify(transformer, Mockito.times(0)).serialize(any()); + + } + + /** + * Test wrapevent when event fequency is not met. + */ + /* + * Here we invoke dispatch just once and frequency of sampling is 2 hence + * wrapping should not be done. + */ + @Test + public void testWrapeventWhenEventFequencyIsNotMet() { + String input = "input"; + String transformed = "transformed"; + DeviceMessage entity = new DeviceMessage(input.getBytes(), Version.V1_0, + new TestEvent(), "topic", Constants.THREAD_SLEEP_TIME_60000); + entity.getDeviceMessageHeader().withDevMsgGlobalTopic("globaltopic"); + entity.getDeviceMessageHeader().withTargetDeviceId("td"); + entity.getDeviceMessageHeader().withVehicleId("vd"); + entity.getDeviceMessageHeader().withRequestId("rd"); + when(client.getState()).thenReturn(MqttClientState.valueOf("CONNECTED")); + when(client.getConfig()).thenReturn(mqtt3ClientConfig); + when(transformer.serialize(any())).thenReturn(transformed.getBytes()); + mqttClientMap.put(PropertyNames.DEFAULT_PLATFORMID, client); + hiveMqMqttDispatcher.setMqttClientMap(mqttClientMap); + mqttDispatcher.setEventWrapFrequency(Constants.TWO); + mqttDispatcher.setWrapDispatchEvent(true); + mqttDispatcher.setTransformer(transformer); + mqttDispatcher.dispatch(new TestKey(), entity); + + Mockito.verify(transformer, Mockito.times(0)).serialize(any()); + + } + + /** + * Test client connection when exception occurs. + */ + @Test + public void testClientConnection_when_exception_occurs() { + defaultMqttTopicNameGeneratorImpl.setTopicNamePrefix(""); + DeviceMessage entity = new DeviceMessage("input".getBytes(), Version.V1_0, new TestEvent(), + "topic", Constants.THREAD_SLEEP_TIME_60000); + entity.getDeviceMessageHeader().withDevMsgGlobalTopic("globaltopic"); + entity.getDeviceMessageHeader().withTargetDeviceId("td"); + entity.getDeviceMessageHeader().withVehicleId("vd"); + entity.getDeviceMessageHeader().withRequestId("rd"); + when(client.getState()).thenReturn(MqttClientState.valueOf("CONNECTED")); + when(transformer.serialize(any())).thenReturn("transformed".getBytes()); + when(client.getConfig()).thenReturn(mqtt3ClientConfig); + mqttClientMap.put(PropertyNames.DEFAULT_PLATFORMID, client); + hiveMqMqttDispatcher.setMqttClientMap(mqttClientMap); + when(client.publish(any())).thenThrow(RuntimeException.class); + mqttDispatcher.dispatch(new TestKey(), entity); + + Assert.assertFalse(mqttDispatcher.healthy); + verify(client, times(1)).disconnect(); + } + + /** + * Test wrapevent for global topic listed. + */ + @Test + public void testWrapeventForGlobalTopicListed() { + String input = "input"; + String transformed = "transformed"; + DeviceMessage entity = new DeviceMessage(input.getBytes(), Version.V1_0, + new TestEvent(), "topic", Constants.THREAD_SLEEP_TIME_60000); + entity.getDeviceMessageHeader().withDevMsgGlobalTopic("globaltopic"); + entity.getDeviceMessageHeader().withTargetDeviceId("td"); + entity.getDeviceMessageHeader().withVehicleId("vd"); + entity.getDeviceMessageHeader().withRequestId("rd"); + + when(transformer.serialize(any())).thenReturn(transformed.getBytes()); + List topicList = new ArrayList<>(); + topicList.add("test"); + topicList.add("topic"); + mqttDispatcher.setGlobalBroadcastRetentionTopicList(topicList); + when(client.getState()).thenReturn(MqttClientState.valueOf("CONNECTED")); + when(client.getConfig()).thenReturn(mqtt3ClientConfig); + mqttClientMap.put(PropertyNames.DEFAULT_PLATFORMID, client); + hiveMqMqttDispatcher.setMqttClientMap(mqttClientMap); + mqttDispatcher.setEventWrapFrequency(1); + mqttDispatcher.setWrapDispatchEvent(true); + mqttDispatcher.setTransformer(transformer); + mqttDispatcher.dispatch(new TestKey(), entity); + + Mockito.verify(transformer, Mockito.times(1)).serialize(any()); + + ArgumentCaptor keyArgument = ArgumentCaptor.forClass(IgniteBlobEvent.class); + Mockito.verify(transformer).serialize(keyArgument.capture()); + IgniteBlobEvent actualKey = keyArgument.getValue(); + BlobDataV1_0 blobDataV10 = (BlobDataV1_0) actualKey.getEventData(); + Assert.assertEquals(new String(blobDataV10.getPayload()), new String(input.getBytes())); + + } + + /** + * Test wrapevent for non global topic listed. + */ + @Test + public void testWrapeventForNonGlobalTopicListed() { + String input = "input"; + String transformed = "transformed"; + DeviceMessage entity = new DeviceMessage(input.getBytes(), Version.V1_0, + new TestEvent(), "topic", Constants.THREAD_SLEEP_TIME_60000); + entity.getDeviceMessageHeader().withDevMsgGlobalTopic("globaltopic"); + entity.getDeviceMessageHeader().withTargetDeviceId("td"); + entity.getDeviceMessageHeader().withVehicleId("vd"); + entity.getDeviceMessageHeader().withRequestId("rd"); + + when(transformer.serialize(any())).thenReturn(transformed.getBytes()); + List topicList = new ArrayList<>(); + topicList.add("globaltopic"); + topicList.add("topic"); + mqttDispatcher.setGlobalBroadcastRetentionTopicList(topicList); + mqttClientMap.put(PropertyNames.DEFAULT_PLATFORMID, client); + hiveMqMqttDispatcher.setMqttClientMap(mqttClientMap); + when(client.getState()).thenReturn(MqttClientState.valueOf("CONNECTED")); + when(client.getConfig()).thenReturn(mqtt3ClientConfig); + mqttDispatcher.setEventWrapFrequency(1); + mqttDispatcher.setWrapDispatchEvent(true); + mqttDispatcher.setTransformer(transformer); + mqttDispatcher.dispatch(new TestKey(), entity); + + Mockito.verify(transformer, Mockito.times(1)).serialize(any()); + + ArgumentCaptor keyArgument = ArgumentCaptor.forClass(IgniteBlobEvent.class); + Mockito.verify(transformer).serialize(keyArgument.capture()); + IgniteBlobEvent actualKey = keyArgument.getValue(); + BlobDataV1_0 blobDataV10 = (BlobDataV1_0) actualKey.getEventData(); + Assert.assertEquals(new String(blobDataV10.getPayload()), new String(input.getBytes())); + + } + + /** + * inner class TestKey implements IgniteKey. + */ + + public class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + + return "test"; + } + + } + + /** + * inner class TestEvent extends IgniteEventImpl. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Sample"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("test"); + } + + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/JsonUtilsTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/JsonUtilsTest.java new file mode 100644 index 0000000..0d13f62 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/JsonUtilsTest.java @@ -0,0 +1,360 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Assert; +import org.junit.Test; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + + +/** + * Test class to verify the functionalities of JsonUtils class. + */ +public class JsonUtilsTest { + + /** The Constant JSON_STRING. */ + private static final String JSON_STRING = "{\"EVENT_ID\":\"DongleStatus\",\"Data\":" + + "{\"status\":\"detached\"},\"enabled\":\"true\"}"; + + /** The Constant JSON_STRING_FOR_ARRAY. */ + private static final String JSON_STRING_FOR_ARRAY = "{\"Data\":[\"a\",\"b\",\"c\"]," + + "\"message\":\"String message\",\"message1\":{\"name\":\"IGnite\"}}"; + + /** The Constant EVENT_ID_COLUMN. */ + private static final String EVENT_ID_COLUMN = "EVENT_ID"; + + /** + * Extract column from json string. + */ + @Test + public void testGetValueAsString() { + String eventId = "DongleStatus"; + String eventIdNew = JsonUtils.getValueAsString(EVENT_ID_COLUMN, JSON_STRING); + assertEquals(eventId, eventIdNew); + } + + /** + * Extract column from json string. + */ + @Test + public void testGetValueAsStringFromInvalidJson() { + String eventId = JsonUtils.getValueAsString(EVENT_ID_COLUMN, "{name}"); + assertNull(eventId); + } + + /** + * Extract column from json Node. + */ + @Test + public void testForGetJsonNode() { + String status = "detached"; + JsonNode dataNode = JsonUtils.getJsonNode("Data", JSON_STRING); + assertEquals(status, JsonUtils.safeGetStringFromJsonNode("status", dataNode)); + } + + /** + * Extract column from json Node. + */ + @Test + public void testForGetJsonNodeInvalidJson() { + JsonNode dataNode = JsonUtils.getJsonNode("Data", "{name}"); + assertNull(dataNode); + } + + /** + * Getting json content as map. + */ + @Test + public void testForGetJsonAsMapValidJson() { + Map jsonMap = JsonUtils.getJsonAsMap(JSON_STRING); + assertNotNull(jsonMap); + assertEquals(Constants.THREE, jsonMap.size()); + } + + /** + * Getting json content as map for invalid scenario. + */ + @Test + public void testForGetJsonAsMapInValidJson() { + Map jsonMap = JsonUtils.getJsonAsMap("{invalid}"); + assertEquals(Collections.emptyMap(), jsonMap); + } + + /** + * Getting json content as String for invalid scenario. + * + * @throws JsonParseException JsonParseException + * @throws JsonMappingException JsonMappingException + * @throws IOException IOException + */ + @Test + public void testForSafeGetStringFromJsonNodeInvalidScenario() throws JsonParseException, + JsonMappingException, IOException { + String column = "invalid"; + ObjectMapper om = new ObjectMapper(); + String jsonString = JsonUtils.safeGetStringFromJsonNode(column, om.readValue(JSON_STRING, JsonNode.class)); + assertNull(jsonString); + jsonString = JsonUtils.safeGetStringFromJsonNode(column, null); + assertNull(jsonString); + } + + /** + * Test for safe get boolean from json node. + * + * @throws JsonParseException the json parse exception + * @throws JsonMappingException the json mapping exception + * @throws IOException Signals that an I/O exception has occurred. + */ + @Test + public void testForSafeGetBooleanFromJsonNode() throws JsonParseException, JsonMappingException, IOException { + String column = "enabled"; + JsonNode jsonNode = new ObjectMapper().readValue(JSON_STRING, JsonNode.class); + Boolean enabled = JsonUtils.safeGetBooleanFromJsonNode(column, jsonNode); + assertNotNull(enabled); + assertEquals(true, enabled); + enabled = JsonUtils.safeGetBooleanFromJsonNode(column, null); + assertFalse(enabled); + enabled = JsonUtils.safeGetBooleanFromJsonNode("invaild", jsonNode); + assertFalse(enabled); + } + + /** + * Get object value as byte array. + * + * @throws JsonParseException JsonParseException + * @throws JsonMappingException JsonMappingException + * @throws IOException IOException + */ + @Test + public void testForGetObjectValueAsBytes() throws JsonParseException, JsonMappingException, IOException { + SimpleClass obj = new SimpleClass(); + String resultString = JsonUtils.getObjectValueAsString(obj); + assertEquals(resultString, new String(JsonUtils.getObjectValueAsBytes(obj))); + assertEquals(0, JsonUtils.getObjectValueAsBytes(new JsonUtils()).length); + assertNull(JsonUtils.getObjectValueAsString(new JsonUtils())); + } + + /** + * Get object value as List. + * + * @throws JsonParseException JsonParseException + * @throws JsonMappingException JsonMappingException + * @throws IOException IOException + */ + @Test + public void testForGetValuesAsList() throws JsonParseException, JsonMappingException, IOException { + List result = Arrays.asList("a", "b", "c"); + List resultString = JsonUtils.getValuesAsList(new ObjectMapper() + .readValue(JSON_STRING_FOR_ARRAY, JsonNode.class), "Data"); + assertEquals(result, resultString); + + resultString = JsonUtils.getValuesAsList(new ObjectMapper() + .readValue(JSON_STRING_FOR_ARRAY, JsonNode.class), "message"); + assertEquals(Arrays.asList("String message"), resultString); + // invalid case. column not present in json + resultString = JsonUtils.getValuesAsList(new ObjectMapper().readValue(JSON_STRING_FOR_ARRAY, + JsonNode.class), "message1"); + Assert.assertEquals(0, resultString.size()); + } + + /** + * Test to verify whether a simple object just consist member + * variables and no complex object is able to convert to map or . + */ + @Test + public void testSimpleMapConversion() { + SimpleClass obj = new SimpleClass(); + Map map = JsonUtils.getObjectAsMap(obj); + Assert.assertEquals("testIntVal value not matching", Constants.TEN, map.get("testIntVal")); + Assert.assertEquals("testStringVal value not matching", "test", map.get("testStringVal")); + } + + /** + * Negative scenario for getObjectAsMap. + */ + @Test + public void testSimpleMapConversionForInvalidObject() { + Map map = JsonUtils.getObjectAsMap(new ObjectUtils()); + assertEquals(Collections.emptyMap(), map); + } + + /** + * Testing whether a complex object is able to convert to map or not. + */ + @Test + public void testNestedObjectMapConversion() { + ComplexClass obj = new ComplexClass(); + // the returned object will be map of map + Map map = JsonUtils.getObjectAsMap(obj); + + Assert.assertEquals("complexVal value not matching", "complex", map.get("complexVal")); + + Assert.assertEquals("testIntVal value not matching", Constants.TEN, + ((Map) map.get("simpleClass")).get("testIntVal")); + Assert.assertEquals("testStringVal value not matching", "test", + ((Map) map.get("simpleClass")).get("testStringVal")); + } + + /** + * inner class {@link SimpleClass}. + */ + public class SimpleClass { + + /** The test int val. */ + private int testIntVal; + + /** The test string val. */ + private String testStringVal; + + /** + * Instantiates a new simple class. + */ + public SimpleClass() { + testIntVal = Constants.TEN; + testStringVal = "test"; + } + + /** + * Gets the test int val. + * + * @return the test int val + */ + public int getTestIntVal() { + return testIntVal; + } + + /** + * Sets the test int val. + * + * @param testIntVal the new test int val + */ + public void setTestIntVal(int testIntVal) { + this.testIntVal = testIntVal; + } + + /** + * Gets the test string val. + * + * @return the test string val + */ + public String getTestStringVal() { + return testStringVal; + } + + /** + * Sets the test string val. + * + * @param testStringVal the new test string val + */ + public void setTestStringVal(String testStringVal) { + this.testStringVal = testStringVal; + } + } + + /** + * inner class {@link ComplexClass}. + */ + public class ComplexClass { + + /** The complex val. */ + private String complexVal; + + /** The simple class. */ + private SimpleClass simpleClass; + + /** + * Instantiates a new complex class. + */ + public ComplexClass() { + complexVal = "complex"; + simpleClass = new SimpleClass(); + } + + /** + * Gets the complex val. + * + * @return the complex val + */ + public String getComplexVal() { + return complexVal; + } + + /** + * Sets the complex val. + * + * @param complexVal the new complex val + */ + public void setComplexVal(String complexVal) { + this.complexVal = complexVal; + } + + /** + * Gets the simple class. + * + * @return the simple class + */ + public SimpleClass getSimpleClass() { + return simpleClass; + } + + /** + * Sets the simple class. + * + * @param simpleClass the new simple class + */ + public void setSimpleClass(SimpleClass simpleClass) { + this.simpleClass = simpleClass; + } + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaDispatcherTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaDispatcherTest.java new file mode 100644 index 0000000..828eb4f --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaDispatcherTest.java @@ -0,0 +1,360 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.apache.kafka.clients.producer.Callback; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.eclipse.ecsp.analytics.stream.base.KafkaProducerInstance; +import org.eclipse.ecsp.analytics.stream.base.KafkaSslConfig; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageDispatchers; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.handler.DefaultPostDispatchHandler; +import org.eclipse.ecsp.stream.dma.handler.DeviceMessageHandler; +import org.eclipse.ecsp.transform.IgniteKeyTransformer; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; + +import static org.junit.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + + +/** + * UT class {@link KafkaDispatcherTest}. + */ +public class KafkaDispatcherTest { + + /** The dispatcher. */ + @InjectMocks + KafkaDispatcher dispatcher = new KafkaDispatcher(); + + /** The key transformer. */ + @Mock + IgniteKeyTransformer keyTransformer; + + /** The kafka producer. */ + @Mock + KafkaProducer kafkaProducer; + + /** The post dispatch handler. */ + @Mock + DeviceMessageHandler postDispatchHandler; + + /** The kafka producer instance. */ + @Mock + private KafkaProducerInstance kafkaProducerInstance; + + /** The kafka headers. */ + private Map kafkaHeaders; + + /** The spc. */ + @Mock + private StreamProcessingContext spc; + + /** The kafka ssl config. */ + @Mock + private KafkaSslConfig kafkaSslConfig; + + /** + * setup(). + */ + @Before + public void setup() { + MockitoAnnotations.openMocks(this); + + Properties kafkaConfig = new Properties(); + String kafkaBootstrapServers = "localhost:9092"; + String kafkaSslEnable = "true"; + String keystore = "src/test/resources/kafka.client.keystore.jks"; + String keystorePwd = "password"; + String keyPwd = "password"; + String truststore = "src/test/resources/kafka.client.truststore.jks"; + String truststorePwd = "password"; + String sslClientAuth = "required"; + String maxRequestSize = "1000012"; + String acksConfig = "1"; + String retriesConfig = "2147483647"; + String batchSizeConfig = "16384"; + String lingerMsConfig = "0"; + String bufferMemoryConfig = "33554432"; + String requestTimeoutMsConfig = "30000"; + String deliveryTimeoutMsConfig = "120000"; + String compressionTypeConfig = "none"; + + kafkaConfig.put(PropertyNames.BOOTSTRAP_SERVERS, kafkaBootstrapServers); + kafkaConfig.put(PropertyNames.KAFKA_SSL_ENABLE, kafkaSslEnable); + kafkaConfig.put(PropertyNames.KAFKA_CLIENT_KEYSTORE, keystore); + kafkaConfig.put(PropertyNames.KAFKA_CLIENT_KEYSTORE_PASSWORD, keystorePwd); + kafkaConfig.put(PropertyNames.KAFKA_CLIENT_KEY_PASSWORD, keyPwd); + kafkaConfig.put(PropertyNames.KAFKA_CLIENT_TRUSTSTORE, truststore); + kafkaConfig.put(PropertyNames.KAFKA_CLIENT_TRUSTSTORE_PASSWORD, truststorePwd); + kafkaConfig.put(PropertyNames.KAFKA_SSL_CLIENT_AUTH, sslClientAuth); + kafkaConfig.put(PropertyNames.KAFKA_MAX_REQUEST_SIZE, maxRequestSize); + kafkaConfig.put(PropertyNames.KAFKA_ACKS_CONFIG, acksConfig); + kafkaConfig.put(PropertyNames.KAFKA_RETRIES_CONFIG, retriesConfig); + kafkaConfig.put(PropertyNames.KAFKA_BATCH_SIZE_CONFIG, batchSizeConfig); + kafkaConfig.put(PropertyNames.KAFKA_LINGER_MS_CONFIG, lingerMsConfig); + kafkaConfig.put(PropertyNames.KAFKA_BUFFER_MEMORY_CONFIG, bufferMemoryConfig); + kafkaConfig.put(PropertyNames.KAFKA_REQUEST_TIMEOUT_MS_CONFIG, requestTimeoutMsConfig); + kafkaConfig.put(PropertyNames.KAFKA_DELIVERY_TIMEOUT_MS_CONFIG, deliveryTimeoutMsConfig); + kafkaConfig.put(PropertyNames.KAFKA_COMPRESSION_TYPE_CONFIG, compressionTypeConfig); + + KafkaSslUtils.checkAndApplySslProperties(kafkaConfig); + + ReflectionTestUtils.setField(dispatcher, "kafkaBootstrapServers", kafkaBootstrapServers); + ReflectionTestUtils.setField(dispatcher, "maxRequestSize", maxRequestSize); + ReflectionTestUtils.setField(dispatcher, "acksConfig", acksConfig); + ReflectionTestUtils.setField(dispatcher, "retriesConfig", retriesConfig); + ReflectionTestUtils.setField(dispatcher, "batchSizeConfig", batchSizeConfig); + ReflectionTestUtils.setField(dispatcher, "lingerMsConfig", lingerMsConfig); + ReflectionTestUtils.setField(dispatcher, "bufferMemoryConfig", bufferMemoryConfig); + ReflectionTestUtils.setField(dispatcher, "requestTimeoutMsConfig", requestTimeoutMsConfig); + ReflectionTestUtils.setField(dispatcher, "deliveryTimeoutMsConfig", deliveryTimeoutMsConfig); + ReflectionTestUtils.setField(dispatcher, "compressionTypeConfig", compressionTypeConfig); + ReflectionTestUtils.setField(dispatcher, "kafkaSslConfig", kafkaSslConfig); + + // Mockito can't mock static methods, so initialising explicitly + kafkaProducerInstance.getProducerInstance(kafkaConfig); + + kafkaHeaders = new HashMap<>(); + kafkaHeaders.put("header_key_1", "header_value_1"); + kafkaHeaders.put("header_key_2", "header_value_2"); + kafkaHeaders.put("header_key_3", "header_value_3"); + } + + /** + * Test dispatch. + */ + /* + * Happy flow. When IgniteKey and DeviceMessage are being passed to KafkaDispatcher, + * then the DeviceMessage is getting dispatched to the configured kafka topic. + */ + @Test + public void testDispatch() { + String msg = "message"; + TestEvent igniteEvent = new TestEvent(); + DeviceMessage message = new DeviceMessage(msg.getBytes(), Version.V1_0, + igniteEvent, "topic", Constants.THREAD_SLEEP_TIME_60000); + message.setEvent(igniteEvent); + + Map> brokerToEcuTypesMapping = new HashMap<>(); + Map ecuTypesMap = new HashMap<>(); + ecuTypesMap.put("testecu", "testTopic"); + brokerToEcuTypesMapping.put("kafka", ecuTypesMap); + + ReflectionTestUtils.setField(dispatcher, "brokerToEcuTypesMapping", brokerToEcuTypesMapping); + ReflectionTestUtils.setField(dispatcher, "keyTransformer", keyTransformer); + ReflectionTestUtils.setField(dispatcher, "kafkaProducer", kafkaProducer); + ReflectionTestUtils.setField(dispatcher, "dmaPostDispatchHandler", postDispatchHandler); + + Mockito.when(keyTransformer.toBlob(any(IgniteKey.class))) + .thenReturn(new byte[Constants.BYTE_1024]); + + TestKey igniteKey = new TestKey(); + dispatcher.dispatch(igniteKey, message); + + verify(kafkaProducer, Mockito.times(1)).send(any(ProducerRecord.class), any(Callback.class)); + verify(postDispatchHandler, Mockito.times(1)).handle(igniteKey, message); + } + + /** + * Test init. + */ + @Test + public void testInit() { + ReflectionTestUtils.invokeMethod(dispatcher, "init", new Object[0]); + assertNotNull(ReflectionTestUtils.getField(dispatcher, "kafkaProducer")); + } + + /** + * Test setup. + */ + @Test + public void testSetup() { + ReflectionTestUtils.setField(dispatcher, "brokerToEcuTypesMapping", new HashMap<>()); + assertNotNull(ReflectionTestUtils.getField(dispatcher, "brokerToEcuTypesMapping")); + } + + /** + * Test set next handler. + */ + @Test + public void testSetNextHandler() { + DeviceMessageHandler handler = new DefaultPostDispatchHandler(); + ReflectionTestUtils.setField(dispatcher, "dmaPostDispatchHandler", handler); + DeviceMessageHandler postDispatchHandler = (DeviceMessageHandler) ReflectionTestUtils.getField(dispatcher, + "dmaPostDispatchHandler"); + assertNotNull(postDispatchHandler); + } + + /** + * Test dispatch with null key. + */ + @Test(expected = java.lang.AssertionError.class) + public void testDispatchWithNullKey() { + String msg = "message"; + TestEvent igniteEvent = new TestEvent(); + DeviceMessage message = new DeviceMessage(msg.getBytes(), Version.V1_0, + igniteEvent, "topic", Constants.THREAD_SLEEP_TIME_60000); + message.setEvent(igniteEvent); + + dispatcher.dispatch(null, message); + Assert.fail("Key is NULL. Not dispatching the data to Kafka"); + } + + /** + * Test dispatch with null value. + */ + @Test(expected = java.lang.AssertionError.class) + public void testDispatchWithNullValue() { + IgniteKey key = new TestKey(); + dispatcher.dispatch(key, null); + Assert.fail("Value is NULL. Not dispatching the data to Kafka"); + } + + /** + * test for setup(). + */ + @Test + public void testSetupMethod() { + Map brokerToEcuTypesMapping = new HashMap>(); + brokerToEcuTypesMapping.put(DeviceMessageDispatchers.KAFKA, new HashMap<>()); + dispatcher.setup(brokerToEcuTypesMapping, spc); + verify(kafkaProducerInstance, Mockito.times(1)).getProducerInstance(any()); + } + + /** + * inner class TestKey implements IgniteKey. + */ + public class TestKey implements IgniteKey { + + /** + * getKey(). + * + * @return String + */ + @Override + public String getKey() { + return "vin123"; + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return "vin123"; + } + } + + /** + * class TestEvent extends IgniteEventImpl. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Sample"; + } + + /** + * Gets the ecu type. + * + * @return the ecu type + */ + @Override + public String getEcuType() { + return "testecu"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("test"); + } + + /** + * Gets the kafka headers. + * + * @return the kafka headers + */ + @Override + public Map getKafkaHeaders() { + return kafkaHeaders; + } + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaStreamsApplicationTestBase.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaStreamsApplicationTestBase.java new file mode 100644 index 0000000..884c0a4 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/KafkaStreamsApplicationTestBase.java @@ -0,0 +1,587 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import io.prometheus.client.exporter.HTTPServer; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.Producer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.apache.kafka.common.errors.TopicExistsException; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.common.utils.Utils; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.StreamsConfig; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.kafka.SingleNodeKafkaCluster; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.junit.ClassRule; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeoutException; +import java.util.function.Function; + + + +/** + * A convenient base class for integration testing kafka streams service applications. + * The key things to do when subclassing this are a) invoke super.setup() + * in subclass setup() b) invoke super.launchApplication() in the + * test case methods to start the stream processing application. + * Check the KafkaStreamsLauncherTest class for concrete examples + * + * @author ssasidharan + */ +public class KafkaStreamsApplicationTestBase { + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant MQTT_SERVER. */ + @ClassRule + public static final EmbeddedMQTTServer MQTT_SERVER = new EmbeddedMQTTServer(); + + /** The Constant REDIS_SERVER. */ + @ClassRule + public static final EmbeddedRedisServer REDIS_SERVER = new EmbeddedRedisServer(); + // RTC-155383 - Running Kafka and Zookeeper on dynamic ports to resolve bind + /** The Constant KAFKA_CLUSTER. */ + // address issue in streambase project + @ClassRule + public static final SingleNodeKafkaCluster KAFKA_CLUSTER = new SingleNodeKafkaCluster(); + + /** The Constant UNLIMITED_MESSAGES. */ + private static final int UNLIMITED_MESSAGES = -1; + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(KafkaStreamsApplicationTestBase.class); + + /** The ctx. */ + @Autowired + protected ApplicationContext ctx; + + /** The ks props. */ + protected Properties ksProps; + + /** The consumer props. */ + protected Properties consumerProps; + + /** The producer props. */ + protected Properties producerProps; + + /** The mqtt messages. */ + private Map> mqttMessages = new HashMap<>(); + + /** The prometheus export server. */ + private HTTPServer prometheusExportServer; + + /** The launcher. */ + private Launcher launcher; + + /** The enable prometheus. */ + private boolean enablePrometheus; + + /** + * Returns up to `maxMessages` message-values from the topic. + * + * @param the key type + * @param the value type + * @param topic Kafka topic to read messages from + * @param consumerConfig Kafka consumer configuration + * @param maxMessages Maximum number of messages to read via the consumer. + * @return The values retrieved via the consumer. + */ + public static List readValues(String topic, Properties consumerConfig, int maxMessages) { + List returnList = new ArrayList<>(); + List> kvs = readKeyValues(topic, consumerConfig, maxMessages); + for (KeyValue kv : kvs) { + returnList.add(kv.value); + } + return returnList; + } + + /** + * Returns as many messages as possible from the topic until a (currently hardcoded) timeout is reached. + * + * @param the key type + * @param the value type + * @param topic Kafka topic to read messages from + * @param consumerConfig Kafka consumer configuration + * @return The KeyValue elements retrieved via the consumer. + */ + public static List> readKeyValues(String topic, Properties consumerConfig) { + return readKeyValues(topic, consumerConfig, UNLIMITED_MESSAGES); + } + + /** + * Returns up to `maxMessages` by reading via the provided consumer + * (the topic(s) to read from are already configured in the consumer). + * + * @param the key type + * @param the value type + * @param topic Kafka topic to read messages from + * @param consumerConfig Kafka consumer configuration + * @param maxMessages Maximum number of messages to read via the consumer + * @return The KeyValue elements retrieved via the consumer + */ + public static List> readKeyValues(String topic, Properties consumerConfig, int maxMessages) { + KafkaConsumer consumer = new KafkaConsumer<>(consumerConfig); + List> consumedValues = new ArrayList<>(); + try { + consumer.subscribe(Collections.singletonList(topic)); + int pollIntervalMs = Constants.THREAD_SLEEP_TIME_100; + int maxTotalPollTimeMs = Constants.THREAD_SLEEP_TIME_2000; + int totalPollTimeMs = 0; + while (totalPollTimeMs < maxTotalPollTimeMs && continueConsuming(consumedValues.size(), maxMessages)) { + totalPollTimeMs += pollIntervalMs; + ConsumerRecords records = consumer.poll(pollIntervalMs); + for (ConsumerRecord record : records) { + consumedValues.add(new KeyValue<>(record.key(), record.value())); + } + } + } finally { + consumer.close(); + } + return consumedValues; + } + + /** + * Continue consuming. + * + * @param messagesConsumed the messages consumed + * @param maxMessages the max messages + * @return true, if successful + */ + private static boolean continueConsuming(int messagesConsumed, int maxMessages) { + return maxMessages <= 0 || messagesConsumed < maxMessages; + } + + /** + * This launches the kafka streams application in an existing Spring context. + * Spring context has to be loaded by the subclass using. + * + * @throws Exception the exception + * @RunWith or @ExtendWith. + */ + protected void launchApplication() throws Exception { + launcher = ctx.getBean(Launcher.class); + launcher.setExecuteShutdownHook(false); + /* + * if (enablePrometheus) { prometheusExportServer = new HTTPServer(1234, true); + * } + */ + launcher.launch(); + } + + /** + * This shuts down the kafka streams application in an existing Spring context. + */ + protected void shutDownApplication() { + logger.info("Shutting down kafka streams with timeout of 30 seconds"); + launcher.closeStreamWithTimeout(); + if (null != prometheusExportServer) { + prometheusExportServer.stop(); + } + } + + /** + * Shut down. + */ + protected void shutDown() { + logger.info("Shutting down kafka streams with timeout of 30 seconds"); + launcher.closeStreamWithTimeout(); + } + + /** + * Creates topics and ensures they are created by deleting if need be before creating. + * + * @param topics topics + * @throws InterruptedException the interrupted exception + */ + protected void createTopics(String... topics) throws InterruptedException { + for (String topic : topics) { + while (true) { + logger.info("Deleting topic {}", topic); + try { + KAFKA_CLUSTER.deleteTopic(topic); + } catch (Exception e) { + logger.info("Exception: {}", e); + } + Thread.sleep(Constants.THREAD_SLEEP_TIME_100); + logger.info("Creating topic {}", topic); + try { + KAFKA_CLUSTER.createTopic(topic); + } catch (TopicExistsException tee) { + logger.error("Creating topic {} failed. Will delete and try again", topic); + continue; + } catch (Exception e) { + logger.error("Exception occurred - ", e); + } + break; + } + } + } + + /** + * Subclasses should invoke this method in their @Before. And use the + * ksProps, consumerProps and producerProps inherited from this + * base. + * + * @throws Exception Exception + */ + public void setup() throws Exception { + Thread.sleep(Constants.THREAD_SLEEP_TIME_2000); + ksProps = new Properties(); + ksProps.put(PropertyNames.LAUNCHER_IMPL, "org.eclipse.ecsp.analytics.stream.base.KafkaStreamsLauncher"); + ksProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + ksProps.put(PropertyNames.BOOTSTRAP_SERVERS, KAFKA_CLUSTER.bootstrapServers()); + ksProps.put(PropertyNames.ZOOKEEPER_CONNECT, KAFKA_CLUSTER.zkconnectstring()); + ksProps.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, + Serdes.ByteArray().getClass().getName()); + ksProps.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, + Serdes.ByteArray().getClass().getName()); + ksProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().deserializer().getClass().getName()); + ksProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().deserializer().getClass().getName()); + ksProps.put(PropertyNames.NUM_STREAM_THREADS, "1"); + ksProps.put(PropertyNames.REPLICATION_FACTOR, "1"); + ksProps.put(PropertyNames.SHARED_TOPICS, "Master-Data-Topic, Config-Data-Topic"); + ksProps.put(StreamsConfig.STATE_DIR_CONFIG, "/tmp/kafka-streams"); + ksProps.put(PropertyNames.KAFKA_SSL_ENABLE, "false"); + Launcher.setDynamicProps(ksProps); + consumerProps = new Properties(); + consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + consumerProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + producerProps = new Properties(); + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + producerProps.put(ProducerConfig.ACKS_CONFIG, "all"); + producerProps.put(ProducerConfig.RETRIES_CONFIG, 0); + KafkaTestUtils.purgeLocalStreamsState(ksProps); + } + + /** + * Removes local state stores. Useful to reset state in-between integration test runs. + * + * @param streamsConfiguration Streams configuration settings + * @throws IOException Signals that an I/O exception has occurred. + */ + protected void purgeLocalStreamsState(Properties streamsConfiguration) throws IOException { + String path = streamsConfiguration.getProperty(StreamsConfig.STATE_DIR_CONFIG); + if (path != null) { + File node = Paths.get(path).normalize().toFile(); + // Only purge state when it's under /tmp. This is a safety net to + // prevent accidentally + // deleting important local directory trees. + if (node.getAbsolutePath().startsWith("/tmp")) { + Utils.delete(new File(node.getAbsolutePath())); + } + } + } + + /** + * produceKeyValuesSynchronously(). + * + * @param Key type of the data records + * @param Value type of the data records + * @param topic Kafka topic to write the data records to + * @param records Data records to write to Kafka + * @param producerConfig Kafka producer configuration + * @throws ExecutionException the execution exception + * @throws InterruptedException the interrupted exception + */ + protected void produceKeyValuesSynchronously( + String topic, Collection> records, Properties producerConfig) + throws ExecutionException, InterruptedException { + Producer producer = new KafkaProducer<>(producerConfig); + try { + for (KeyValue record : records) { + Future f = producer.send( + new ProducerRecord<>(topic, record.key, record.value)); + f.get(); + } + producer.flush(); + } finally { + producer.close(); + } + } + + /** + * Produce values synchronously. + * + * @param the value type + * @param topic the topic + * @param records the records + * @param producerConfig the producer config + * @throws ExecutionException the execution exception + * @throws InterruptedException the interrupted exception + */ + protected void produceValuesSynchronously( + String topic, Collection records, Properties producerConfig) + throws ExecutionException, InterruptedException { + Collection> keyedRecords = new ArrayList<>(); + for (V value : records) { + KeyValue kv = new KeyValue<>(null, value); + keyedRecords.add(kv); + } + produceKeyValuesSynchronously(topic, keyedRecords, producerConfig); + } + + /** + * Read messages. + * + * @param topic the topic + * @param consumerProps the consumer props + * @param i the i + * @return the list + * @throws TimeoutException the timeout exception + */ + protected List readMessages(String topic, Properties consumerProps, int i) throws TimeoutException { + return KafkaTestUtils.readKeyValues(topic, consumerProps, i).stream() + .map(t -> new String[] { (String) t.key, (String) t.value }) + .toList(); + } + + /** + * Send messages. + * + * @param topic the topic + * @param producerProps the producer props + * @param strings the strings + * @throws ExecutionException the execution exception + * @throws InterruptedException the interrupted exception + */ + protected void sendMessages(String topic, Properties producerProps, String... strings) + throws ExecutionException, InterruptedException { + Collection> kvs = new ArrayList<>(); + for (int i = 1; i <= strings.length; i++) { + if (i % Constants.TWO == 0) { + kvs.add(new KeyValue(strings[i - Constants.TWO], strings[i - 1])); + } + } + KafkaTestUtils.produceKeyValuesSynchronously(topic, kvs, producerProps); + } + + /** + * Send messages. + * + * @param topic the topic + * @param producerProps the producer props + * @param bytes the bytes + * @throws ExecutionException the execution exception + * @throws InterruptedException the interrupted exception + */ + protected void sendMessages(String topic, Properties producerProps, List bytes) + throws ExecutionException, InterruptedException { + Collection> kvs = new ArrayList<>(); + for (int i = 1; i <= bytes.size(); i++) { + if (i % Constants.TWO == 0) { + kvs.add(new KeyValue(bytes.get(i - Constants.TWO), bytes.get(i - 1))); + } + } + KafkaTestUtils.produceKeyValuesSynchronously(topic, kvs, producerProps); + } + + /** + * Gets the messages. + * + * @param topic the topic + * @param consumerProps the consumer props + * @param n the n + * @param waitTime the wait time + * @return the messages + * @throws TimeoutException the timeout exception + * @throws InterruptedException the interrupted exception + */ + protected List getMessages(String topic, Properties consumerProps, int n, int waitTime) + throws TimeoutException, InterruptedException { + int timeWaited = 0; + int increment = Constants.THREAD_SLEEP_TIME_2000; + List messages = new ArrayList<>(); + while ((messages.size() < n) && (timeWaited <= waitTime)) { + messages.addAll(KafkaTestUtils.readMessages(topic, consumerProps, n)); + Thread.sleep(increment); + timeWaited = timeWaited + increment; + } + return messages; + } + + /** + * Subscibe to mqtt topic. + * + * @param topic the topic + * @throws MqttException the mqtt exception + */ + protected void subscibeToMqttTopic(String topic) throws MqttException { + MqttCallback callback = new MqttCallback() { + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + logger.info("Received message on topic {}", topic); + if (mqttMessages.containsKey(topic)) { + mqttMessages.get(topic).add(message.getPayload()); + } else { + List messages = new ArrayList<>(); + messages.add(message.getPayload()); + mqttMessages.put(topic, messages); + } + + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + // Nothing to do + + } + + @Override + public void connectionLost(Throwable cause) { + // Nothing to do + + } + }; + MQTT_SERVER.subscribeToTopic(topic, callback); + } + + /** + * Gets the messages from mqtt topic. + * + * @param topic the topic + * @param n the n + * @param waitTime the wait time + * @return the messages from mqtt topic + * @throws TimeoutException the timeout exception + * @throws InterruptedException the interrupted exception + */ + protected List getMessagesFromMqttTopic(String topic, int n, int waitTime) + throws TimeoutException, InterruptedException { + int timeWaited = 0; + int increment = Constants.THREAD_SLEEP_TIME_2000; + List messages = new ArrayList<>(); + while ((messages.size() < n) && (timeWaited <= waitTime)) { + List payloads = mqttMessages.get(topic); + if (null != payloads) { + messages.addAll(payloads); + } + Thread.sleep(increment); + timeWaited = timeWaited + increment; + } + return messages; + } + + /** + * Publish message to mqtt topic. + * + * @param topic the topic + * @param payload the payload + * @throws MqttException the mqtt exception + */ + protected void publishMessageToMqttTopic(String topic, byte[] payload) throws MqttException { + MQTT_SERVER.publishToTopic(topic, payload); + } + + /** + * Retries a function call and returns result or throws exception if + * function didn't return a non-null response for all attempts. Retry + * interval is 250ms. + * + * @param the generic type + * @param n - number of retries to attempt + * @param f - function that should return a result if it is successful + * @return result from function + */ + protected R retryWithException(int n, Function f) { + return RetryUtils.retryWithException(n, f); + } + + /** + * Gets the key value records. + * + * @param the key type + * @param the value type + * @param topic the topic + * @param consumerProps the consumer props + * @param n the n + * @param waitTime the wait time + * @return the key value records + * @throws InterruptedException the interrupted exception + */ + protected List> getKeyValueRecords(String topic, + Properties consumerProps, int n, int waitTime) + throws InterruptedException { + int timeWaited = 0; + int increment = Constants.THREAD_SLEEP_TIME_2000; + List> messages = new ArrayList<>(); + while ((messages.size() < n) && (timeWaited <= waitTime)) { + List> currentList = KafkaTestUtils.readKeyValues(topic, consumerProps, n); + messages.addAll(currentList); + Thread.sleep(increment); + timeWaited = timeWaited + increment; + } + return messages; + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherHealthMontiorIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherHealthMontiorIntegrationTest.java new file mode 100644 index 0000000..d726caf --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherHealthMontiorIntegrationTest.java @@ -0,0 +1,246 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.apache.commons.lang3.StringUtils; +import org.apache.kafka.test.TestUtils; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.io.IOException; +import java.util.Optional; + + +/** + * class {@link MqttDispatcherHealthMontiorIntegrationTest}: UT class. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/mqtt-health-monitor.properties") +public class MqttDispatcherHealthMontiorIntegrationTest { + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS_SERVER. */ + @ClassRule + public static final EmbeddedRedisServer REDIS_SERVER = new EmbeddedRedisServer(); + + /** The Constant LOGGER. */ + private static final IgniteLogger LOGGER = IgniteLoggerFactory + .getLogger(MqttDispatcherHealthMontiorIntegrationTest.class); + + /** The paho mqtt dispatcher. */ + @Autowired + PahoMqttDispatcher pahoMqttDispatcher; + + /** The default mqtt topic name generator impl. */ + @Autowired + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The mqtt dispatcher. */ + @Autowired + MqttDispatcher mqttDispatcher; + + /** The client. */ + MqttClient client; + + /** The msg received. */ + boolean msgReceived = false; + + /** The mqtt topic. */ + private String mqttTopic = StringUtils.EMPTY; + + /** The forced check value. */ + private DeviceMessage forcedCheckValue; + + /** The forced check key. */ + private IgniteStringKey forcedCheckKey; + + /** + * Setup class. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + @BeforeClass + public static void setupClass() throws IOException { + TestUtils.startMqttServer(); + } + + /** + * Teardown class. + * + * @throws IOException Signals that an I/O exception has occurred. + */ + @AfterClass + public static void teardownClass() throws IOException { + TestUtils.startMqttServer(); + } + + /** + * setup(). + */ + @Before + public void setup() { + + forcedCheckKey = new IgniteStringKey(); + forcedCheckKey.setKey(Constants.FORCED_HEALTH_CHECK_DEVICE_ID); + forcedCheckValue = new DeviceMessage(); + forcedCheckValue.setMessage("forcedHealthCheckDummyMsg".getBytes()); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId(Constants.FORCED_HEALTH_CHECK_DEVICE_ID); + forcedCheckValue.setDeviceMessageHeader(header); + defaultMqttTopicNameGeneratorImpl.setTopicNamePrefix("haa/harman/dev/"); + + } + + /** + * Test mqtt health monitor integration. + * + * @throws MqttException the mqtt exception + * @throws InterruptedException the interrupted exception + */ + @Test + public void testMqttHealthMonitorIntegration() throws MqttException, InterruptedException { + MqttClient client = pahoMqttDispatcher.getMqttClient(PropertyNames.DEFAULT_PLATFORMID).get(); + String mqttTopicToSubscribe = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(forcedCheckKey, + forcedCheckValue.getDeviceMessageHeader(), null).get(); + client.subscribe(mqttTopicToSubscribe); + client.setCallback(new MqttCallback() { + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + LOGGER.error("Msg received:{} on topic:{}", message, topic); + msgReceived = true; + mqttTopic = topic; + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + + } + + @Override + public void connectionLost(Throwable cause) { + + } + }); + + Assert.assertEquals(true, mqttDispatcher.isHealthy(false)); + RetryUtils.retry(Constants.TWENTY, (v) -> { + return mqttTopic.length() > 0 ? Boolean.TRUE : null; + }); + Assert.assertEquals(false, msgReceived); + Assert.assertEquals("", mqttTopic); + Assert.assertEquals(true, mqttDispatcher.isHealthy(true)); + RetryUtils.retry(Constants.TWENTY, (v) -> { + return mqttTopic.length() > 0 ? Boolean.TRUE : null; + }); + + Assert.assertEquals(true, mqttDispatcher.isHealthy(false)); + Assert.assertEquals("haa/harman/dev/testDevice123/2d/test", mqttTopic); + Assert.assertEquals(true, msgReceived); + + } + + /** + * inner class TestEvent extends IgniteEventImpl. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "testHealthMonitorService"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("testDevice123"); + } + + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherHealthMontiorMultipleDispatcherIntegrationTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherHealthMontiorMultipleDispatcherIntegrationTest.java new file mode 100644 index 0000000..76c5ff9 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherHealthMontiorMultipleDispatcherIntegrationTest.java @@ -0,0 +1,111 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.MqttDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.MqttHealthMonitor; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + + +/** + * class MqttDispatcherHealthMontiorMultipleDispatcherIntegrationTest extends KafkaStreamsApplicationTestBase. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/mqtt-health-monitor.properties") +public class MqttDispatcherHealthMontiorMultipleDispatcherIntegrationTest extends KafkaStreamsApplicationTestBase { + + /** The monitor. */ + @Autowired + MqttHealthMonitor monitor; + + /** The Mqtt dispatcher one. */ + @Autowired + MqttDispatcher MqttDispatcherOne; + + /** The Mqtt dispatcher two. */ + @Autowired + MqttDispatcher MqttDispatcherTwo; + + /** + * Setup. + * + * @throws Exception the exception + */ + @Before + public void setup() throws Exception { + super.setup(); + } + + /** + * Test mqtt health monitor integration. + */ + @Test + public void testMqttHealthMonitorIntegration() { + + List dispatchers = monitor.getDispatchers(); + Assert.assertEquals(Constants.THREE, monitor.getDispatchers().size()); + MqttDispatcher mqttDispatcherOne = dispatchers.get(0); + MqttDispatcher mqttDispatcherTwo = dispatchers.get(1); + if (mqttDispatcherOne.isHealthy(false) && mqttDispatcherTwo.isHealthy(false)) { + Assert.assertEquals(true, monitor.isHealthy(false)); + } else { + Assert.assertEquals(false, monitor.isHealthy(false)); + } + ReflectionTestUtils.setField(MqttDispatcherOne, "healthy", false); + Assert.assertEquals(false, monitor.isHealthy(false)); + Assert.assertEquals(true, monitor.isHealthy(true)); + Assert.assertEquals(true, ReflectionTestUtils.getField(MqttDispatcherOne, "healthy")); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherHealthMontiorTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherHealthMontiorTest.java new file mode 100644 index 0000000..72b5f1a --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherHealthMontiorTest.java @@ -0,0 +1,264 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.platform.MqttTopicNameGenerator; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + + + +/** + * class {@link MqttDispatcherTestHealthMontior}. + */ + +public class MqttDispatcherHealthMontiorTest { + + /** The mqtt health monitor. */ + @Spy + private MqttHealthMonitor mqttHealthMonitor; + + /** The mqtt dispatcher one. */ + @Spy + private PahoMqttDispatcher mqttDispatcherOne; + + /** The mqtt dispatcher two. */ + @Spy + private PahoMqttDispatcher mqttDispatcherTwo; + + /** The mqtt client one. */ + @Mock + private MqttClient mqttClientOne; + + /** The mqtt client two. */ + @Mock + private MqttClient mqttClientTwo; + + /** The mqtt client map one. */ + @Mock + Map mqttClientMapOne; + + /** The mqtt client map two. */ + @Mock + Map mqttClientMapTwo; + + /** The mqtt platform config map. */ + Map mqttPlatformConfigMap; + + /** The forced check key. */ + private IgniteStringKey forcedCheckKey; + + /** The forced check value. */ + private DeviceMessage forcedCheckValue; + + /** The name generator. */ + @Mock + private MqttTopicNameGenerator nameGenerator; + + /** + * setUp(). + * + * @throws MqttException MqttException + */ + @Before + public void setUp() throws MqttException { + + MockitoAnnotations.initMocks(this); + + + + Mockito.when(mqttClientMapOne.get(PropertyNames.DEFAULT_PLATFORMID)).thenReturn(mqttClientOne); + Mockito.when(mqttClientMapTwo.get(PropertyNames.DEFAULT_PLATFORMID)).thenReturn(mqttClientTwo); + ReflectionTestUtils.setField(mqttDispatcherOne, "mqttClientMap", mqttClientMapOne); + ReflectionTestUtils.setField(mqttDispatcherTwo, "mqttClientMap", mqttClientMapTwo); + + mqttPlatformConfigMap = new HashMap<>(); + MqttConfig config = new MqttConfig(); + config.setMqttQosValue(0); + mqttPlatformConfigMap.put(PropertyNames.DEFAULT_PLATFORMID, config); + ReflectionTestUtils.setField(mqttDispatcherOne, "mqttPlatformConfigMap", mqttPlatformConfigMap); + ReflectionTestUtils.setField(mqttDispatcherTwo, "mqttPlatformConfigMap", mqttPlatformConfigMap); + + ReflectionTestUtils.setField(mqttHealthMonitor, "mqttHealthMonitorEnabled", true); + ReflectionTestUtils.setField(mqttDispatcherOne, "retryCount", 1); + ReflectionTestUtils.setField(mqttDispatcherTwo, "retryCount", 1); + ReflectionTestUtils.setField(mqttHealthMonitor, "dispatchers", + Arrays.asList(mqttDispatcherOne, mqttDispatcherTwo)); + forcedCheckKey = new IgniteStringKey(); + forcedCheckKey.setKey(Constants.FORCED_HEALTH_CHECK_DEVICE_ID); + ReflectionTestUtils.setField(mqttDispatcherOne, "forcedCheckKey", forcedCheckKey); + ReflectionTestUtils.setField(mqttDispatcherTwo, "forcedCheckKey", forcedCheckKey); + forcedCheckValue = new DeviceMessage(); + IgniteEventImpl event = new IgniteEventImpl(); + event.setPlatformId(PropertyNames.DEFAULT_PLATFORMID); + forcedCheckValue.setMessage("forcedHealthCheckDummyMsg".getBytes()); + forcedCheckValue.setEvent(event); + DeviceMessageHeader header = new DeviceMessageHeader(); + header.withTargetDeviceId(Constants.FORCED_HEALTH_CHECK_DEVICE_ID); + forcedCheckValue.setDeviceMessageHeader(header); + ReflectionTestUtils.setField(mqttDispatcherOne, "forcedCheckValue", forcedCheckValue); + ReflectionTestUtils.setField(mqttDispatcherTwo, "forcedCheckValue", forcedCheckValue); + Mockito.doReturn(true).when(mqttClientOne).isConnected(); + Mockito.doReturn(true).when(mqttClientTwo).isConnected(); + Mockito.doReturn(Optional.of(mqttClientOne)).when(mqttDispatcherOne) + .getMqttClient(PropertyNames.DEFAULT_PLATFORMID); + Mockito.doReturn(Optional.of(mqttClientTwo)).when(mqttDispatcherTwo) + .getMqttClient(PropertyNames.DEFAULT_PLATFORMID); + + } + + /** + * Test is healthy. + */ + @Test + public void testIsHealthy() { + + ReflectionTestUtils.setField(mqttDispatcherOne, "healthy", false); + ReflectionTestUtils.setField(mqttDispatcherTwo, "mqttTopicNameGenerator", nameGenerator); + ReflectionTestUtils.setField(mqttDispatcherOne, "mqttTopicNameGenerator", nameGenerator); + Mockito.when(mqttClientMapOne.get(PropertyNames.DEFAULT_PLATFORMID)).thenReturn(null); + ReflectionTestUtils.setField(mqttDispatcherOne, "mqttClientMap", mqttClientMapOne); + mqttDispatcherOne.setMqttClientMap(new HashMap<>()); + ReflectionTestUtils.setField(mqttDispatcherTwo, "healthy", true); + + Assert.assertEquals(false, mqttDispatcherOne.isHealthy(false)); + Assert.assertEquals(true, mqttDispatcherTwo.isHealthy(false)); + Assert.assertEquals(false, mqttHealthMonitor.isHealthy(false)); + Mockito.verify(mqttDispatcherOne, + Mockito.times(0)).dispatch(forcedCheckKey, forcedCheckValue); + Mockito.verify(mqttDispatcherTwo, + Mockito.times(0)).dispatch(forcedCheckKey, forcedCheckValue); + + Assert.assertEquals(true, mqttHealthMonitor.isHealthy(true)); + Mockito.verify(mqttDispatcherOne, + Mockito.times(1)).dispatch(forcedCheckKey, forcedCheckValue); + Mockito.verify(mqttDispatcherTwo, + Mockito.times(1)).dispatch(forcedCheckKey, forcedCheckValue); + Assert.assertEquals(true, mqttDispatcherOne.isHealthy(false)); + Assert.assertEquals(true, mqttDispatcherTwo.isHealthy(false)); + + ReflectionTestUtils.setField(mqttDispatcherTwo, "healthy", false); + ReflectionTestUtils.setField(mqttDispatcherTwo, "mqttClientMap", new HashMap<>()); + Assert.assertEquals(true, mqttDispatcherOne.isHealthy(false)); + Assert.assertEquals(false, mqttDispatcherTwo.isHealthy(false)); + Assert.assertEquals(false, mqttHealthMonitor.isHealthy(false)); + + Assert.assertEquals(true, mqttHealthMonitor.isHealthy(true)); + Assert.assertEquals(true, mqttDispatcherOne.isHealthy(false)); + Assert.assertEquals(true, mqttDispatcherTwo.isHealthy(false)); + + } + + /** + * Test monitor name. + */ + @Test + public void testMonitorName() { + Assert.assertEquals("MQTT_HEALTH_MONITOR", mqttHealthMonitor.monitorName()); + } + + /** + * Test metric name. + */ + @Test + public void testMetricName() { + String metricName = mqttHealthMonitor.metricName(); + Assert.assertEquals("MQTT_HEALTH_GUAGE", metricName); + } + + /** + * Test is enabled. + */ + @Test + public void testIsEnabled() { + boolean isEnabled = mqttHealthMonitor.isEnabled(); + Assert.assertEquals(true, isEnabled); + + } + + /** + * Test needs restart on failure. + */ + @Test + public void testNeedsRestartOnFailure() { + ReflectionTestUtils.setField(mqttHealthMonitor, "mqttRestartOnFailure", true); + Assert.assertEquals(true, mqttHealthMonitor.needsRestartOnFailure()); + } + + /** + * Test initialize forced health check event. + */ + @Test + public void testInitializeForcedHealthCheckEvent() { + mqttDispatcherOne.initializeForcedHealthCheckEvent(); + IgniteStringKey igniteStringKey = (IgniteStringKey) ReflectionTestUtils.getField(mqttDispatcherOne, + "forcedCheckKey"); + Assert.assertEquals(Constants.FORCED_HEALTH_CHECK_DEVICE_ID, igniteStringKey.getKey()); + DeviceMessage forcedCheckValue = (DeviceMessage) ReflectionTestUtils.getField(mqttDispatcherOne, + "forcedCheckValue"); + Assert.assertEquals(Constants.FORCED_HEALTH_CHECK_DEVICE_ID, + forcedCheckValue.getDeviceMessageHeader().getTargetDeviceId()); + Assert.assertEquals(Constants.FORCED_HEALTH_DEFAULT_TEST_TOPIC_NAME, + forcedCheckValue.getDeviceMessageHeader().getDevMsgTopicSuffix()); + Assert.assertEquals(Constants.FORCED_HEALTH_DEFAULT_TEST_TOPIC_NAME, + forcedCheckValue.getEvent().getDevMsgTopicSuffix().get()); + Assert.assertEquals(Constants.FORCED_HEALTH_CHECK_DEVICE_ID, + forcedCheckValue.getEvent().getTargetDeviceId().get()); + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherTest.java new file mode 100644 index 0000000..62a0fcb --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/MqttDispatcherTest.java @@ -0,0 +1,451 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.platform.MqttTopicNameGenerator; +import org.eclipse.ecsp.domain.BlobDataV1_0; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteBlobEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.serializer.IngestionSerializer; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; + + + +/** + * class {@link MqttDispatcherTest}: UT class for {@link MqttDispatcher}. + */ +public class MqttDispatcherTest { + + /** The mockito rule. */ + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + /** The paho mqtt dispatcher. */ + @InjectMocks + PahoMqttDispatcher pahoMqttDispatcher; + + /** The mqtt dispatcher. */ + @InjectMocks + private MqttDispatcher mqttDispatcher = new PahoMqttDispatcher(); + + /** The client. */ + @Mock + private MqttClient client; + + /** The transformer. */ + @Mock + private IngestionSerializer transformer; + + /** The mqtt topic name generator. */ + @Mock + private MqttTopicNameGenerator mqttTopicNameGenerator; + + /** The mqtt client map. */ + Map mqttClientMap; + + /** The mqtt platform config map. */ + Map mqttPlatformConfigMap; + + /** + * Setup for this test case. + */ + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mqttClientMap = new HashMap<>(); + mqttClientMap.put(PropertyNames.DEFAULT_PLATFORMID, client); + ReflectionTestUtils.setField(mqttDispatcher, "mqttClientMap", mqttClientMap); + + mqttPlatformConfigMap = new HashMap<>(); + MqttConfig config = new MqttConfig(); + config.setMqttQosValue(0); + mqttPlatformConfigMap.put(PropertyNames.DEFAULT_PLATFORMID, config); + ReflectionTestUtils.setField(mqttDispatcher, "mqttPlatformConfigMap", mqttPlatformConfigMap); + + Mockito.when(mqttTopicNameGenerator.getMqttTopicName(any(), any(), any())).thenReturn(Optional.of("topic")); + } + + /** + * Test wrapevent frequency validation. + */ + @Test(expected = IllegalArgumentException.class) + public void testWrapeventFrequencyValidation() { + mqttDispatcher.setEventWrapFrequency(0); + mqttDispatcher.validateEventWrapFrequency(); + } + + /** + * Test wrapevent. + */ + /* + * Happy flow where wrap event is set to true and frequency is 1 so all + * events will be wrapped. + */ + @Test + public void testWrapevent() { + String input = "input"; + String transformed = "transformed"; + DeviceMessage entity = new DeviceMessage(input.getBytes(), Version.V1_0, + new TestEvent(), "topic", Constants.THREAD_SLEEP_TIME_60000); + entity.getDeviceMessageHeader().withDevMsgGlobalTopic("globaltopic"); + entity.getDeviceMessageHeader().withTargetDeviceId("td"); + entity.getDeviceMessageHeader().withVehicleId("vd"); + entity.getDeviceMessageHeader().withRequestId("rd"); + Mockito.when(client.isConnected()).thenReturn(true); + Mockito.when(transformer.serialize(Mockito.any())).thenReturn(transformed.getBytes()); + mqttClientMap.put(PropertyNames.DEFAULT_PLATFORMID, client); + pahoMqttDispatcher.setMqttClientMap(mqttClientMap); + mqttDispatcher.setEventWrapFrequency(1); + mqttDispatcher.setWrapDispatchEvent(true); + mqttDispatcher.setTransformer(transformer); + mqttDispatcher.dispatch(new TestKey(), entity); + + Mockito.verify(transformer, Mockito.times(1)).serialize(Mockito.any()); + + ArgumentCaptor keyArgument = ArgumentCaptor.forClass(IgniteBlobEvent.class); + Mockito.verify(transformer).serialize(keyArgument.capture()); + IgniteBlobEvent actualKey = keyArgument.getValue(); + BlobDataV1_0 blobDataV10 = (BlobDataV1_0) actualKey.getEventData(); + Assert.assertEquals(new String(blobDataV10.getPayload()), new String(input.getBytes())); + + } + + /** + * Test wrapevent false. + */ + /* + * Here the flaf wrap event is set to false hence event should not be + * wrapped + */ + @Test + public void testWrapeventFalse() { + String input = "input"; + String transformed = "transformed"; + DeviceMessage entity = new DeviceMessage(input.getBytes(), Version.V1_0, + new TestEvent(), "topic", Constants.THREAD_SLEEP_TIME_60000); + entity.getDeviceMessageHeader().withDevMsgGlobalTopic("globaltopic"); + entity.getDeviceMessageHeader().withTargetDeviceId("td"); + entity.getDeviceMessageHeader().withVehicleId("vd"); + entity.getDeviceMessageHeader().withRequestId("rd"); + Mockito.when(client.isConnected()).thenReturn(true); + Mockito.when(transformer.serialize(Mockito.any())).thenReturn(transformed.getBytes()); + mqttClientMap.put(PropertyNames.DEFAULT_PLATFORMID, client); + pahoMqttDispatcher.setMqttClientMap(mqttClientMap); + mqttDispatcher.setEventWrapFrequency(1); + mqttDispatcher.setWrapDispatchEvent(false); + mqttDispatcher.setTransformer(transformer); + mqttDispatcher.dispatch(new TestKey(), entity); + + Mockito.verify(transformer, Mockito.times(0)).serialize(Mockito.any()); + + } + + /** + * Test wrapevent when event fequency is not met. + */ + /* + * Here we invoke dispatch just once and frequency of sampling is Constants.TWO hence + * wrapping should not be done. + */ + @Test + public void testWrapeventWhenEventFequencyIsNotMet() { + String input = "input"; + String transformed = "transformed"; + DeviceMessage entity = new DeviceMessage(input.getBytes(), Version.V1_0, + new TestEvent(), "topic", Constants.THREAD_SLEEP_TIME_60000); + entity.getDeviceMessageHeader().withDevMsgGlobalTopic("globaltopic"); + entity.getDeviceMessageHeader().withTargetDeviceId("td"); + entity.getDeviceMessageHeader().withVehicleId("vd"); + entity.getDeviceMessageHeader().withRequestId("rd"); + Mockito.when(client.isConnected()).thenReturn(true); + Mockito.when(transformer.serialize(Mockito.any())).thenReturn(transformed.getBytes()); + mqttClientMap.put(PropertyNames.DEFAULT_PLATFORMID, client); + pahoMqttDispatcher.setMqttClientMap(mqttClientMap); + mqttDispatcher.setEventWrapFrequency(Constants.TWO); + mqttDispatcher.setWrapDispatchEvent(true); + mqttDispatcher.setTransformer(transformer); + mqttDispatcher.dispatch(new TestKey(), entity); + + Mockito.verify(transformer, Mockito.times(0)).serialize(Mockito.any()); + + } + + /** + * Test wrapevent when event fequency is met. + */ + /* + * Here we will invoke dispatch 4 times . The frequency of sampling is set + * to Constants.TWO hence two events should be wrapped. + */ + @Test + public void testWrapeventWhenEventFequencyIsMet() { + String input = "input"; + String transformed = "transformed"; + DeviceMessage entity = new DeviceMessage(input.getBytes(), Version.V1_0, + new TestEvent(), "topic", Constants.THREAD_SLEEP_TIME_60000); + entity.getDeviceMessageHeader().withDevMsgGlobalTopic("globaltopic"); + entity.getDeviceMessageHeader().withTargetDeviceId("td"); + entity.getDeviceMessageHeader().withVehicleId("vd"); + entity.getDeviceMessageHeader().withRequestId("rd"); + Mockito.when(client.isConnected()).thenReturn(true); + Mockito.when(transformer.serialize(Mockito.any())).thenReturn(transformed.getBytes()); + mqttClientMap.put(PropertyNames.DEFAULT_PLATFORMID, client); + pahoMqttDispatcher.setMqttClientMap(mqttClientMap); + mqttDispatcher.setEventWrapFrequency(Constants.TWO); + mqttDispatcher.setWrapDispatchEvent(true); + mqttDispatcher.setTransformer(transformer); + mqttDispatcher.dispatch(new TestKey(), entity); + mqttDispatcher.dispatch(new TestKey(), entity); + mqttDispatcher.dispatch(new TestKey(), entity); + mqttDispatcher.dispatch(new TestKey(), entity); + + Mockito.verify(transformer, Mockito.times(Constants.TWO)).serialize(Mockito.any()); + + } + + /** + * Test close mqtt connection when exception occur in disconnect. + * + * @throws MqttException the mqtt exception + */ + @Test + public void testCloseMqttConnectionWhenExceptionOccurInDisconnect() throws MqttException { + Mockito.when(client.isConnected()).thenReturn(true); + doThrow(MqttException.class).when(client).disconnect(); + mqttDispatcher.close(); + Mockito.verify(client, Mockito.times(1)).disconnectForcibly(); + } + + /** + * Test close mqtt connection when exception occur in disconnect forcibly. + * + * @throws MqttException the mqtt exception + */ + @Test + public void testCloseMqttConnectionWhenExceptionOccurInDisconnectForcibly() throws MqttException { + Mockito.when(client.isConnected()).thenReturn(true); + doThrow(MqttException.class).when(client).disconnect(); + doThrow(MqttException.class).when(client).disconnectForcibly(); + mqttDispatcher.close(); + Mockito.verify(client, Mockito.times(1)).disconnectForcibly(); + Mockito.verify(client, Mockito.times(0)).close(); + } + + /** + * Test close mqtt connection when client close successfully. + * + * @throws MqttException the mqtt exception + */ + @Test + public void testCloseMqttConnectionWhenClientCloseSuccessfully() throws MqttException { + Mockito.when(client.isConnected()).thenReturn(true); + mqttDispatcher.close(); + Mockito.verify(client, Mockito.times(1)).close(); + Mockito.verify(client, Mockito.times(1)).disconnect(); + Mockito.verify(client, Mockito.times(0)).disconnectForcibly(); + } + + /** + * Test close mqtt connection when exception occur in closing. + * + * @throws MqttException the mqtt exception + */ + @Test + public void testCloseMqttConnectionWhenExceptionOccurInClosing() throws MqttException { + Mockito.when(client.isConnected()).thenReturn(true); + doThrow(MqttException.class).when(client).close(); + mqttDispatcher.close(); + Mockito.verify(client, Mockito.times(Constants.TWO)).close(); + Mockito.verify(client, Mockito.times(1)).disconnectForcibly(); + } + + /** + * Test wrapevent for global topic listed. + */ + @Test + public void testWrapeventForGlobalTopicListed() { + String input = "input"; + List topicList = new ArrayList<>(); + topicList.add("globaltopic"); + topicList.add("topic"); + DeviceMessage entity = new DeviceMessage(input.getBytes(), Version.V1_0, + new TestEvent(), "topic", Constants.THREAD_SLEEP_TIME_60000); + entity.getDeviceMessageHeader().withDevMsgGlobalTopic("globaltopic"); + entity.getDeviceMessageHeader().withTargetDeviceId("td"); + entity.getDeviceMessageHeader().withVehicleId("vd"); + entity.getDeviceMessageHeader().withRequestId("rd"); + String transformed = "transformed"; + Mockito.when(client.isConnected()).thenReturn(true); + Mockito.when(transformer.serialize(Mockito.any())).thenReturn(transformed.getBytes()); + mqttClientMap.put(PropertyNames.DEFAULT_PLATFORMID, client); + pahoMqttDispatcher.setMqttClientMap(mqttClientMap); + mqttDispatcher.setEventWrapFrequency(1); + mqttDispatcher.setWrapDispatchEvent(true); + mqttDispatcher.setTransformer(transformer); + mqttDispatcher.setGlobalBroadcastRetentionTopicList(topicList); + mqttDispatcher.dispatch(new TestKey(), entity); + + Mockito.verify(transformer, Mockito.times(1)).serialize(Mockito.any()); + + ArgumentCaptor keyArgument = ArgumentCaptor.forClass(IgniteBlobEvent.class); + Mockito.verify(transformer).serialize(keyArgument.capture()); + IgniteBlobEvent actualKey = keyArgument.getValue(); + BlobDataV1_0 blobDataV10 = (BlobDataV1_0) actualKey.getEventData(); + Assert.assertEquals(new String(blobDataV10.getPayload()), new String(input.getBytes())); + + } + + /** + * Test wrapevent for global topic not listed. + */ + @Test + public void testWrapeventForGlobalTopicNotListed() { + String input = "input"; + List topicList = new ArrayList<>(); + topicList.add("globaltopic"); + topicList.add("topic"); + DeviceMessage entity = new DeviceMessage(input.getBytes(), Version.V1_0, + new TestEvent(), "topic", Constants.THREAD_SLEEP_TIME_60000); + entity.getDeviceMessageHeader().withDevMsgGlobalTopic("test"); + entity.getDeviceMessageHeader().withTargetDeviceId("td"); + entity.getDeviceMessageHeader().withVehicleId("vd"); + entity.getDeviceMessageHeader().withRequestId("rd"); + Mockito.when(client.isConnected()).thenReturn(true); + String transformed = "transformed"; + Mockito.when(transformer.serialize(Mockito.any())).thenReturn(transformed.getBytes()); + mqttClientMap.put(PropertyNames.DEFAULT_PLATFORMID, client); + pahoMqttDispatcher.setMqttClientMap(mqttClientMap); + mqttDispatcher.setEventWrapFrequency(1); + mqttDispatcher.setWrapDispatchEvent(true); + mqttDispatcher.setTransformer(transformer); + mqttDispatcher.setGlobalBroadcastRetentionTopicList(topicList); + mqttDispatcher.dispatch(new TestKey(), entity); + + Mockito.verify(transformer, Mockito.times(1)).serialize(Mockito.any()); + + ArgumentCaptor keyArgument = ArgumentCaptor.forClass(IgniteBlobEvent.class); + Mockito.verify(transformer).serialize(keyArgument.capture()); + IgniteBlobEvent actualKey = keyArgument.getValue(); + BlobDataV1_0 blobDataV10 = (BlobDataV1_0) actualKey.getEventData(); + Assert.assertEquals(new String(blobDataV10.getPayload()), new String(input.getBytes())); + + } + + /** + * inner class TestKey implements IgniteKey. + */ + public class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + + return "test"; + } + + } + + /** + * innerc class TestEvent extends IgniteEventImpl. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Sample"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("test"); + } + + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/ObjectUtilsTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/ObjectUtilsTest.java new file mode 100644 index 0000000..9589580 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/ObjectUtilsTest.java @@ -0,0 +1,182 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.analytics.stream.base.exception.ObjectUtilsException; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + + +/** + * ObjectUtils test for different checking methods. + * + * @author Binoy + */ +public class ObjectUtilsTest { + + /** + * Testing for non null object. + */ + @Test + public void testObjectUtilsForRequireNonEmpty() { + // to cover default constructor + String message = "test message"; + assertEquals(message, ObjectUtils.requireNonEmpty(message, "No error. Because object is not null or empty")); + Object object = new Object(); + assertEquals(object, ObjectUtils.requireNonEmpty(object, "No error. Because object is not null or empty")); + } + + /** + * Testing for null object. + */ + @Test(expected = RuntimeException.class) + public void testObjectUtilsForRequireNonEmptyWithException() { + String message = ""; + ObjectUtils.requireNonEmpty(message, "Error.Because String is empty"); + } + + /** + * Testing for null object. + */ + @Test(expected = RuntimeException.class) + public void testObjectUtilsForRequireNonEmptyWithExceptionObject() { + Object object = null; + ObjectUtils.requireNonEmpty(object, "Error.Because object is null"); + } + + /** + * Testing for collection's size. + */ + @Test + public void testRequireSizeOfCollection() { + List list = new ArrayList<>(); + list.add("test"); + assertTrue("Collection size is not matching", ObjectUtils.requireSizeOf(list, + 1, "No error. Because list size is 1(one)")); + + } + + /** + * Testing for collection's size. + */ + @Test + public void testRequireSizeOfCollectionForInvalidCollectionSize() { + List list = new ArrayList<>(); + list.add("test"); + assertThrows(ObjectUtilsException.class, () -> ObjectUtils.requireSizeOf(list, + Constants.TEN, "Error. Because list contains 1 item")); + } + + /** + * Testing for non null. + */ + @Test() + public void testForRequireNonNull() { + Object object = new Object(); + assertEquals(object, ObjectUtils.requireNonNull(object, "No Error. Because object is not null")); + assertThrows(NullPointerException.class, + () -> ObjectUtils.requireNonNull(null, "Error. Because object is null")); + } + + /** + * Testing for minimum size of collection. + */ + @Test() + public void testForRequireMinSize() { + List list = new ArrayList<>(); + list.add("item1"); + list.add("item2"); + assertTrue(ObjectUtils.requireMinSize(list, 1, "No Error. Because collection has more than expected item")); + assertThrows(ObjectUtilsException.class, () -> ObjectUtils.requireMinSize(list, + Constants.THREE, "Error. Because collection has less than expected item")); + } + + /** + * Testing for non null and non empty collection. + */ + @Test() + public void testForRequiresNotNullAndNotEmpty() { + List list = new ArrayList<>(); + list.add("item1"); + list.add("item2"); + assertTrue(ObjectUtils.requiresNotNullAndNotEmpy(list, + "No Error. Because collection is non null and non empty")); + list.clear(); + assertThrows(ObjectUtilsException.class, + () -> ObjectUtils.requiresNotNullAndNotEmpy(list, "Error. Because collection is empty")); + } + + /** + * Testing for non negative. + */ + @Test + public void testForRequireNonNegative() { + Integer positiveNumber = Constants.TEN; + assertEquals(positiveNumber, ObjectUtils.requireNonNegative(positiveNumber, + "No Error. Because number is positive")); + String positiveStrNumber = "10"; + assertEquals(positiveStrNumber, ObjectUtils.requireNonNegative(positiveStrNumber, + " No Error. Because number is positive")); + } + + /** + * Testing for non negative for exception. + */ + @Test(expected = RuntimeException.class) + public void testForRequireNonNegativeForException() { + Integer negativeNumber = Constants.NEGATIVE_ONE; + ObjectUtils.requireNonNegative(negativeNumber, "Error. Because number is negative"); + } + + /** + * Testing for non negative for exception. + */ + @Test(expected = RuntimeException.class) + public void testForRequireNonNegativeObjectForException() { + Object object = new Object(); + ObjectUtils.requireNonNegative(object, "Error. Because only supported type is int and string"); + } +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/PairTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/PairTest.java new file mode 100644 index 0000000..8138620 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/PairTest.java @@ -0,0 +1,92 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.analytics.stream.base.utils.Pair; +import org.junit.Assert; +import org.junit.Test; + + +/** + * {@link PairTest}. + */ +public class PairTest { + + /** + * Pair getter setter test. + */ + @Test + public void pairGetterSetterTest() { + Pair pairObj = new Pair<>(); + pairObj.setA("obj1"); + pairObj.setB("obj2"); + Assert.assertEquals("obj1", pairObj.getA()); + Assert.assertEquals("obj2", pairObj.getB()); + } + + /** + * Pair hash code tester. + */ + @Test + public void pairHashCodeTester() { + Pair pairObj = new Pair<>("obj1", "obj2"); + Pair pairObj1 = new Pair<>("obj3", "obj4"); + Assert.assertNotEquals(pairObj.hashCode(), pairObj1.hashCode()); + } + + /** + * Pair equals tester. + */ + @Test + public void pairEqualsTester() { + Pair pairObj = new Pair<>("obj1", "obj2"); + Pair pairObj1 = new Pair<>("obj3", "obj4"); + Assert.assertEquals(pairObj, pairObj); + Assert.assertNotEquals(null, pairObj); + Assert.assertNotEquals(pairObj, pairObj1); + Pair pairObj3 = new Pair<>(null, "obj2"); + Pair pairObj4 = new Pair<>("obj1", null); + Assert.assertNotEquals(pairObj3, pairObj); + Assert.assertNotEquals(pairObj4, pairObj); + Pair pairObj5 = new Pair<>("obj1", "obj4"); + Assert.assertNotEquals(pairObj5, pairObj); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/TripletTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/TripletTest.java new file mode 100644 index 0000000..0deab02 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/base/utils/TripletTest.java @@ -0,0 +1,79 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.base.utils; + +import org.eclipse.ecsp.analytics.stream.base.utils.Triplet; +import org.junit.Assert; +import org.junit.Test; + + +/** + * {@link TripletTest}. + */ +public class TripletTest { + + /** + * Triplets getters test. + */ + @Test + public void tripletsGettersTest() { + //to cover constructor + Triplet triplet = new Triplet(new String("obj1"), + new String("obj2"), new String("obj3")); + Assert.assertEquals("obj1", triplet.getA()); + Assert.assertEquals("obj2", triplet.getB()); + Assert.assertEquals("obj3", triplet.getC()); + } + + /** + * Triple setters test test. + */ + @Test + public void tripleSettersTestTest() { + Triplet triplet = new Triplet(new String("obj1"), new String("obj2"), new String("obj3")); + triplet.setA(new String("A")); + triplet.setB(new String("B")); + triplet.setC(new String("C")); + Assert.assertEquals("A", triplet.getA()); + Assert.assertEquals("B", triplet.getB()); + Assert.assertEquals("C", triplet.getC()); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileClientApiUtilTest.java b/src/test/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileClientApiUtilTest.java new file mode 100644 index 0000000..39ba03e --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/analytics/stream/vehicleprofile/utils/VehicleProfileClientApiUtilTest.java @@ -0,0 +1,123 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.analytics.stream.vehicleprofile.utils; + +import org.eclipse.ecsp.analytics.stream.base.http.HttpClient; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.context.ApplicationContext; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.HashMap; +import java.util.Map; + + +/** + * UT class for {@link VehicleProfileClientApiUtil}. + **/ +public class VehicleProfileClientApiUtilTest { + + /** The http client. */ + @Mock + HttpClient httpClient; + + /** The ctx. */ + @Mock + ApplicationContext ctx; + + /** The vehicle profile. */ + @InjectMocks + private VehicleProfileClientApiUtil vehicleProfile = new VehicleProfileClientApiUtil(); + + /** + * Testcall vehicle profile. + */ + @Test + public void testcallVehicleProfile() { + IgniteEventImpl igniteEvent = new IgniteEventImpl(); + igniteEvent.setVersion(Version.V1_0); + igniteEvent.setTimestamp(System.currentTimeMillis()); + igniteEvent.setRequestId("Request123"); + igniteEvent.setSourceDeviceId("12345"); + Map responseData = new HashMap<>(); + String jsontest = "{\"message\":\"SUCCESS\",\"data\":" + + "[{\"provisionedServices\":[{\"applicationId\":\"LOCATION\"}," + + "{\"applicationId\":\"GENERIC-SETTINGS\"}],\"capabilities\":" + + "[{\"applicationId\":\"LOCATION\"},{\"applicationId\":\"AOTA\"}," + + "{\"applicationId\":\"GENERIC-SETTINGS\"}],\"customParams\":null,\"dummy\":true," + + "\"authorizedPartners\":null,\"authorizedUsers\":[{\"userId\":\"admin\",\"role\":" + + "\"VEHICLE_OWNER\",\"source\":null,\"status\":null,\"tc\":null,\"pp\":null,\"createdOn" + + "\":null,\"updatedOn\":null}],\"vehicleAttributes\":{\"make\":\"NA\",\"model\":\"NA\"," + + "\"marketingColor\":null,\"baseColor\":null,\"modelYear\":\"NA\",\"destinationCountry\":null," + + "\"engineType\":null,\"bodyStyle\":null,\"bodyType\":null,\"name\":\"My Car\",\"trim\":null," + + "\"type\":\"UNAVAILABLE\",\"fuelType\":null},\"vehicleId\":\"64e7469b51e8d859ee363c61\",\"vin\"" + + ":\"HCPDOIQ5KRKD12458\"}]}"; + responseData.put("responseJson", jsontest); + httpClient = Mockito.mock(HttpClient.class); + ReflectionTestUtils.setField(vehicleProfile, "httpClient", httpClient); + ReflectionTestUtils.setField(vehicleProfile, "apiUrl", "test/url"); + Mockito.when(httpClient.invokeJsonResource(Mockito.any(), Mockito.anyString(), + Mockito.anyMap(), Mockito.anyMap(), Mockito.anyInt(), Mockito.anyLong())).thenReturn(responseData); + String expectedVin = (String) ReflectionTestUtils.invokeMethod(vehicleProfile, "callVehicleProfile", "DQ12345"); + String actualVin = "HCPDOIQ"; + Assert.assertNotEquals(actualVin, expectedVin); + } + + /** + * Test append to url. + */ + @Test + public void testAppendToUrl() { + String vehicleId = "vehicle1234"; + String expectedUrl = "http://vehicle-profile-api-int-svc:8080/v1.0/vehicles?clientId=" + vehicleId; + String actualUrl = ""; + + String urlWithoutForwardSlash = "http://vehicle-profile-api-int-svc:8080/v1.0/vehicles?clientId="; + expectedUrl = "http://vehicle-profile-api-int-svc:8080/v1.0/vehicles?clientId=" + vehicleId; + ReflectionTestUtils.setField(vehicleProfile, "apiUrl", urlWithoutForwardSlash); + actualUrl = (String) ReflectionTestUtils.invokeMethod(vehicleProfile, "appendToURL", vehicleId); + Assert.assertEquals(expectedUrl, actualUrl); + } +} diff --git a/src/test/java/org/eclipse/ecsp/cache/redis/EmbeddedRedisSentinelServer.java b/src/test/java/org/eclipse/ecsp/cache/redis/EmbeddedRedisSentinelServer.java new file mode 100644 index 0000000..9df5954 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/cache/redis/EmbeddedRedisSentinelServer.java @@ -0,0 +1,114 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.cache.redis; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.junit.rules.ExternalResource; +import redis.embedded.RedisCluster; +import redis.embedded.RedisCluster408; +import redis.embedded.RedisExecProvider; +import redis.embedded.RedisSentinel; +import redis.embedded.RedisServer; +import redis.embedded.util.Architecture; +import redis.embedded.util.OS; + +import java.util.Arrays; + + +/** + * class EmbeddedRedisSentinelServer extends ExternalResource. + */ +public class EmbeddedRedisSentinelServer extends ExternalResource { + + /** The logger. */ + private static IgniteLogger logger = IgniteLoggerFactory.getLogger(EmbeddedRedisSentinelServer.class); + + /** The redis. */ + private RedisCluster408 redis = null; + + /** + * Before. + * + * @throws Throwable the throwable + */ + @Override + protected void before() throws Throwable { + RedisExecProvider igniteProvider = RedisExecProvider.defaultProvider(); + igniteProvider.override(OS.MAC_OS_X, Architecture.x86, + "redis-server-4.0.8.app"); + igniteProvider.override(OS.MAC_OS_X, Architecture.x86_64, + "redis-server-4.0.8.app"); + igniteProvider.override(OS.UNIX, Architecture.x86, + "redis-server-4.0.8"); + igniteProvider.override(OS.UNIX, Architecture.x86_64, + "redis-server-4.0.8"); + int[] sentinelPorts = new int[Constants.THREE]; + PortScanner portScanner = new PortScanner(); + sentinelPorts[0] = portScanner.getAvailablePort(Constants.INT_26379); + sentinelPorts[1] = portScanner.getAvailablePort(sentinelPorts[0] + 1); + sentinelPorts[Constants.TWO] = portScanner.getAvailablePort(sentinelPorts[1] + 1); + int[] serverPorts = new int[Constants.TWO]; + serverPorts[0] = portScanner.getAvailablePort(Constants.INT_6379); + serverPorts[1] = portScanner.getAvailablePort(serverPorts[0] + 1); + logger.info("Sentinel ports {}, {}, {}", sentinelPorts[0], sentinelPorts[1], sentinelPorts[Constants.TWO]); + RedisCluster rc = RedisCluster.builder().withSentinelBuilder(RedisSentinel.builder() + .redisExecProvider(igniteProvider)) + .withServerBuilder(RedisServer.builder().redisExecProvider(igniteProvider)) + .sentinelPorts(Arrays.asList(sentinelPorts[0], + sentinelPorts[1], sentinelPorts[Constants.TWO])) + // .serverPorts(Arrays.asList(serverPorts[0], serverPorts[1])) + .replicationGroup("mogambo", 1).build(); + redis = new RedisCluster408(rc); + RedisConfig.overridingSentinelPorts = new Integer[] { + sentinelPorts[0], sentinelPorts[1], sentinelPorts[Constants.TWO] }; + redis.start(); + } + + /** + * After. + */ + @Override + protected void after() { + redis.stop(); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/cache/redis/EmbeddedRedisServer.java b/src/test/java/org/eclipse/ecsp/cache/redis/EmbeddedRedisServer.java new file mode 100644 index 0000000..8b3f214 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/cache/redis/EmbeddedRedisServer.java @@ -0,0 +1,102 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.cache.redis; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.junit.rules.ExternalResource; +import redis.embedded.RedisExecProvider; +import redis.embedded.RedisServer408; +import redis.embedded.util.Architecture; +import redis.embedded.util.OS; + + +/** + * class {@link EmbeddedRedisServer} extends {@link ExternalResource}. + */ +public class EmbeddedRedisServer extends ExternalResource { + + /** The redis. */ + private RedisServer408 redis = null; + + /** The port. */ + private int port = 0; + + /** + * Before. + * + * @throws Throwable the throwable + */ + @Override + protected void before() throws Throwable { + RedisExecProvider igniteProvider = RedisExecProvider.defaultProvider(); + igniteProvider.override(OS.MAC_OS_X, Architecture.x86, + "redis-server-4.0.8.app"); + igniteProvider.override(OS.MAC_OS_X, Architecture.x86_64, + "redis-server-4.0.8.app"); + igniteProvider.override(OS.UNIX, Architecture.x86, + "redis-server-4.0.8"); + igniteProvider.override(OS.UNIX, Architecture.x86_64, + "redis-server-4.0.8"); + igniteProvider.override(OS.WINDOWS, Architecture.x86, "redis-server.exe"); + igniteProvider.override(OS.WINDOWS, Architecture.x86_64, "redis-server.exe"); + port = new PortScanner().getAvailablePort(Constants.INT_6379); + redis = new RedisServer408(igniteProvider, port); + redis.start(); + RedisConfig.overridingPort = port; + } + + /** + * After. + */ + @Override + protected void after() { + redis.stop(); + } + + /** + * Gets the port. + * + * @return the port + */ + public int getPort() { + return port; + } + +} diff --git a/src/test/java/org/eclipse/ecsp/dao/utils/EmbeddedMongoDB.java b/src/test/java/org/eclipse/ecsp/dao/utils/EmbeddedMongoDB.java new file mode 100644 index 0000000..c5fe440 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/dao/utils/EmbeddedMongoDB.java @@ -0,0 +1,122 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.dao.utils; + +import com.mongodb.BasicDBObject; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import de.flapdoodle.embed.mongo.MongodExecutable; +import de.flapdoodle.embed.mongo.MongodProcess; +import de.flapdoodle.embed.mongo.MongodStarter; +import de.flapdoodle.embed.mongo.config.MongodConfig; +import de.flapdoodle.embed.mongo.config.Net; +import de.flapdoodle.embed.mongo.distribution.Version; +import de.flapdoodle.embed.process.runtime.Network; +import org.eclipse.ecsp.nosqldao.spring.config.AbstractIgniteDAOMongoConfig; +import org.eclipse.ecsp.utils.logger.IgniteLogger; +import org.eclipse.ecsp.utils.logger.IgniteLoggerFactory; +import org.junit.rules.ExternalResource; + +import java.util.Map; + + +/** + * class EmbeddedMongoDB extends ExternalResource. + */ +public class EmbeddedMongoDB extends ExternalResource { + + /** The Constant LOGGER. */ + private static final IgniteLogger LOGGER = IgniteLoggerFactory.getLogger(EmbeddedMongoDB.class); + + /** The mongod starter. */ + private MongodStarter mongodStarter = MongodStarter.getDefaultInstance(); + + /** The mongod executable. */ + private MongodExecutable mongodExecutable; + + /** The mongod process. */ + private MongodProcess mongodProcess; + + /** The port. */ + private int port = 0; + + /** + * Before. + * + * @throws Throwable the throwable + */ + @Override + protected void before() throws Throwable { + port = Network.getFreeServerPort(); + MongodConfig mongodConfig = MongodConfig.builder().version(Version.Main.V4_4) + .net(new Net("localhost", port, Network.localhostIsIPv6())) + .build(); + mongodExecutable = mongodStarter.prepare(mongodConfig); + mongodProcess = mongodExecutable.start(); + LOGGER.info("Embedded mongo DB started on port {} ", port); + AbstractIgniteDAOMongoConfig.overridingPort = port; + + LOGGER.info("MongoClient connecting for pre-work DB configuration..."); + try (MongoClient mongoClient = MongoClients.create("mongodb://localhost:" + port)) { + Map commandArguments = new BasicDBObject(); + commandArguments.put("createUser", "admin"); + commandArguments.put("pwd", "password"); + String[] roles = { "readWrite" }; + commandArguments.put("roles", roles); + BasicDBObject command = new BasicDBObject(commandArguments); + MongoDatabase adminDatabase = mongoClient.getDatabase("admin"); + adminDatabase.runCommand(command); + } + } + + /** + * After. + */ + @Override + protected void after() { + if (null != mongodProcess) { + mongodProcess.stop(); + } + if (null != mongodExecutable) { + mongodExecutable.stop(); + } + } +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/ConnectionStatusHandlerTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/ConnectionStatusHandlerTest.java new file mode 100644 index 0000000..463ca79 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/ConnectionStatusHandlerTest.java @@ -0,0 +1,624 @@ +package org.eclipse.ecsp.stream.dma; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.DefaultMqttTopicNameGeneratorImpl; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.eclipse.ecsp.analytics.stream.base.utils.PahoMqttDispatcher; +import org.eclipse.ecsp.entities.AbstractIgniteEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusService; +import org.eclipse.ecsp.stream.dma.handler.DeviceConnectionStatusHandler; +import org.eclipse.ecsp.stream.dma.handler.DeviceStatusBackDoorKafkaConsumer; +import org.eclipse.ecsp.utils.ConcurrentHashSet; +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + + +/** + * Integration test case for {@link DeviceConnectionStatusHandler} in Device Messaging module. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/dma-connectionstatus-handler-test.properties") +public class ConnectionStatusHandlerTest extends KafkaStreamsApplicationTestBase { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(ConnectionStatusHandlerTest.class); + + /** The conn status topic. */ + private static String connStatusTopic; + + /** The source topic name. */ + private static String sourceTopicName; + + /** The i. */ + private static int i = 0; + + /** The vehicle id. */ + private String vehicleId = "Vehicle12345"; + + /** The device id. */ + private String deviceId = "Device12345"; + + /** The test client. */ + private MqttClientTest testClient; + + /** The default mqtt topic name generator impl. */ + @Autowired + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The device service. */ + @Autowired + private DeviceStatusService deviceService; + + /** The paho mqtt dispatcher. */ + @Autowired + private PahoMqttDispatcher pahoMqttDispatcher; + + /** The device status back door kafka consumer. */ + @Autowired + DeviceStatusBackDoorKafkaConsumer deviceStatusBackDoorKafkaConsumer; + + /** The device connection status handler. */ + @Autowired + DeviceConnectionStatusHandler deviceConnectionStatusHandler; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** + * Gets the service name. + * + * @return the service name + */ + public String getServiceName() { + return serviceName; + } + + /** + * Sets the service name. + * + * @param serviceName the new service name + */ + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + /** The interval. */ + private long interval = 500L; + + /** + * Sets up the environment for this test class before each test case run. + * Like creation of necessary kafka topics. + * + * @throws Exception the exception + */ + @Before + public void setup() throws Exception { + super.setup(); + i++; + sourceTopicName = "sourceTopic" + i; + connStatusTopic = DMAConstants.DEVICE_STATUS_TOPIC_PREFIX + serviceName.toLowerCase(); + createTopics(connStatusTopic, sourceTopicName, "dff-dfn-updates"); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, sourceTopicName); + + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + + Properties kafkaConsumerProps = deviceStatusBackDoorKafkaConsumer.getKafkaConsumerProps(); + kafkaConsumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + deviceStatusBackDoorKafkaConsumer.addCallback(deviceConnectionStatusHandler.new DeviceStatusCallBack(), 0); + deviceStatusBackDoorKafkaConsumer.startBackDoorKafkaConsumer(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + } + + /** + * Test DMA based on device status. + * + * @throws Exception the exception + */ + @Test + public void testDMABasedOnDeviceStatus() throws Exception { + ConcurrentHashSet expectedValue = new ConcurrentHashSet<>(); + expectedValue.add(deviceId); + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, DMATestServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + launchApplication(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + + String deviceConnStatusEvent = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\"," + + "\"Data\": {\"connStatus\":\"ACTIVE\",\"serviceName\":\"eCall\"},\"MessageId\": \"1234\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"Device12345\"}"; + + String deviceIdKey = vehicleId; + Assert.assertNull(deviceService.get(deviceIdKey, Optional.empty())); + + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, vehicleId.getBytes(), + deviceConnStatusEvent.getBytes()); + + Function> getStatus = x -> deviceService.get(deviceIdKey, Optional.empty()); + + ConcurrentHashSet actual = retryWithException(TestConstants.TWENTY, getStatus); + /* + * Ensure incorrect status does not exist. + */ + Assert.assertNull(deviceService.get(deviceId, Optional.empty())); + /* + * Once a status ACTIVE has been sent it should reflect in LocalCache. + */ + Assert.assertEquals(expectedValue, actual); + + String speedEventWithVehicleId = "{\"EventID\": \"Speed\",\"Version\": \"1.0\"," + + "\"Data\": {\"value\":20.0},\"MessageId\": \"1234\",\"CorrelationId\": \"1234\"," + + "\"BizTransactionId\": \"Biz1234\",\"VehicleId\": \"Vehicle12345\"}"; + + /* + * Send Data to source Topic. + */ + testClient = new MqttClientTest(new TestKey(), new DeviceMessageHeader().withTargetDeviceId("Device12345")); + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, vehicleId.getBytes(), + speedEventWithVehicleId.getBytes()); + /* + * Data send in source topic should come to mqtt + */ + String msgRec = testClient.getMsgReceived(); + Assert.assertTrue(msgRec.contains("\"EventID\":\"Speed\"")); + Assert.assertTrue(msgRec.contains("\"Version\":\"1.0\"")); + Assert.assertTrue(msgRec.contains("\"Data\":{\"value\":20.0}")); + Assert.assertTrue(msgRec.contains("\"BizTransactionId\":\"Biz1234\"")); + /* + * Disconnect Device after sending connStatus=INACTIVE. + */ + String terminateDeviceConnStatusEvent = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\"," + + "\"Data\": {\"connStatus\":\"INACTIVE\",\"serviceName\":\"eCall\"},\"MessageId\": \"1234\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, vehicleId.getBytes(), + terminateDeviceConnStatusEvent.getBytes()); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_2000); + Assert.assertNull(deviceService.get(deviceIdKey, Optional.empty())); + /* + * Data send in source topic should NOT come to mqtt as current status + * of device is INACTIVE. + */ + testClient = new MqttClientTest(new TestKey(), new DeviceMessageHeader().withTargetDeviceId("Device12345")); + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, vehicleId.getBytes(), + speedEventWithVehicleId.getBytes()); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_2000); + Assert.assertNull(testClient.getMsgReceived()); + /* + * Test if sourceDeviceId notPresent in DeviceConnStatus will the status + * be set in cache. + */ + String deviceConnStatusEventWithoutSourceDeviceId = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\"," + + "\"Data\": {\"connStatus\":\"ACTIVE\",\"serviceName\":\"eCall\"}," + + "\"MessageId\": \"1234\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, vehicleId.getBytes(), + deviceConnStatusEventWithoutSourceDeviceId.getBytes()); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_2000); + Assert.assertNull(deviceService.get(deviceIdKey, Optional.empty())); + /* + * Test if eventId for DeviceConnStatus event is incorrect will the + * status be set in cache + */ + String deviceConnStatusEventWithIncorrectEventId = "{\"EventID\": \"DeviceConn\",\"Version\": \"1.0\"," + + "\"Data\": {\"connStatus\":\"ACTIVE\",\"serviceName\":\"eCall\"}," + + "\"MessageId\": \"1234\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, vehicleId.getBytes(), + + deviceConnStatusEventWithIncorrectEventId.getBytes()); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_2000); + Assert.assertNull(deviceService.get(deviceIdKey, Optional.empty())); + /* + * Test if vehicleId notPresent in Speed event being pushed to + * sourceTopic of SP if data will be pushed to MQTT. + */ + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, vehicleId.getBytes(), + deviceConnStatusEvent.getBytes()); + actual = new ConcurrentHashSet(); + actual = retryWithException(TestConstants.TWENTY, getStatus); + + Assert.assertEquals(expectedValue, actual); + testClient = new MqttClientTest(new TestKey(), new DeviceMessageHeader().withTargetDeviceId("Device12345")); + + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_2000); + + if (testClient.getMsgReceived() != null) { + testClient = new MqttClientTest(new TestKey(), new DeviceMessageHeader().withTargetDeviceId("Device12345")); + } + + String speedEventWithoutVehicleId = "{\"EventID\": \"Speed\",\"Version\": \"1.0\"," + + "\"Data\": {\"value\":20.0},\"MessageId\": \"1234\",\"CorrelationId\": \"1234\"," + + "\"BizTransactionId\": \"Biz1234\"}"; + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, vehicleId.getBytes(), + speedEventWithoutVehicleId.getBytes()); + + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_2000); + Assert.assertNull(testClient.getMsgReceived()); + + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, vehicleId.getBytes(), + terminateDeviceConnStatusEvent.getBytes()); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_3000); + /* + * When correct sourceDeviceId is set will data be forwarded to MQTT + */ + testClient = new MqttClientTest(new TestKey(), new DeviceMessageHeader().withTargetDeviceId("Device12345")); + String speedEventWithVehicleIdAndSourceDeviceId = "{\"EventID\": \"Speed\",\"Version\": \"1.0\"," + + "\"Data\": {\"value\":20.0},\"MessageId\": \"1237\"," + + "\"BizTransactionId\": \"Biz1237\",\"VehicleId\": \"Vehicle12345\"," + + "\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, vehicleId.getBytes(), + deviceConnStatusEvent.getBytes()); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_3000); + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, vehicleId.getBytes(), + speedEventWithVehicleIdAndSourceDeviceId.getBytes()); + String actualMsgRec = retryWithException(TestConstants.THIRTY, x -> testClient.getMsgReceived()); + actualMsgRec = retryWithException(TestConstants.TWENTY, x -> testClient.getMsgReceived()); + + msgRec = testClient.getMsgReceived(); + Assert.assertTrue(msgRec.contains("\"EventID\":\"Speed\"")); + Assert.assertTrue(msgRec.contains("\"Version\":\"1.0\"")); + Assert.assertTrue(msgRec.contains("\"Data\":{\"value\":20.0}")); + Assert.assertTrue(msgRec.contains("\"BizTransactionId\":\"Biz1237\"")); + Assert.assertTrue(msgRec.contains("\"SourceDeviceId\":\"Device12345\"")); + + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, vehicleId.getBytes(), + terminateDeviceConnStatusEvent.getBytes()); + await().atMost(TestConstants.THREAD_SLEEP_TIME_3000, TimeUnit.MILLISECONDS); + + /* + * When incorrect sourceDeviceId is set will data be forwarded to MQTT + */ + testClient = new MqttClientTest(new TestKey(), + new DeviceMessageHeader().withTargetDeviceId("IncorrectDevice12345")); + String speedEventWithVehicleIdAndIncorrectSourceDeviceId = "{\"EventID\": \"Speed\",\"Version\": \"1.0\"," + + "\"Data\": {\"value\":20.0},\"MessageId\": \"1234\",\"CorrelationId\": \"1234\"," + + "\"BizTransactionId\": \"Biz1234\",\"VehicleId\": \"Vehicle12345\"," + + "\"SourceDeviceId\": \"IncorrectDevice12345\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, vehicleId.getBytes(), + deviceConnStatusEvent.getBytes()); + await().atMost(TestConstants.THREAD_SLEEP_TIME_3000, TimeUnit.MILLISECONDS); + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, vehicleId.getBytes(), + speedEventWithVehicleIdAndIncorrectSourceDeviceId.getBytes()); + await().atMost(TestConstants.THREAD_SLEEP_TIME_2000, TimeUnit.MILLISECONDS); + Assert.assertNull(testClient.getMsgReceived()); + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, vehicleId.getBytes(), + terminateDeviceConnStatusEvent.getBytes()); + await().atMost(TestConstants.THREAD_SLEEP_TIME_2000, TimeUnit.MILLISECONDS); + + /* + * When multiple deviceIds are present for a vehcileId, will data be + * forwarded to MQTT if sourceDeviceIdis not present + */ + testClient = new MqttClientTest(new TestKey(), new DeviceMessageHeader().withTargetDeviceId("Device12345")); + String deviceConnStatusEventDevice12346 = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\"," + + "\"Data\": {\"connStatus\":\"ACTIVE\",\"serviceName\":\"eCall\"},\"MessageId\": \"1234\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"Device12346\"}"; + String deviceConnStatusEventDevice12347 = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\"," + + "\"Data\": {\"connStatus\":\"ACTIVE\",\"serviceName\":\"eCall\"},\"MessageId\": \"1234\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"Device12347\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, vehicleId.getBytes(), + deviceConnStatusEventDevice12346.getBytes()); + await().atMost(TestConstants.THREAD_SLEEP_TIME_2000, TimeUnit.MILLISECONDS); + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, vehicleId.getBytes(), + deviceConnStatusEventDevice12347.getBytes()); + await().atMost(TestConstants.THREAD_SLEEP_TIME_2000, TimeUnit.MILLISECONDS); + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, vehicleId.getBytes(), + speedEventWithVehicleId.getBytes()); + await().atMost(TestConstants.THREAD_SLEEP_TIME_3000, TimeUnit.MILLISECONDS); + Assert.assertNull(testClient.getMsgReceived()); + + /* + * When multiple deviceIds are present for a vehcileId, will data be + * forwarded to MQTT if sourceId is present + */ + testClient = new MqttClientTest(new TestKey(), new DeviceMessageHeader().withTargetDeviceId("Device12347")); + String speedEventWithVehicleIdAndSourceDeviceIdDevice12347 = "{\"EventID\": \"Speed\",\"Version\": \"1.0\"," + + "\"Data\": {\"value\":20.0},\"MessageId\": \"9001\",\"BizTransactionId\": \"Biz9001\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"Device12347\"}"; + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, vehicleId.getBytes(), + speedEventWithVehicleIdAndSourceDeviceIdDevice12347.getBytes()); + actualMsgRec = retryWithException(TestConstants.TWENTY, x -> testClient.getMsgReceived()); + msgRec = testClient.getMsgReceived(); + Assert.assertTrue(msgRec.contains("\"EventID\":\"Speed\"")); + Assert.assertTrue(msgRec.contains("\"Version\":\"1.0\"")); + Assert.assertTrue(msgRec.contains("\"Data\":{\"value\":20.0}")); + Assert.assertTrue(msgRec.contains("\"BizTransactionId\":\"Biz9001\"")); + Assert.assertTrue(msgRec.contains("\"SourceDeviceId\":\"Device12347\"")); + + String terminateDeviceConnStatusEventDevice12346 = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\"," + + "\"Data\": {\"connStatus\":\"INACTIVE\",\"serviceName\":\"eCall\"},\"MessageId\": \"1234\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"Device12346\"}"; + String terminateDeviceConnStatusEventDevice12347 = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\"," + + "\"Data\": {\"connStatus\":\"INACTIVE\",\"serviceName\":\"eCall\"},\"MessageId\": \"1234\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"Device12347\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, vehicleId.getBytes(), + terminateDeviceConnStatusEventDevice12346.getBytes()); + await().atMost(TestConstants.THREAD_SLEEP_TIME_2000, TimeUnit.MILLISECONDS); + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, vehicleId.getBytes(), + terminateDeviceConnStatusEventDevice12347.getBytes()); + await().atMost(TestConstants.THREAD_SLEEP_TIME_2000, TimeUnit.MILLISECONDS); + + } + + /** + * Tear down. + */ + @After + public void tearDown() { + deviceStatusBackDoorKafkaConsumer.shutdown(); + } + + /** + * Test implementation of IgniteKey. + */ + public class TestKey implements IgniteKey { + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + + return vehicleId; + } + + } + + /** + * Test stream processor class for this integration test class. + */ + public static final class DMATestServiceProcessor implements IgniteEventStreamProcessor { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "DMATestServiceProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + AbstractIgniteEvent event = (AbstractIgniteEvent) kafkaRecord.value(); + event.setDeviceRoutable(true); + spc.forward(kafkaRecord.withValue(event)); + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + // default implementation + + } + + /** + * Close. + */ + @Override + public void close() { + // default implementation + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + // default implementation + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { sourceTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] {}; + } + } + + /** + * Test IgniteEvent class. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** The device id. */ + private String deviceId; + + /** + * Instantiates a new test event. + * + * @param deviceId the device id + */ + public TestEvent(String deviceId) { + this.deviceId = deviceId; + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Speed"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of(this.deviceId); + } + + } + + /** + * Test MQTT Client class. + */ + public class MqttClientTest { + + /** + * get a client to subscribe to the required topic topic. + */ + + private String mqttTopicToSubscribe; + + /** The msg received. */ + private String msgReceived; + + /** + * Gets the msg received. + * + * @return the msg received + */ + public String getMsgReceived() { + return msgReceived; + } + + /** + * Creates the MQTT client. + * + * @param key IgniteKey + * @param header DeviceMessageHeader + */ + public MqttClientTest(IgniteKey key, DeviceMessageHeader header) { + mqttTopicToSubscribe = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(key, header, null).get(); + try { + createClient(); + } catch (MqttException e) { + e.printStackTrace(); + } + } + + /** + * Creates the MQTT client and subscribes it to a topic for this test case. + * + * @throws MqttException MqttException + */ + public void createClient() throws MqttException { + MqttClient client = pahoMqttDispatcher.getMqttClient(PropertyNames.DEFAULT_PLATFORMID).get(); + client.subscribe(mqttTopicToSubscribe); + client.setCallback(new MqttCallback() { + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + LOGGER.info("Msg received:{} on topic:{}", message, topic); + msgReceived = message.toString(); + + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + + } + + @Override + public void connectionLost(Throwable cause) { + + } + }); + } + + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/ConnectionStatusParserTestImpl.java b/src/test/java/org/eclipse/ecsp/stream/dma/ConnectionStatusParserTestImpl.java new file mode 100644 index 0000000..db7676e --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/ConnectionStatusParserTestImpl.java @@ -0,0 +1,63 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.analytics.stream.base.parser.DeviceConnectionStatusParser; + +import java.util.Map; + + +/** + * {@link ConnectionStatusParserTestImpl} implements {@link DeviceConnectionStatusParser}. + */ +public class ConnectionStatusParserTestImpl implements DeviceConnectionStatusParser { + + /** + * Gets the connection status. + * + * @param responseData the response data + * @return the connection status + */ + @Override + public String getConnectionStatus(Map responseData) { + return "ACTIVE"; + } + +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DMARetryBucketDAOCacheBackedInMemoryImplTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DMARetryBucketDAOCacheBackedInMemoryImplTest.java new file mode 100644 index 0000000..e11910e --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DMARetryBucketDAOCacheBackedInMemoryImplTest.java @@ -0,0 +1,371 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.stores.CacheBypass; +import org.eclipse.ecsp.analytics.stream.base.utils.InternalCacheConstants; +import org.eclipse.ecsp.cache.GetMapOfEntitiesRequest; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEntity; +import org.eclipse.ecsp.entities.dma.RetryRecordIds; +import org.eclipse.ecsp.stream.dma.dao.DMARetryBucketDAOCacheBackedInMemoryImpl; +import org.eclipse.ecsp.stream.dma.dao.key.RetryBucketKey; +import org.eclipse.ecsp.utils.ConcurrentHashSet; +import org.eclipse.ecsp.utils.metrics.InternalCacheGuage; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Value; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + + +/** + * Test class for {@link DmaRetryBucketDaoCacheBackedInMemoryImpl}. + */ +public class DMARetryBucketDAOCacheBackedInMemoryImplTest { + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The task id. */ + private String taskId = "taskId"; + + /** The map key. */ + private String mapKey; + + /** The sorted dao. */ + @InjectMocks + private DMARetryBucketDAOCacheBackedInMemoryImpl sortedDao; + + /** The cache. */ + @Mock + private IgniteCache cache; + + /** The bypass. */ + @Mock + private CacheBypass bypass; + + /** The cache guage. */ + @Mock + private InternalCacheGuage cacheGuage; + + /** The key 1. */ + private RetryBucketKey key1; + + /** The key 2. */ + private RetryBucketKey key2; + + /** The key 3. */ + private RetryBucketKey key3; + + /** The key 4. */ + private RetryBucketKey key4; + + /** The retry msg ids 1. */ + private RetryRecordIds retryMsgIds1; + + /** The retry msg ids 2. */ + private RetryRecordIds retryMsgIds2; + + /** The retry msg ids 3. */ + private RetryRecordIds retryMsgIds3; + + /** The retry msg ids 4. */ + private RetryRecordIds retryMsgIds4; + + /** The message ids set 1. */ + private ConcurrentHashSet messageIdsSet1; + + /** The message ids set 2. */ + private ConcurrentHashSet messageIdsSet2; + + /** The message ids set 3. */ + private ConcurrentHashSet messageIdsSet3; + + /** The message ids set 4. */ + private ConcurrentHashSet messageIdsSet4; + + /** + * setup method is for setting up {@link RetryBucketKey} just after the class initialization. + */ + @Before + public void setup() { + mapKey = RetryBucketKey.getMapKey(serviceName, taskId); + // sortedDao.setBypass(bypass); + key1 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_3000); + key2 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_1000); + key3 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_5000); + key4 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_1500); + + MockitoAnnotations.initMocks(this); + sortedDao.initialize(taskId); + messageIdsSet1 = new ConcurrentHashSet(); + messageIdsSet1.add("message123"); + messageIdsSet1.add("message456"); + retryMsgIds1 = new RetryRecordIds(Version.V1_0, messageIdsSet1); + + messageIdsSet2 = new ConcurrentHashSet(); + messageIdsSet2.add("message223"); + messageIdsSet2.add("message256"); + retryMsgIds2 = new RetryRecordIds(Version.V1_0, messageIdsSet2); + + messageIdsSet3 = new ConcurrentHashSet(); + messageIdsSet3.add("message323"); + messageIdsSet3.add("message356"); + retryMsgIds3 = new RetryRecordIds(Version.V1_0, messageIdsSet3); + + messageIdsSet4 = new ConcurrentHashSet(); + messageIdsSet4.add("message423"); + messageIdsSet4.add("message456"); + retryMsgIds4 = new RetryRecordIds(Version.V1_0, messageIdsSet4); + + sortedDao.putToMap(mapKey, key1, retryMsgIds1, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_BUCKET); + sortedDao.putToMap(mapKey, key2, retryMsgIds2, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_BUCKET); + sortedDao.putToMap(mapKey, key3, retryMsgIds3, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_BUCKET); + sortedDao.putToMap(mapKey, key4, retryMsgIds4, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_BUCKET); + + } + + /** + * Cleanup. + */ + @After + public void cleanup() { + sortedDao.close(); + } + + /** + * Test update. + */ + @Test + public void testUpdate() { + Assert.assertEquals(messageIdsSet1, sortedDao.get(key1).getRecordIds()); + sortedDao.update(key1.getMapKey(serviceName, taskId), key1, "message556"); + + Set expected = new HashSet(); + expected.add("message123"); + expected.add("message456"); + expected.add("message556"); + + Assert.assertEquals(expected, sortedDao.get(key1).getRecordIds()); + + RetryBucketKey newKey = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_6000); + Assert.assertNull(sortedDao.get(newKey)); + sortedDao.update(newKey.getMapKey(serviceName, taskId), newKey, "message556"); + + expected = new HashSet(); + expected.add("message556"); + + Assert.assertEquals(expected, sortedDao.get(newKey).getRecordIds()); + + } + + /** + * Test put long set of string. + */ + @Test + public void testPutLongSetOfString() { + RetryBucketKey newKey = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_6000); + Assert.assertNull(sortedDao.get(newKey)); + ConcurrentHashSet expected = new ConcurrentHashSet(); + expected.add("message123"); + expected.add("message456"); + expected.add("message556"); + RetryRecordIds expectedMsgIds = new RetryRecordIds(Version.V1_0, expected); + sortedDao.putToMap(mapKey, newKey, expectedMsgIds, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_BUCKET); + Assert.assertEquals(expected, sortedDao.get(newKey).getRecordIds()); + } + + /** + * Test delete key. + */ + @Test + public void testDeleteKey() { + Assert.assertEquals(messageIdsSet1, sortedDao.get(key1).getRecordIds()); + sortedDao.deleteFromMap(mapKey, key1, Optional.empty(), InternalCacheConstants.CACHE_TYPE_RETRY_BUCKET); + Assert.assertNull(sortedDao.get(key1)); + } + + /** + * Test delete long string. + */ + @Test + public void testDeleteLongString() { + String mapKey = RetryBucketKey.getMapKey(serviceName, taskId); + sortedDao.deleteMessageId(mapKey, key1, "message124"); + // Cannot delte elemt that is not present + Assert.assertEquals(retryMsgIds1, sortedDao.get(key1)); + + sortedDao.deleteMessageId(mapKey, key1, "message123"); + Set expected = new HashSet(); + expected.add("message456"); + // Element Deleted + Assert.assertEquals(expected, sortedDao.get(key1).getRecordIds()); + } + + /** + * Test get head map long boolean. + */ + @Test + public void testGetHeadMapLongBoolean() { + + // Including key3 + Set expectedKeySet = new HashSet(); + expectedKeySet.add(key2); + expectedKeySet.add(key4); + expectedKeySet.add(key1); + expectedKeySet.add(key3); + + Set actualKeySet = new HashSet(); + + KeyValueIterator headMapItr1 = sortedDao.getHead(key3); + while (headMapItr1.hasNext()) { + KeyValue keyValue = headMapItr1.next(); + RetryBucketKey timestamp = keyValue.key; + actualKeySet.add(timestamp); + } + Assert.assertEquals(expectedKeySet, actualKeySet); + + expectedKeySet = new HashSet(); + expectedKeySet.add(key2); + expectedKeySet.add(key4); + expectedKeySet.add(key1); + actualKeySet = new HashSet(); + KeyValueIterator headMapItr2 = sortedDao + .getHead((new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_4000))); + while (headMapItr2.hasNext()) { + KeyValue keyValue = headMapItr2.next(); + RetryBucketKey timestamp = keyValue.key; + actualKeySet.add(timestamp); + } + Assert.assertEquals(expectedKeySet, actualKeySet); + + } + + /** + * Test get tail map long boolean. + */ + @Test + public void testGetTailMapLongBoolean() { + Set expectedKeySet = new HashSet(); + + expectedKeySet = new HashSet(); + expectedKeySet.add(key1); + expectedKeySet.add(key3); + Set actualKeySet = new HashSet(); + KeyValueIterator headMapItr1 = sortedDao.getTail(key1); + while (headMapItr1.hasNext()) { + KeyValue keyValue = headMapItr1.next(); + RetryBucketKey timestamp = keyValue.key; + actualKeySet.add(timestamp); + } + Assert.assertEquals(expectedKeySet, actualKeySet); + + } + + /** + * Test get sub map long boolean. + */ + @Test + public void testGetSubMapLongBoolean() { + Set expectedKeySet = new HashSet(); + + expectedKeySet = new HashSet(); + expectedKeySet.add(key4); + expectedKeySet.add(key1); + expectedKeySet.add(key3); + Set actualKeySet = new HashSet(); + KeyValueIterator subMapItr = sortedDao.range(key4, key3); + while (subMapItr.hasNext()) { + KeyValue keyValue = subMapItr.next(); + RetryBucketKey timestamp = keyValue.key; + actualKeySet.add(timestamp); + } + Assert.assertEquals(expectedKeySet, actualKeySet); + } + + /** + * Test get long. + */ + @Test + public void testGetLong() { + Assert.assertEquals(retryMsgIds1, sortedDao.get(key1)); + Assert.assertEquals(retryMsgIds2, sortedDao.get(key2)); + Assert.assertEquals(retryMsgIds3, sortedDao.get(key3)); + Assert.assertEquals(retryMsgIds4, sortedDao.get(key4)); + } + + /** + * Test sync with map cache. + */ + @Test + public void testSyncWithMapCache() { + Map map = new HashMap(); + map.put("123", retryMsgIds1); + map.put("223", retryMsgIds2); + sortedDao.setServiceName(serviceName); + Mockito.when(cache.getMapOfEntities(Mockito.any(GetMapOfEntitiesRequest.class))).thenReturn(map); + sortedDao.initialize("taskId"); + Assert.assertEquals(retryMsgIds1, sortedDao.get(new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_123))); + Assert.assertEquals(retryMsgIds2, sortedDao.get(new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_223))); + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DMARetryRecordDAOCacheBackedInMemoryImplTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DMARetryRecordDAOCacheBackedInMemoryImplTest.java new file mode 100644 index 0000000..53f5672 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DMARetryRecordDAOCacheBackedInMemoryImplTest.java @@ -0,0 +1,196 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.stores.CacheBypass; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.InternalCacheConstants; +import org.eclipse.ecsp.cache.GetMapOfEntitiesRequest; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEntity; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.RetryRecord; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.stream.dma.dao.DMARetryRecordDAOCacheBackedInMemoryImpl; +import org.eclipse.ecsp.stream.dma.dao.key.RetryRecordKey; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.ecsp.utils.metrics.InternalCacheGuage; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Value; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + + +/** + * DMARetryRecordDAOCacheBackedInMemoryImplTest UT class for {@link DMARetryRecordDAOCacheBackedInMemoryImpl}. + */ +public class DMARetryRecordDAOCacheBackedInMemoryImplTest { + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The map key. */ + private String mapKey; + + /** The dao. */ + @InjectMocks + private DMARetryRecordDAOCacheBackedInMemoryImpl dao; + + /** The cache. */ + @Mock + private IgniteCache cache; + + /** The bypass. */ + @Mock + private CacheBypass bypass; + + /** The cache guage. */ + @Mock + private InternalCacheGuage cacheGuage; + + /** The ignite key. */ + private IgniteStringKey igniteKey = new IgniteStringKey(); + + /** The entity. */ + private DeviceMessage entity; + + /** The message id. */ + private String messageId = "message123"; + + /** The task id. */ + private String taskId = "taskId"; + + /** The message id key. */ + private RetryRecordKey messageIdKey = new RetryRecordKey(messageId, taskId); + + /** The message id 2. */ + private String messageId2 = "message456"; + + /** The message id 2 key. */ + private RetryRecordKey messageId2Key = new RetryRecordKey(messageId2, taskId); + + /** The transformer. */ + private DeviceMessageIgniteEventTransformer transformer = new DeviceMessageIgniteEventTransformer(); + + /** + * setup(). + */ + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + mapKey = RetryRecordKey.getMapKey(serviceName, taskId); + dao.initialize(taskId); + igniteKey.setKey("abc"); + IgniteEventImpl igniteEvent = new IgniteEventImpl(); + igniteEvent.setEventId("test"); + + entity = new DeviceMessage(transformer.toBlob(igniteEvent), Version.V1_0, + igniteEvent, "testTopic", Constants.THREAD_SLEEP_TIME_60000); + } + + /** + * Test put string DMA retry event. + */ + @Test + public void testPutStringDMARetryEvent() { + RetryRecord retryEvent = new RetryRecord(igniteKey, entity, 0L); + dao.putToMap(mapKey, messageIdKey, retryEvent, + Optional.empty(), InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + Assert.assertEquals(retryEvent, dao.get(messageIdKey)); + } + + /** + * Test delete string. + */ + @Test + public void testDeleteString() { + RetryRecord retryEvent = new RetryRecord(igniteKey, entity, 0L); + dao.putToMap(mapKey, messageIdKey, retryEvent, + Optional.empty(), InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + dao.deleteFromMap(mapKey, messageIdKey, Optional.empty(), InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + Assert.assertNull(dao.get(messageIdKey)); + } + + /** + * Test get. + */ + @Test + public void testGet() { + RetryRecord retryEvent = new RetryRecord(igniteKey, entity, 0L); + dao.putToMap(mapKey, messageIdKey, retryEvent, + Optional.empty(), InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + igniteKey.setKey("bcd"); + RetryRecord retryEvent2 = new RetryRecord(igniteKey, entity, 0L); + dao.putToMap(mapKey, messageId2Key, retryEvent2, + Optional.empty(), InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + Assert.assertEquals(retryEvent, dao.get(messageIdKey)); + Assert.assertEquals(retryEvent2, dao.get(messageId2Key)); + } + + /** + * Test sync with cache. + */ + @Test + public void testSyncWithCache() { + Map map = new HashMap(); + map.put("taskId:abc", entity); + map.put("taskId:bcd", entity); + dao.setServiceName(serviceName); + Mockito.when(cache.getMapOfEntities(Mockito.any(GetMapOfEntitiesRequest.class))).thenReturn(map); + dao.initialize(taskId); + Object result = dao.get(new RetryRecordKey("abc", taskId)); + Assert.assertEquals(entity, result); + Object result2 = dao.get(new RetryRecordKey("bcd", taskId)); + Assert.assertEquals(entity, result2); + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DMAShoulderTapRetryBucketDAOCacheBackedInMemoryImplTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DMAShoulderTapRetryBucketDAOCacheBackedInMemoryImplTest.java new file mode 100644 index 0000000..e63dbea --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DMAShoulderTapRetryBucketDAOCacheBackedInMemoryImplTest.java @@ -0,0 +1,369 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.stores.CacheBypass; +import org.eclipse.ecsp.analytics.stream.base.utils.InternalCacheConstants; +import org.eclipse.ecsp.cache.GetMapOfEntitiesRequest; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEntity; +import org.eclipse.ecsp.entities.dma.RetryRecordIds; +import org.eclipse.ecsp.stream.dma.dao.ShoulderTapRetryBucketDAOCacheImpl; +import org.eclipse.ecsp.stream.dma.dao.key.ShoulderTapRetryBucketKey; +import org.eclipse.ecsp.utils.ConcurrentHashSet; +import org.eclipse.ecsp.utils.metrics.InternalCacheGuage; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Value; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + + +/** + * UT class {@link DMAShoulderTapRetryBucketDAOCacheBackedInMemoryImplTest}. + */ +public class DMAShoulderTapRetryBucketDAOCacheBackedInMemoryImplTest { + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The map key. */ + private String mapKey; + + /** The sorted dao. */ + @InjectMocks + private ShoulderTapRetryBucketDAOCacheImpl sortedDao; + + /** The cache. */ + @Mock + private IgniteCache cache; + + /** The bypass. */ + @Mock + private CacheBypass bypass; + + /** The cache guage. */ + @Mock + private InternalCacheGuage cacheGuage; + + /** The key 1. */ + private ShoulderTapRetryBucketKey key1; + + /** The key 2. */ + private ShoulderTapRetryBucketKey key2; + + /** The key 3. */ + private ShoulderTapRetryBucketKey key3; + + /** The key 4. */ + private ShoulderTapRetryBucketKey key4; + + /** The retry vehicle ids 1. */ + private RetryRecordIds retryVehicleIds1; + + /** The retry vehicle ids 2. */ + private RetryRecordIds retryVehicleIds2; + + /** The retry vehicle ids 3. */ + private RetryRecordIds retryVehicleIds3; + + /** The retry vehicle ids 4. */ + private RetryRecordIds retryVehicleIds4; + + /** The vehicle ids set 1. */ + private ConcurrentHashSet vehicleIdsSet1; + + /** The vehicle ids set 2. */ + private ConcurrentHashSet vehicleIdsSet2; + + /** The vehicle ids set 3. */ + private ConcurrentHashSet vehicleIdsSet3; + + /** The vehicle ids set 4. */ + private ConcurrentHashSet vehicleIdsSet4; + + /** + * setup(). + */ + @Before + public void setup() { + key1 = new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_3000); + key2 = new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_1000); + key3 = new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_5000); + key4 = new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_1500); + + MockitoAnnotations.initMocks(this); + mapKey = ShoulderTapRetryBucketKey.getMapKey(serviceName, "taskId"); + sortedDao.initialize("taskId"); + vehicleIdsSet1 = new ConcurrentHashSet(); + vehicleIdsSet1.add("message123"); + vehicleIdsSet1.add("message456"); + retryVehicleIds1 = new RetryRecordIds(Version.V1_0, vehicleIdsSet1); + + vehicleIdsSet2 = new ConcurrentHashSet(); + vehicleIdsSet2.add("message223"); + vehicleIdsSet2.add("message256"); + retryVehicleIds2 = new RetryRecordIds(Version.V1_0, vehicleIdsSet2); + + vehicleIdsSet3 = new ConcurrentHashSet(); + vehicleIdsSet3.add("message323"); + vehicleIdsSet3.add("message356"); + retryVehicleIds3 = new RetryRecordIds(Version.V1_0, vehicleIdsSet3); + + vehicleIdsSet4 = new ConcurrentHashSet(); + vehicleIdsSet4.add("message423"); + vehicleIdsSet4.add("message456"); + retryVehicleIds4 = new RetryRecordIds(Version.V1_0, vehicleIdsSet4); + + sortedDao.putToMap(mapKey, key1, retryVehicleIds1, + Optional.empty(), InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_BUCKET); + sortedDao.putToMap(mapKey, key2, retryVehicleIds2, + Optional.empty(), InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_BUCKET); + sortedDao.putToMap(mapKey, key3, retryVehicleIds3, + Optional.empty(), InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_BUCKET); + sortedDao.putToMap(mapKey, key4, retryVehicleIds4, + Optional.empty(), InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_BUCKET); + + } + + /** + * Cleanup. + */ + @After + public void cleanup() { + sortedDao.close(); + } + + /** + * Test update. + */ + @Test + public void testUpdate() { + Assert.assertEquals(vehicleIdsSet1, sortedDao.get(key1).getRecordIds()); + sortedDao.update(mapKey, key1, "message556"); + + Set expected = new HashSet(); + expected.add("message123"); + expected.add("message456"); + expected.add("message556"); + + Assert.assertEquals(expected, sortedDao.get(key1).getRecordIds()); + + ShoulderTapRetryBucketKey newKey = new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_6000); + Assert.assertNull(sortedDao.get(newKey)); + sortedDao.update(mapKey, newKey, "message556"); + + expected = new HashSet(); + expected.add("message556"); + + Assert.assertEquals(expected, sortedDao.get(newKey).getRecordIds()); + + } + + /** + * Test put long set of string. + */ + @Test + public void testPutLongSetOfString() { + ShoulderTapRetryBucketKey newKey = new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_6000); + Assert.assertNull(sortedDao.get(newKey)); + ConcurrentHashSet expected = new ConcurrentHashSet(); + expected.add("message123"); + expected.add("message456"); + expected.add("message556"); + RetryRecordIds expectedMsgIds = new RetryRecordIds(Version.V1_0, expected); + sortedDao.putToMap(mapKey, newKey, expectedMsgIds, + Optional.empty(), InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_BUCKET); + Assert.assertEquals(expected, sortedDao.get(newKey).getRecordIds()); + } + + /** + * Test delete key. + */ + @Test + public void testDeleteKey() { + Assert.assertEquals(vehicleIdsSet1, sortedDao.get(key1).getRecordIds()); + sortedDao.deleteFromMap(mapKey, key1, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_SHOULDER_TAP_RETRY_BUCKET); + Assert.assertNull(sortedDao.get(key1)); + } + + /** + * Test delete long string. + */ + @Test + public void testDeleteLongString() { + sortedDao.deleteVehicleId(mapKey, key1, "message124"); + // Cannot delte elemt that is not present + Assert.assertEquals(retryVehicleIds1, sortedDao.get(key1)); + + sortedDao.deleteVehicleId(mapKey, key1, "message123"); + Set expected = new HashSet(); + expected.add("message456"); + // Element Deleted + Assert.assertEquals(expected, sortedDao.get(key1).getRecordIds()); + } + + /** + * Test get head map long boolean. + */ + @Test + public void testGetHeadMapLongBoolean() { + + // Including key3 + Set expectedKeySet = new HashSet(); + expectedKeySet.add(key2); + expectedKeySet.add(key4); + expectedKeySet.add(key1); + expectedKeySet.add(key3); + + Set actualKeySet = new HashSet(); + KeyValueIterator headMapItr1 = sortedDao.getHead(key3); + + while (headMapItr1.hasNext()) { + KeyValue keyValue = headMapItr1.next(); + ShoulderTapRetryBucketKey timestamp = keyValue.key; + actualKeySet.add(timestamp); + } + Assert.assertEquals(expectedKeySet, actualKeySet); + + expectedKeySet = new HashSet(); + expectedKeySet.add(key2); + expectedKeySet.add(key4); + expectedKeySet.add(key1); + actualKeySet = new HashSet(); + KeyValueIterator headMapItr2 = sortedDao + .getHead((new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_4000))); + while (headMapItr2.hasNext()) { + KeyValue keyValue = headMapItr2.next(); + ShoulderTapRetryBucketKey timestamp = keyValue.key; + actualKeySet.add(timestamp); + } + Assert.assertEquals(expectedKeySet, actualKeySet); + + } + + /** + * Test get tail map long boolean. + */ + @Test + public void testGetTailMapLongBoolean() { + Set expectedKeySet = new HashSet(); + + expectedKeySet = new HashSet(); + expectedKeySet.add(key1); + expectedKeySet.add(key3); + Set actualKeySet = new HashSet(); + KeyValueIterator headMapItr1 = sortedDao.getTail(key1); + while (headMapItr1.hasNext()) { + KeyValue keyValue = headMapItr1.next(); + ShoulderTapRetryBucketKey timestamp = keyValue.key; + actualKeySet.add(timestamp); + } + Assert.assertEquals(expectedKeySet, actualKeySet); + + } + + /** + * Test get sub map long boolean. + */ + @Test + public void testGetSubMapLongBoolean() { + Set expectedKeySet = new HashSet(); + + expectedKeySet = new HashSet(); + expectedKeySet.add(key4); + expectedKeySet.add(key1); + expectedKeySet.add(key3); + Set actualKeySet = new HashSet(); + KeyValueIterator subMapItr = sortedDao.range(key4, key3); + while (subMapItr.hasNext()) { + KeyValue keyValue = subMapItr.next(); + ShoulderTapRetryBucketKey timestamp = keyValue.key; + actualKeySet.add(timestamp); + } + Assert.assertEquals(expectedKeySet, actualKeySet); + } + + /** + * Test get long. + */ + @Test + public void testGetLong() { + Assert.assertEquals(retryVehicleIds1, sortedDao.get(key1)); + Assert.assertEquals(retryVehicleIds2, sortedDao.get(key2)); + Assert.assertEquals(retryVehicleIds3, sortedDao.get(key3)); + Assert.assertEquals(retryVehicleIds4, sortedDao.get(key4)); + } + + /** + * Test sync with cache. + */ + @Test + public void testSyncWithCache() { + Map map = new HashMap(); + map.put("123", retryVehicleIds1); + map.put("223", retryVehicleIds2); + sortedDao.setServiceName(serviceName); + Mockito.when(cache.getMapOfEntities(Mockito.any(GetMapOfEntitiesRequest.class))).thenReturn(map); + sortedDao.initialize("taskId"); + Assert.assertEquals(retryVehicleIds1, sortedDao.get( + new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_123))); + Assert.assertEquals(retryVehicleIds2, sortedDao.get( + new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_223))); + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DMNextTtlExpirationTimerTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DMNextTtlExpirationTimerTest.java new file mode 100644 index 0000000..f875630 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DMNextTtlExpirationTimerTest.java @@ -0,0 +1,65 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.stream.dma.dao.DMNextTtlExpirationTimer; +import org.junit.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/** + * class DMNextTtlExpirationTimerTest. + */ +public class DMNextTtlExpirationTimerTest { + + /** + * Test get id. + */ + @Test + public void testGetId() { + DMNextTtlExpirationTimer dmNextTtlExpirationTimer = new DMNextTtlExpirationTimer(); + dmNextTtlExpirationTimer.setTtlExpirationTimer(Constants.THREAD_SLEEP_TIME_1000); + dmNextTtlExpirationTimer.setId("1"); + assertEquals(Constants.THREAD_SLEEP_TIME_1000, dmNextTtlExpirationTimer.getTtlExpirationTimer()); + assertEquals(new String("1"), dmNextTtlExpirationTimer.getId()); + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DMOfflineBufferIntegrationTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DMOfflineBufferIntegrationTest.java new file mode 100644 index 0000000..814064e --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DMOfflineBufferIntegrationTest.java @@ -0,0 +1,348 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.ByteArraySerializer; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.entities.AbstractIgniteEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntry; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntryDAOMongoImpl; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusService; +import org.eclipse.ecsp.stream.dma.handler.DeviceConnectionStatusHandler; +import org.eclipse.ecsp.stream.dma.handler.DeviceStatusBackDoorKafkaConsumer; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + + +/** + * class DMOfflineBufferIntegrationTest extends KafkaStreamsApplicationTestBase. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@TestPropertySource("/dma-offline-test.properties") +public class DMOfflineBufferIntegrationTest extends KafkaStreamsApplicationTestBase { + + /** The service name. */ + @Value("${service.name}") + private String serviceName; + + /** The source topic. */ + @Value("${source.topic.name}") + private String sourceTopic; + + /** The mqtt prefix. */ + @Value("${mqtt.service.topic.name.prefix}") + private String mqttPrefix; + + /** The to device. */ + @Value("${" + PropertyNames.MQTT_TOPIC_TO_DEVICE_INFIX + ":" + Constants.TO_DEVICE + "}") + private String toDevice; + + /** The mqtt topic. */ + @Value("${mqtt.service.topic.name}") + private String mqttTopic; + + /** The device service. */ + @Autowired + private DeviceStatusService deviceService; + + /** The offline buffer DAO. */ + @Autowired + private DMOfflineBufferEntryDAOMongoImpl offlineBufferDAO; + + /** The device status back door kafka consumer. */ + @Autowired + DeviceStatusBackDoorKafkaConsumer deviceStatusBackDoorKafkaConsumer; + + /** The device connection status handler. */ + @Autowired + DeviceConnectionStatusHandler deviceConnectionStatusHandler; + + /** The device status topic name. */ + private String deviceStatusTopicName; + + /** The vehicle id. */ + private String vehicleId = "Vehicle12345"; + + /** + * setUp(). + * + * @throws Exception setUp() + * @throws MqttException setUp() + */ + @Before + public void init() throws Exception, MqttException { + super.setup(); + deviceStatusTopicName = DMAConstants.DEVICE_STATUS_TOPIC_PREFIX + serviceName.toLowerCase(); + createTopics(sourceTopic, deviceStatusTopicName); + Properties kafkaConsumerProps = deviceStatusBackDoorKafkaConsumer.getKafkaConsumerProps(); + kafkaConsumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + deviceStatusBackDoorKafkaConsumer.addCallback(deviceConnectionStatusHandler.new DeviceStatusCallBack(), 0); + deviceStatusBackDoorKafkaConsumer.startBackDoorKafkaConsumer(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + launchApplication(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + subscibeToMqttTopic(mqttPrefix + "12345" + toDevice + "/" + mqttTopic); + } + + /** + * Test offliner buffer removals on device active. + * + * @throws ExecutionException the execution exception + * @throws InterruptedException the interrupted exception + * @throws TimeoutException the timeout exception + */ + @Test + public void testOfflinerBufferRemovalsOnDeviceActive() + throws ExecutionException, InterruptedException, TimeoutException { + Properties producerProps = new Properties(); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + String deviceInactive = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\",\"Data\": " + + "{\"connStatus\":\"INACTIVE\",\"serviceName\":\"eCall\"},\"MessageId\": \"1234\",\"VehicleId\": " + + "\"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + sendMessages(deviceStatusTopicName, producerProps, + Arrays.asList(vehicleId.getBytes(), deviceInactive.getBytes())); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + assertNull(deviceService.get(vehicleId, Optional.empty())); + String speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":20.0}," + + "\"MessageId\": \"1234\",\"CorrelationId\": \"1234\",\"BizTransactionId\": \"Biz1234\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + sendMessages(sourceTopic, producerProps, + Arrays.asList(vehicleId.getBytes(), speedEvent.getBytes())); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + List bufferEntries = offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, + false, Optional.empty(), Optional.empty()); + assertEquals("Expected one entry", 1, bufferEntries.size()); + String deviceActive = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\",\"Data\": " + + "{\"connStatus\":\"ACTIVE\",\"serviceName\":\"eCall\"},\"MessageId\": \"1234\",\"VehicleId\": " + + "\"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + sendMessages(deviceStatusTopicName, producerProps, + Arrays.asList(vehicleId.getBytes(), deviceActive.getBytes())); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + List bufferEntries2 = offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, + false, Optional.empty(), Optional.empty()); + assertEquals("Expected 0 entry", 0, bufferEntries2.size()); + String completeMqttTopic = mqttPrefix + "12345" + toDevice + "/" + mqttTopic; + List messages = getMessagesFromMqttTopic(completeMqttTopic, 1, Constants.THREAD_SLEEP_TIME_60000); + assertEquals("No of message expected", 1, messages.size()); + } + + /** + * Tear down. + */ + @After + public void tearDown() { + deviceStatusBackDoorKafkaConsumer.shutdown(); + } + + /** + * Test device id not matched. + * + * @throws ExecutionException the execution exception + * @throws InterruptedException the interrupted exception + * @throws TimeoutException the timeout exception + */ + @Test + public void testDeviceIdNotMatched() throws ExecutionException, InterruptedException, TimeoutException { + Properties producerProps = new Properties(); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + String deviceInactive = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\",\"Data\": " + + "{\"connStatus\":\"INACTIVE\",\"serviceName\":\"eCall\"},\"MessageId\": \"1234\",\"VehicleId\": " + + "\"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + sendMessages(deviceStatusTopicName, producerProps, + Arrays.asList(vehicleId.getBytes(), deviceInactive.getBytes())); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + assertNull(deviceService.get(vehicleId, Optional.empty())); + String speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": " + + "{\"value\":20.0},\"MessageId\": \"1234\",\"CorrelationId\": \"1234\",\"BizTransactionId\":" + + " \"Biz1234\",\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + sendMessages(sourceTopic, producerProps, + Arrays.asList(vehicleId.getBytes(), speedEvent.getBytes())); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":20.0}," + + "\"MessageId\": \"12345\",\"CorrelationId\": \"12345\",\"BizTransactionId\": \"Biz12345\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"123456\"}"; + deviceInactive = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\"," + + "\"Data\": {\"connStatus\":\"INACTIVE\",\"serviceName\":\"eCall\"}," + + "\"MessageId\": \"12345\",\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"123456\"}"; + sendMessages(deviceStatusTopicName, producerProps, + Arrays.asList(vehicleId.getBytes(), deviceInactive.getBytes())); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + assertNull(deviceService.get(vehicleId, Optional.empty())); + sendMessages(sourceTopic, producerProps, + Arrays.asList(vehicleId.getBytes(), speedEvent.getBytes())); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + List bufferEntries = offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, + false, Optional.empty(), Optional.empty()); + assertEquals("Expected two entry", TestConstants.TWO, bufferEntries.size()); + String deviceActive = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\"," + + "\"Data\": {\"connStatus\":\"ACTIVE\",\"serviceName\":\"eCall\"}," + + "\"MessageId\": \"1234\",\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + sendMessages(deviceStatusTopicName, producerProps, + Arrays.asList(vehicleId.getBytes(), deviceActive.getBytes())); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + List bufferEntries2 = offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, + false, Optional.empty(), Optional.empty()); + assertEquals("Expected 1 entry", 1, bufferEntries2.size()); + String completeMqttTopic = mqttPrefix + "12345" + toDevice + "/" + mqttTopic; + List messages = getMessagesFromMqttTopic(completeMqttTopic, 1, Constants.THREAD_SLEEP_TIME_60000); + assertEquals("No of message expected", 1, messages.size()); + } + + /** + * inner class {@link DMOfflineBufferTestStreamProcessor} implements IgniteEventStreamProcessor. + */ + public static class DMOfflineBufferTestStreamProcessor implements IgniteEventStreamProcessor { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "dma-sp"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + IgniteEvent value = kafkaRecord.value(); + if (!value.getEventId().equals(EventID.DEVICEMESSAGEFAILURE)) { + ((AbstractIgniteEvent) value).setDeviceRoutable(true); + kafkaRecord.withValue(value); + spc.forward(kafkaRecord); + } + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + } + + /** + * Close. + */ + @Override + public void close() { + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DMOfflineBufferMultipleDevicesIntegrationTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DMOfflineBufferMultipleDevicesIntegrationTest.java new file mode 100644 index 0000000..04b98a7 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DMOfflineBufferMultipleDevicesIntegrationTest.java @@ -0,0 +1,312 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.ByteArraySerializer; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.entities.AbstractIgniteEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntry; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntryDAOMongoImpl; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusService; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + + +/** + * class DMOfflineBufferMultipleDevicesIntegrationTest extends KafkaStreamsApplicationTestBase. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@TestPropertySource("/dma-offline-multiple-device-test.properties") +public class DMOfflineBufferMultipleDevicesIntegrationTest extends KafkaStreamsApplicationTestBase { + + /** The service name. */ + @Value("${service.name}") + private String serviceName; + + /** The source topic. */ + @Value("${source.topic.name}") + private String sourceTopic; + + /** The mqtt prefix. */ + @Value("${mqtt.service.topic.name.prefix}") + private String mqttPrefix; + + /** The to device. */ + @Value("${" + PropertyNames.MQTT_TOPIC_TO_DEVICE_INFIX + ":" + Constants.TO_DEVICE + "}") + private String toDevice; + + /** The mqtt topic. */ + @Value("${mqtt.service.topic.name}") + private String mqttTopic; + + /** The device service. */ + @Autowired + private DeviceStatusService deviceService; + + /** The offline buffer DAO. */ + @Autowired + private DMOfflineBufferEntryDAOMongoImpl offlineBufferDAO; + + /** The device status topic name. */ + private String deviceStatusTopicName; + + /** + * setUp(). + * + * @throws Exception Exception + * @throws MqttException MqttException + */ + @Before + public void setUp() throws Exception, MqttException { + super.setup(); + deviceStatusTopicName = DMAConstants.DEVICE_STATUS_TOPIC_PREFIX + serviceName.toLowerCase(); + createTopics(sourceTopic, deviceStatusTopicName); + launchApplication(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + subscibeToMqttTopic(mqttPrefix + "12345" + toDevice + "/" + mqttTopic); + } + + /** + * Shutdown. + */ + @After + public void shutdown() { + shutDownApplication(); + } + + /** + * test(). + * + * @throws ExecutionException ExecutionException + * @throws InterruptedException InterruptedException + * @throws TimeoutException TimeoutException + */ + // @Test + public void test() throws ExecutionException, InterruptedException, TimeoutException { + Properties producerProps = new Properties(); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + String vehicleId = "Vehicle12345"; + String deviceInactive = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\",\"Data\": " + + "{\"connStatus\":\"INACTIVE\",\"serviceName\":\"eCall\"},\"MessageId\": \"1234\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + sendMessages(deviceStatusTopicName, producerProps, + Arrays.asList(vehicleId.getBytes(), deviceInactive.getBytes())); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + assertNull(deviceService.get(vehicleId, null)); + String speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": " + + "{\"value\":20.0},\"MessageId\": \"1234\",\"CorrelationId\": \"1234\",\"BizTransactionId\": " + + "\"Biz1234\",\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + sendMessages(sourceTopic, producerProps, + Arrays.asList(vehicleId.getBytes(), speedEvent.getBytes())); + String deviceId = "12345"; + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + List bufferEntries = offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, + false, Optional.ofNullable(deviceId), Optional.empty()); + assertEquals("Expected one entry", 1, bufferEntries.size()); + String deviceActive = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\",\"Data\": " + + "{\"connStatus\":\"ACTIVE\",\"serviceName\":\"eCall\"},\"MessageId\": \"1234\",\"VehicleId\": " + + "\"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + sendMessages(deviceStatusTopicName, producerProps, + Arrays.asList(vehicleId.getBytes(), deviceActive.getBytes())); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + List bufferEntries2 = offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, + false, Optional.ofNullable(deviceId), Optional.empty()); + assertEquals("Expected 0 entry", 0, bufferEntries2.size()); + String completeMqttTopic = mqttPrefix + deviceId + toDevice + "/" + mqttTopic; + List messages = getMessagesFromMqttTopic(completeMqttTopic, 1, Constants.THREAD_SLEEP_TIME_60000); + assertEquals("No of message expected", 1, messages.size()); + } + + /** + * Test when target device id not in payload. + * + * @throws ExecutionException the execution exception + * @throws InterruptedException the interrupted exception + * @throws TimeoutException the timeout exception + */ + @Test + public void testWhenTargetDeviceIdNotInPayload() throws ExecutionException, InterruptedException, TimeoutException { + Properties producerProps = new Properties(); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + String deviceInactive = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\",\"Data\": " + + "{\"connStatus\":\"INACTIVE\",\"serviceName\":\"eCall\"},\"MessageId\": \"1234\",\"VehicleId\": " + + "\"Vehicle12346\",\"SourceDeviceId\": \"12346\"}"; + String vehicleId = "Vehicle12346"; + sendMessages(deviceStatusTopicName, producerProps, + Arrays.asList(vehicleId.getBytes(), deviceInactive.getBytes())); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + assertNull(deviceService.get(vehicleId, Optional.empty())); + String speedEventWithoutDeviceId = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": " + + "{\"value\":20.0},\"MessageId\": \"1234\",\"CorrelationId\": \"1234\",\"BizTransactionId\": " + + "\"Biz1234\",\"VehicleId\": \"Vehicle12346\"}"; + sendMessages(sourceTopic, producerProps, + Arrays.asList(vehicleId.getBytes(), speedEventWithoutDeviceId.getBytes())); + String deviceId = "12346"; + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + List bufferEntriesWithDeviceId = offlineBufferDAO + .getOfflineBufferEntriesSortedByPriority(vehicleId, false, + Optional.ofNullable(deviceId), Optional.empty()); + assertEquals("Expected 0 entry", 0, bufferEntriesWithDeviceId.size()); + List bufferEntriesWithVehicleId = + offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, + false, Optional.empty(), Optional.empty()); + assertEquals("Expected 1 entry", 1, bufferEntriesWithVehicleId.size()); + + } + + /** + * DMOfflieBufferTestStreamProcessor implements IgniteEventStreamProcessor. + */ + public static class DMOfflineBufferTestStreamProcessor implements IgniteEventStreamProcessor { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "dma-sp"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + IgniteEvent value = kafkaRecord.value(); + if (!value.getEventId().equals(EventID.DEVICEMESSAGEFAILURE)) { + ((AbstractIgniteEvent) value).setDeviceRoutable(true); + spc.forward(kafkaRecord); + } + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + } + + /** + * Close. + */ + @Override + public void close() { + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DMOfflineBufferServiceImplTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DMOfflineBufferServiceImplTest.java new file mode 100644 index 0000000..7426cf0 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DMOfflineBufferServiceImplTest.java @@ -0,0 +1,394 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import dev.morphia.AdvancedDatastore; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.nosqldao.IgniteCriteria; +import org.eclipse.ecsp.nosqldao.IgniteCriteriaGroup; +import org.eclipse.ecsp.nosqldao.IgniteQuery; +import org.eclipse.ecsp.nosqldao.Operator; +import org.eclipse.ecsp.nosqldao.Updates; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntry; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntryDAOMongoImpl; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; + + +/** + * UT class for {@link DMOfflineBufferServiceImplTest}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@TestPropertySource("/dma-handler-test.properties") +public class DMOfflineBufferServiceImplTest { + + /** The Constant REDIS. */ + @ClassRule + public static final EmbeddedRedisServer REDIS = new EmbeddedRedisServer(); + + /** The mongo server. */ + @ClassRule + public static EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The offline buffer DAO. */ + @Autowired + private DMOfflineBufferEntryDAOMongoImpl offlineBufferDAO; + + /** The mongo datastore. */ + @Autowired + private AdvancedDatastore mongoDatastore; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** + * Clean up collection. + */ + @Before + public void cleanUpCollection() { + offlineBufferDAO.deleteAll(); + } + + /** + * Test offline buffer service. + */ + @Test + public void testOfflineBufferService() { + String vehicleId = "Vehicle1"; + for (int i = 0; i < Constants.FIVE; i++) { + IgniteEventImpl event = new IgniteEventImpl(); + event.setTargetDeviceId("Device1"); + event.setEventId(EventID.LOCATION); + event.setMessageId("Message_" + i); + event.setTimestamp(Constants.INT_4736565 + Constants.TEN * i); + event.setVehicleId(vehicleId); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), Version.V1_0, + event, "testTopic", Constants.THREAD_SLEEP_TIME_60000); + IgniteStringKey key = new IgniteStringKey(); + key.setKey(vehicleId); + offlineBufferDAO.addOfflineBufferEntry(vehicleId, key, entity, null); + } + List events = offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, true, + Optional.empty(), Optional.empty()); + events.forEach(event -> assertEquals("Wrong device fetched", vehicleId, event.getVehicleId())); + assertEquals("Service failed to get expected events", Constants.FIVE, events.size()); + + events.forEach(event -> offlineBufferDAO.removeOfflineBufferEntry(event.getId())); + List events2 = offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, true, + Optional.empty(), Optional.empty()); + assertEquals("Service failed to get expected events", 0, events2.size()); + } + + /** + * Test get offline buffer entry with earliest ttl. + */ + @Test + public void testGetOfflineBufferEntryWithEarliestTtl() { + for (int i = 1; i < Constants.FIVE; i++) { + IgniteEventImpl event = new IgniteEventImpl(); + String vehicleId = "Vehicle" + i; + event.setTargetDeviceId("Device1"); + event.setEventId(EventID.LOCATION); + event.setMessageId("Message_" + i); + event.setTimestamp(Constants.INT_4736565 + Constants.TEN * i); + event.setVehicleId(vehicleId); + event.setDeviceDeliveryCutoff(Constants.INT_4792987 + Constants.TWENTY * i); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), Version.V1_0, + event, "testTopic", Constants.THREAD_SLEEP_TIME_60000); + offlineBufferDAO.addOfflineBufferEntry(vehicleId, new IgniteStringKey(String.valueOf(i)), entity, null); + } + DMOfflineBufferEntry entry = offlineBufferDAO.getOfflineBufferEntryWithEarliestTtl(); + IgniteEventImpl event = entry.getEvent().getEvent(); + assertEquals("Wrong device fetched", "Vehicle1", event.getVehicleId()); + assertEquals("Wrong offline entry fetched", Constants.INT_4792987 + + Constants.TWENTY * 1, entry.getTtlExpirationTime()); + } + + /** + * Test get offline buffer entries with expired ttl. + */ + @Test + public void testGetOfflineBufferEntriesWithExpiredTtl() { + long currTime = System.currentTimeMillis(); + IgniteEventImpl event; + for (int i = 1; i < Constants.SIX; i++) { + event = new IgniteEventImpl(); + String vehicleId = "Vehicle" + i; + event.setTargetDeviceId("Device1"); + event.setEventId(EventID.LOCATION); + event.setMessageId("Message_" + i); + event.setTimestamp(currTime); + event.setVehicleId(vehicleId); + event.setDeviceDeliveryCutoff(i % Constants.TWO == 0 ? currTime + - (Constants.TWENTY * Constants.THREAD_SLEEP_TIME_1000 * i) + : currTime + (Constants.TWENTY * Constants.THREAD_SLEEP_TIME_1000 * i)); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), Version.V1_0, event, "testTopic", + Constants.THREAD_SLEEP_TIME_60000); + offlineBufferDAO.addOfflineBufferEntry(vehicleId, new IgniteStringKey(String.valueOf(i)), entity, null); + // Add and update an entry with TTL notification processed flag as true + // This entry should not be returned by the method even though TTL has expired + IgniteEventImpl event2 = new IgniteEventImpl(); + String vehicleId7 = "Vehicle7"; + event2.setTargetDeviceId("Device1"); + event2.setEventId(EventID.LOCATION); + event2.setMessageId("Message_7"); + event2.setTimestamp(currTime); + event2.setVehicleId(vehicleId7); + event2.setDeviceDeliveryCutoff(currTime - (TestConstants.TWENTY * TestConstants.INT_1000)); + DeviceMessage entity2 = new DeviceMessage(transformer.toBlob(event2), + Version.V1_0, event2, "testTopic", TestConstants.THREAD_SLEEP_TIME_60000); + offlineBufferDAO.addOfflineBufferEntry(vehicleId7, + new IgniteStringKey(String.valueOf(TestConstants.SEVEN)), entity2, null); + + IgniteCriteria igCriteria = new IgniteCriteria("vehicleId", Operator.EQ, vehicleId7); + IgniteCriteriaGroup igniteCriteriaGroup = new IgniteCriteriaGroup(igCriteria); + IgniteQuery query = new IgniteQuery(igniteCriteriaGroup); + Updates u = new Updates(); + u.addFieldSet(DMAConstants.IS_TTL_NOTIF_PROCESSED_FIELD, true); + offlineBufferDAO.update(query, u); + } + + // add an entry with ttl as -1 i.e no TTL set by service. + // This record should not be considered expired, and not returned by the method + event = new IgniteEventImpl(); + String vehicleId = "Vehicle6"; + event.setTargetDeviceId("Device1"); + event.setEventId(EventID.LOCATION); + event.setMessageId("Message_6"); + event.setTimestamp(currTime); + event.setVehicleId(vehicleId); + event.setDeviceDeliveryCutoff(Constants.LONG_MINUS_ONE); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), Version.V1_0, + event, "testTopic", Constants.THREAD_SLEEP_TIME_60000); + offlineBufferDAO.addOfflineBufferEntry(vehicleId, + new IgniteStringKey(String.valueOf(Constants.SIX)), entity, null); + + List entries = offlineBufferDAO.getOfflineBufferEntriesWithExpiredTtl(); + List vehicleIdList = new ArrayList<>(); + for (DMOfflineBufferEntry entry : entries) { + vehicleIdList.add(entry.getVehicleId()); + } + + assertEquals("Incorrect number of entries returned", Constants.TWO, entries.size()); + assertEquals("Entry with expired TTL not returned", true, vehicleIdList.contains("Vehicle2")); + assertEquals("Entry with expired TTL not returned", true, vehicleIdList.contains("Vehicle4")); + assertEquals("Entry with TTL notification processed should not be returned", + false, vehicleIdList.contains("Vehicle7")); + } + + /** + * Test offline buffer service if sub services configured. + */ + @Test + public void testOfflineBufferServiceIfSubServicesConfigured() { + String vehicleId = "Vehicle1"; + String subService1 = "ecall/test/ftd"; + String subService2 = "ecall/test/ubi"; + for (int i = 0; i < Constants.FIVE; i++) { + IgniteEventImpl event = new IgniteEventImpl(); + event.setTargetDeviceId("Device1"); + event.setEventId(EventID.LOCATION); + event.setMessageId("Message_" + i); + event.setTimestamp(Constants.INT_4736565 + Constants.TEN * i); + event.setVehicleId(vehicleId); + event.setDevMsgTopicSuffix(subService1); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), Version.V1_0, + event, "testTopic", Constants.THREAD_SLEEP_TIME_60000); + IgniteStringKey key = new IgniteStringKey(); + key.setKey(vehicleId); + offlineBufferDAO.addOfflineBufferEntry(vehicleId, key, entity, subService1); + } + for (int i = Constants.FIVE; i < Constants.TEN; i++) { + IgniteEventImpl event = new IgniteEventImpl(); + event.setTargetDeviceId("Device1"); + event.setEventId(EventID.LOCATION); + event.setMessageId("Message_" + i); + event.setTimestamp(Constants.INT_4736565 + Constants.TEN * i); + event.setVehicleId(vehicleId); + event.setDevMsgTopicSuffix(subService2); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), Version.V1_0, + event, "testTopic", Constants.THREAD_SLEEP_TIME_60000); + IgniteStringKey key = new IgniteStringKey(); + key.setKey(vehicleId); + offlineBufferDAO.addOfflineBufferEntry(vehicleId, key, entity, subService2); + } + + List events = offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, true, + Optional.ofNullable("Device1"), Optional.of(subService1)); + + events.forEach(event -> assertEquals("Wrong device fetched", vehicleId, event.getVehicleId())); + events.forEach(event -> assertEquals(subService1, event.getSubService())); + assertEquals("Service failed to get expected events", Constants.FIVE, events.size()); + events.forEach(event -> offlineBufferDAO.removeOfflineBufferEntry(event.getId())); + List events2 = offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, true, + Optional.empty(), Optional.of(subService2)); + assertEquals("Service failed to get expected events", Constants.FIVE, events2.size()); + events2.forEach(event -> assertEquals(subService2, event.getSubService())); + } + + /** + * Test get offline buffer entries sorted by priority and device id. + */ + @Test + public void testGetOfflineBufferEntriesSortedByPriorityAndDeviceId() { + String deviceId1 = "DeviceId1"; + String deviceId2 = "DeviceId2"; + + String vehicleId = "Vehicle2"; + for (int i = 0; i < Constants.FIVE; i++) { + IgniteEventImpl event = new IgniteEventImpl(); + event.setTargetDeviceId(deviceId1); + event.setEventId(EventID.LOCATION); + event.setMessageId("Message_" + i); + event.setTimestamp(Constants.INT_4736565 + Constants.TEN * i); + event.setVehicleId(vehicleId); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), Version.V1_0, + event, "testTopic", Constants.THREAD_SLEEP_TIME_60000); + IgniteStringKey key = new IgniteStringKey(); + key.setKey(vehicleId); + offlineBufferDAO.addOfflineBufferEntry(vehicleId, key, entity, null); + + IgniteEventImpl event2 = new IgniteEventImpl(); + event2.setTargetDeviceId(deviceId2); + event2.setEventId(EventID.LOCATION); + event2.setMessageId("Message_" + i); + event2.setTimestamp(Constants.INT_4736565 + Constants.TEN * i); + event2.setVehicleId(vehicleId); + DeviceMessage entity2 = new DeviceMessage(transformer.toBlob(event2), Version.V1_0, + event2, "testTopic", Constants.THREAD_SLEEP_TIME_60000); + offlineBufferDAO.addOfflineBufferEntry(vehicleId, key, entity2, null); + + } + List eventsForDeviceId = offlineBufferDAO + .getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.ofNullable(deviceId1), Optional.empty()); + eventsForDeviceId.forEach(event -> assertEquals("Wrong device fetched", deviceId1, event.getDeviceId())); + assertEquals("Service failed to get expected events", Constants.FIVE, eventsForDeviceId.size()); + + List allEventsForVehicle = offlineBufferDAO + .getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.empty(), Optional.empty()); + allEventsForVehicle.forEach(event -> assertEquals("Wrong vehicle fetched", vehicleId, event.getVehicleId())); + assertEquals("Service failed to get expected events", Constants.TEN, allEventsForVehicle.size()); + } + + /** + * Test offline buffer service shoulder tap enabled. + */ + @Test + public void testOfflineBufferServiceShoulderTapEnabled() { + String vehicleId = "Vehicle3"; + for (int i = 0; i < Constants.FIVE; i++) { + IgniteEventImpl event = new IgniteEventImpl(); + event.setTargetDeviceId("Device1"); + event.setEventId(EventID.LOCATION); + event.setMessageId("Message_" + i); + event.setTimestamp(Constants.INT_4736565 + Constants.TEN * i); + event.setVehicleId(vehicleId); + + if (i % Constants.TWO == 0) { + event.setShoulderTapEnabled(true); + } + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), Version.V1_0, + event, "testTopic", Constants.THREAD_SLEEP_TIME_60000); + IgniteStringKey key = new IgniteStringKey(); + key.setKey(vehicleId); + offlineBufferDAO.addOfflineBufferEntry(vehicleId, key, entity, null); + } + + List events = offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, true, + Optional.empty(), Optional.empty()); + for (int index = 0; index < events.size(); index++) { + DMOfflineBufferEntry event = events.get(index); + + if (index < Constants.THREE) { + assertEquals("Device message is not shoulder tap enabled", true, + event.getEvent().getDeviceMessageHeader().isShoulderTapEnabled()); + assertEquals("Device message priority is not 10", Constants.TEN, event.getPriority()); + } else { + assertEquals("Device message priority is not 0", 0, event.getPriority()); + } + assertEquals("Wrong device fetched", vehicleId, event.getVehicleId()); + } + + events = offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, false, + Optional.empty(), Optional.empty()); + for (int index = 0; index < events.size(); index++) { + DMOfflineBufferEntry event = events.get(index); + + if (index < Constants.TWO) { + assertEquals("Device message priority is not 0", 0, event.getPriority()); + } else { + assertEquals("Device message is not shoulder tap enabled", true, + event.getEvent().getDeviceMessageHeader().isShoulderTapEnabled()); + assertEquals("Device message priority is not 10", Constants.TEN, event.getPriority()); + } + assertEquals("Wrong device fetched", vehicleId, event.getVehicleId()); + } + + assertEquals("Service failed to get expected events", Constants.FIVE, events.size()); + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DefaultDMAConfigResolverTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DefaultDMAConfigResolverTest.java new file mode 100644 index 0000000..0917f20 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DefaultDMAConfigResolverTest.java @@ -0,0 +1,70 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.stream.dma.config.DefaultDMAConfigResolver; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.springframework.test.context.TestPropertySource; + + +/** + * DefaultDMAConfigResolverTest UT class for {@link DefaultDMAConfigResolver}. + */ +@TestPropertySource("/dma-connectionstatus-handler-test.properties") +public class DefaultDMAConfigResolverTest { + + /** The default DMA config resolver. */ + @InjectMocks + DefaultDMAConfigResolver defaultDMAConfigResolver = new DefaultDMAConfigResolver(); + + /** + * Test get retry interval. + */ + @Test + public void testGetRetryInterval() { + IgniteEventImpl event = new IgniteEventImpl(); + event.setEventId("testId"); + long retryInt = defaultDMAConfigResolver.getRetryInterval(event); + Assert.assertEquals(0, retryInt); + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DeviceConnStatusDAOTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceConnStatusDAOTest.java new file mode 100644 index 0000000..063c6e5 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceConnStatusDAOTest.java @@ -0,0 +1,157 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.stores.CacheBypass; +import org.eclipse.ecsp.analytics.stream.base.utils.InternalCacheConstants; +import org.eclipse.ecsp.cache.GetMapOfEntitiesRequest; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEntity; +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdMapping; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusDaoCacheBackedInMemoryImpl; +import org.eclipse.ecsp.stream.dma.dao.key.DeviceStatusKey; +import org.eclipse.ecsp.utils.ConcurrentHashSet; +import org.eclipse.ecsp.utils.metrics.InternalCacheGuage; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Value; + +import java.util.HashMap; +import java.util.Map; + + +/** + * class DeviceConnStatusDAOTest UT {@link org.eclipse.ecsp.stream.dma.dao.DeviceConnStatusDAO}. + */ +public class DeviceConnStatusDAOTest { + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The device status cache backed in memory DAO. */ + @InjectMocks + private DeviceStatusDaoCacheBackedInMemoryImpl deviceStatusCacheBackedInMemoryDAO; + + /** The cache. */ + @Mock + private IgniteCache cache; + + /** The bypass. */ + @Mock + private CacheBypass bypass; + + /** The cache guage. */ + @Mock + private InternalCacheGuage cacheGuage; + + /** + * Setup. + */ + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + deviceStatusCacheBackedInMemoryDAO.initialize(); + } + + /** + * Close. + */ + @After + public void close() { + deviceStatusCacheBackedInMemoryDAO.close(); + } + + /** + * Test device status DAO. + */ + @Test + public void testDeviceStatusDAO() { + String key = "vehicleId12345"; + DeviceStatusKey deviceStatusKey = new DeviceStatusKey(key); + ConcurrentHashSet value = new ConcurrentHashSet(); + value.add("deviceId12345"); + VehicleIdDeviceIdMapping mapping = new VehicleIdDeviceIdMapping(Version.V1_0, value); + deviceStatusCacheBackedInMemoryDAO.putToMap(DeviceStatusKey + .getMapKey(serviceName), deviceStatusKey, mapping, null, + InternalCacheConstants.CACHE_TYPE_DEVICE_CONN_STATUS_CACHE); + + Assert.assertEquals(mapping, deviceStatusCacheBackedInMemoryDAO.get(deviceStatusKey)); + + deviceStatusCacheBackedInMemoryDAO.deleteFromMap(DeviceStatusKey.getMapKey(serviceName), deviceStatusKey, null, + InternalCacheConstants.CACHE_TYPE_DEVICE_CONN_STATUS_CACHE); + + Assert.assertNull(deviceStatusCacheBackedInMemoryDAO.get(deviceStatusKey)); + + } + + /** + * Test sync with cache. + */ + @Test + public void testSyncWithCache() { + ConcurrentHashSet value = new ConcurrentHashSet(); + value.add("deviceId12345"); + VehicleIdDeviceIdMapping mapping = new VehicleIdDeviceIdMapping(Version.V1_0, value); + + ConcurrentHashSet value2 = new ConcurrentHashSet(); + value.add("deviceId22345"); + VehicleIdDeviceIdMapping mapping2 = new VehicleIdDeviceIdMapping(Version.V1_0, value2); + + Map map = new HashMap(); + map.put("123", mapping); + map.put("223", mapping2); + deviceStatusCacheBackedInMemoryDAO.setServiceName(serviceName); + deviceStatusCacheBackedInMemoryDAO.setSubServices("ecall"); + Mockito.when(cache.getMapOfEntities(Mockito.any(GetMapOfEntitiesRequest.class))).thenReturn(map); + deviceStatusCacheBackedInMemoryDAO.initialize(); + Assert.assertNull(deviceStatusCacheBackedInMemoryDAO.get(new DeviceStatusKey("123"))); + Assert.assertNull(deviceStatusCacheBackedInMemoryDAO.get(new DeviceStatusKey("223"))); + + } +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DeviceConnStatusServiceTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceConnStatusServiceTest.java new file mode 100644 index 0000000..e926ee0 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceConnStatusServiceTest.java @@ -0,0 +1,247 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.cache.PutMapOfEntitiesRequest; +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdMapping; +import org.eclipse.ecsp.stream.dma.dao.DeviceConnStatusDAO; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusServiceImpl; +import org.eclipse.ecsp.stream.dma.dao.key.DeviceStatusKey; +import org.eclipse.ecsp.utils.ConcurrentHashSet; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + + +/** + * class DeviceConnStatusServiceTest extends KafkaStreamsApplicationTestBase. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/dma-handler-test.properties") +public class DeviceConnStatusServiceTest extends KafkaStreamsApplicationTestBase { + + /** The key. */ + String key = "vehicleId12345"; + + /** The device id 1. */ + String deviceId1 = "deviceId12345"; + + /** The device id 2. */ + String deviceId2 = "deviceId12346"; + + /** The device id 3. */ + String deviceId3 = "deviceId12347"; + + /** The sub service 1. */ + String subService1 = "ecall/test_service/ubi"; + + /** The sub service 2. */ + String subService2 = "ecall/test_service/ftd"; + + /** The device status service impl. */ + @Autowired + private DeviceStatusServiceImpl deviceStatusServiceImpl; + + /** The cache. */ + @Autowired + private IgniteCache cache; + + /** The device status DAO. */ + @Autowired + private DeviceConnStatusDAO deviceStatusDAO; + + /** The value. */ + private ConcurrentHashSet value; + + /** + * setUp(). + */ + @Before + public void setUp() { + value = new ConcurrentHashSet(); + value.add(deviceId1); + value.add(deviceId2); + value.add(deviceId3); + deviceStatusServiceImpl.put(key, value, null, Optional.empty()); + } + + /** + * Clear. + */ + @After + public void clear() { + deviceStatusServiceImpl.deleteKey(key, null); + } + + /** + * Test get device status service. + */ + @Test + public void testGetDeviceStatusService() { + Assert.assertEquals(value, deviceStatusServiceImpl.get(key, Optional.empty())); + } + + /** + * Test get device status service for sub service. + */ + @Test + public void testGetDeviceStatusServiceForSubService() { + clear(); + deviceStatusServiceImpl.put(key, value, null, Optional.of(subService1)); + Assert.assertEquals(value, deviceStatusServiceImpl.get(key, Optional.of(subService1))); + } + + /** + * Test delete single device id. + */ + @Test + public void testDeleteSingleDeviceId() { + String deviceIdToDelete = deviceId1; + deviceStatusServiceImpl.delete(key, deviceIdToDelete, null, Optional.empty()); + + ConcurrentHashSet valueExpected = new ConcurrentHashSet(); + valueExpected.add(deviceId2); + valueExpected.add(deviceId3); + + Assert.assertEquals(valueExpected, deviceStatusServiceImpl.get(key, Optional.empty())); + + } + + /** + * Test delete key. + */ + @Test + public void testDeleteKey() { + deviceStatusServiceImpl.deleteKey(key, null); + Assert.assertNull(deviceStatusServiceImpl.get(key, Optional.empty())); + } + + /** + * Test delete key for sub service. + */ + @Test + public void testDeleteKeyForSubService() { + ConcurrentHashSet value = new ConcurrentHashSet(); + value.add(deviceId2); + deviceStatusServiceImpl.put(key, value, null, Optional.of("subService1")); + + String deviceIdToDelete = deviceId2; + deviceStatusServiceImpl.delete(key, deviceIdToDelete, null, Optional.of("subService1")); + + Assert.assertNull(deviceStatusServiceImpl.get(key, Optional.of("subService1"))); + } + + /** + * Test force get. + */ + @Test + public void testForceGet() { + String key2 = "vehicleId12346"; + Assert.assertNull(deviceStatusServiceImpl.get(key2, Optional.empty())); + + DeviceStatusKey mapEntryKey = new DeviceStatusKey(key2); + String mapKey = "VEHICLE_DEVICE_MAPPING:Ecall"; + PutMapOfEntitiesRequest putRequest = + new PutMapOfEntitiesRequest(); + putRequest.withKey(mapKey); + Map map = new HashMap(); + VehicleIdDeviceIdMapping mapEntryValue = new VehicleIdDeviceIdMapping(); + mapEntryValue.addDeviceId(deviceId1); + map.put(mapEntryKey.convertToString(), mapEntryValue); + putRequest.withValue(map); + putRequest.withNamespaceEnabled(false); + cache.putMapOfEntities(putRequest); + + ConcurrentHashSet actualDeviceIds = new ConcurrentHashSet(); + actualDeviceIds.add(deviceId1); + ConcurrentHashSet deviceIds = deviceStatusServiceImpl + .forceGet(key2, Optional.empty()); + Assert.assertEquals(actualDeviceIds, deviceIds); + } + + /** + * Test get device status when mapping is present in memory with null devices. + */ + @Test + public void testGetDeviceStatusWhenMappingIsPresentInMemoryWithNullDevices() { + clear(); + deviceStatusServiceImpl.put(key, null, null, Optional.empty()); + Assert.assertNull(deviceStatusServiceImpl.get(key, Optional.empty())); + Assert.assertNull(deviceStatusDAO.get(new DeviceStatusKey(key)).getDeviceIds()); + } + + /** + * Test get device status when mapping is present in memory with zero devices. + */ + @Test + public void testGetDeviceStatusWhenMappingIsPresentInMemoryWithZeroDevices() { + clear(); + deviceStatusServiceImpl.put(key, new ConcurrentHashSet<>(), null, Optional.empty()); + Assert.assertNull(deviceStatusServiceImpl.get(key, Optional.empty())); + Assert.assertEquals(0, deviceStatusDAO.get(new DeviceStatusKey(key)).getDeviceIds().size()); + } + + /** + * Test get device status when mapping is not present in memory. + */ + @Test + public void testGetDeviceStatusWhenMappingIsNotPresentInMemory() { + clear(); + Assert.assertNull(deviceStatusServiceImpl.get(key, Optional.empty())); + Assert.assertNull(deviceStatusDAO.get(new DeviceStatusKey(key))); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DeviceConnStatusServiceWithSubServicesTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceConnStatusServiceWithSubServicesTest.java new file mode 100644 index 0000000..b234cb0 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceConnStatusServiceWithSubServicesTest.java @@ -0,0 +1,116 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.cache.PutMapOfEntitiesRequest; +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdMapping; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusServiceImpl; +import org.eclipse.ecsp.stream.dma.dao.key.DeviceStatusKey; +import org.eclipse.ecsp.utils.ConcurrentHashSet; +import org.junit.Assert; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + + +/** + * class DeviceConnStatusServiceWithSubServicesTest extends KafkaStreamsApplicationTestBase. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/dma-handler-sub-services-test.properties") +public class DeviceConnStatusServiceWithSubServicesTest extends KafkaStreamsApplicationTestBase { + + /** The device status service impl. */ + @Autowired + private DeviceStatusServiceImpl deviceStatusServiceImpl; + + /** The cache. */ + @Autowired + private IgniteCache cache; + + /** The Constant SUB_SERVICE_ECALL. */ + private static final String SUB_SERVICE_ECALL = "ecall/test/ftd"; + + /** The key. */ + String key = "vehicleId12345"; + + /** The device id 1. */ + String deviceId1 = "deviceId12345"; + + /** + * Test force get if sub services exist. + */ + @Test + public void testForceGetIfSubServicesExist() { + String key2 = "vehicleId12346"; + Assert.assertNull(deviceStatusServiceImpl.get(key2, Optional.of(SUB_SERVICE_ECALL))); + + DeviceStatusKey mapEntryKey = new DeviceStatusKey(key2); + String mapKey = "VEHICLE_DEVICE_MAPPING" + ":" + SUB_SERVICE_ECALL; + PutMapOfEntitiesRequest putRequest + = new PutMapOfEntitiesRequest(); + putRequest.withKey(mapKey); + Map map = new HashMap<>(); + VehicleIdDeviceIdMapping mapEntryValue = new VehicleIdDeviceIdMapping(); + mapEntryValue.addDeviceId(deviceId1); + map.put(mapEntryKey.convertToString(), mapEntryValue); + putRequest.withValue(map); + putRequest.withNamespaceEnabled(false); + cache.putMapOfEntities(putRequest); + + ConcurrentHashSet actualDeviceIds = new ConcurrentHashSet<>(); + actualDeviceIds.add(deviceId1); + ConcurrentHashSet deviceIds = deviceStatusServiceImpl.forceGet(key2, Optional.of(SUB_SERVICE_ECALL)); + Assert.assertEquals(actualDeviceIds, deviceIds); + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DeviceFetchConnStatusIntegrationTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceFetchConnStatusIntegrationTest.java new file mode 100644 index 0000000..a5b0dea --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceFetchConnStatusIntegrationTest.java @@ -0,0 +1,326 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.JsonMappingException; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.ByteArrayDeserializer; +import org.apache.kafka.common.serialization.ByteArraySerializer; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.FetchConnectionStatusEventData; +import org.eclipse.ecsp.entities.AbstractIgniteEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusService; +import org.eclipse.ecsp.stream.dma.handler.DeviceConnectionStatusHandler; +import org.eclipse.ecsp.stream.dma.handler.DeviceStatusBackDoorKafkaConsumer; +import org.eclipse.ecsp.transform.GenericIgniteEventTransformer; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertNull; + + +/** + * Integration test case for "Fetching connection status of a device using presence-manager through kafka topic" + * use case in stream-base. + * + * @author karora + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/dma-handler-fetch-conn-status-test.properties") +public class DeviceFetchConnStatusIntegrationTest extends KafkaStreamsApplicationTestBase { + + /** The service name. */ + @Value("${service.name}") + private String serviceName; + + /** The source topic. */ + @Value("${source.topic.name}") + private String sourceTopic; + + /** The mqtt prefix. */ + @Value("${mqtt.service.topic.name.prefix}") + private String mqttPrefix; + + /** The to device. */ + @Value("${" + PropertyNames.MQTT_TOPIC_TO_DEVICE_INFIX + ":" + Constants.TO_DEVICE + "}") + private String toDevice; + + /** The mqtt topic. */ + @Value("${mqtt.service.topic.name}") + private String mqttTopic; + + /** The fetch conn status topic. */ + @Value("${fetch.connection.status.topic.name}") + private String fetchConnStatusTopic; + + /** The device service. */ + @Autowired + private DeviceStatusService deviceService; + + /** The device status back door kafka consumer. */ + @Autowired + DeviceStatusBackDoorKafkaConsumer deviceStatusBackDoorKafkaConsumer; + + /** The device connection status handler. */ + @Autowired + DeviceConnectionStatusHandler deviceConnectionStatusHandler; + + /** The device status topic name. */ + private String deviceStatusTopicName; + + /** The vehicle id. */ + private String vehicleId = "Vehicle12345"; + + /** + * Setup for this test case. + * + * @throws Exception exception + */ + @Before + public void setUp() throws Exception { + super.setup(); + deviceStatusTopicName = DMAConstants.DEVICE_STATUS_TOPIC_PREFIX + serviceName.toLowerCase(); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class); + consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "test-sp-consumer-group"); + createTopics(sourceTopic, deviceStatusTopicName, fetchConnStatusTopic); + Properties kafkaConsumerProps = deviceStatusBackDoorKafkaConsumer.getKafkaConsumerProps(); + kafkaConsumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + deviceStatusBackDoorKafkaConsumer.addCallback(deviceConnectionStatusHandler.new DeviceStatusCallBack(), 0); + deviceStatusBackDoorKafkaConsumer.startBackDoorKafkaConsumer(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + launchApplication(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + subscibeToMqttTopic(mqttPrefix + "12345" + toDevice + "/" + mqttTopic); + } + + /** + * Test fetch connection status event. + * + * @throws ExecutionException the execution exception + * @throws InterruptedException the interrupted exception + * @throws TimeoutException the timeout exception + * @throws JsonParseException the json parse exception + * @throws JsonMappingException the json mapping exception + * @throws IOException Signals that an I/O exception has occurred. + */ + @Test + public void testFetchConnectionStatusEvent() throws ExecutionException, InterruptedException, + TimeoutException, JsonParseException, JsonMappingException, IOException { + Properties producerProps = new Properties(); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + + String deviceInactive = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\"," + + "\"Data\": {\"connStatus\":\"INACTIVE\",\"serviceName\":\"eCall\"},\"MessageId\": \"1234\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + sendMessages(deviceStatusTopicName, producerProps, Arrays.asList(vehicleId.getBytes(), + deviceInactive.getBytes())); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + assertNull(deviceService.get(vehicleId, Optional.empty())); + String speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":20.0}," + + "\"MessageId\": \"1234\",\"CorrelationId\": \"1234\",\"BizTransactionId\": \"Biz1234\"," + + "\"Timezone\": \"60\",\"PlatformId\": \"Platform1\",\"VehicleId\": \"Vehicle12345\"," + + "\"SourceDeviceId\": \"12345\"}"; + sendMessages(sourceTopic, producerProps, Arrays.asList(vehicleId.getBytes(), speedEvent.getBytes())); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + + List> receivedRecords = getKeyValueRecords(fetchConnStatusTopic, + consumerProps, TestConstants.SEVEN, TestConstants.INT_60000); + IgniteEvent speedIgniteEvent = getIgniteEvent(receivedRecords.get(0).value); + Assert.assertNotNull(speedIgniteEvent); + Assert.assertEquals("Timezone in event received is not as expected", (short) TestConstants.INT_60, + speedIgniteEvent.getTimezone()); + Assert.assertEquals("EventID in event received is not as expected", EventID.FETCH_CONN_STATUS, + speedIgniteEvent.getEventId()); + FetchConnectionStatusEventData fetchConnStatusData = + (FetchConnectionStatusEventData) speedIgniteEvent.getEventData(); + Assert.assertEquals("PlatformID in FetchConnectionStatusEventData is not as expected", "Platform1", + fetchConnStatusData.getPlatformId()); + Assert.assertEquals("VehicleID in FetchConnectionStatusEventData is not as expected", "Vehicle12345", + fetchConnStatusData.getVehicleId()); + } + + /** + * Tear down. + */ + @After + public void tearDown() { + deviceStatusBackDoorKafkaConsumer.shutdown(); + } + + /** + * Gets the ignite event. + * + * @param eventData the event data + * @return the ignite event + * @throws JsonParseException the json parse exception + * @throws JsonMappingException the json mapping exception + * @throws IOException Signals that an I/O exception has occurred. + */ + private IgniteEvent getIgniteEvent(byte[] eventData) throws JsonParseException, JsonMappingException, IOException { + GenericIgniteEventTransformer eventTransformer = new GenericIgniteEventTransformer(); + return eventTransformer.fromBlob(eventData, Optional.empty()); + } + + /** + * Test stream processor class. + */ + public static class FetchConnStatusTestStreamProcessor implements IgniteEventStreamProcessor { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "dma-sp"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + IgniteEvent value = kafkaRecord.value(); + if (!value.getEventId().equals(EventID.DEVICEMESSAGEFAILURE)) { + ((AbstractIgniteEvent) value).setDeviceRoutable(true); + spc.forward(kafkaRecord); + } + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + // todo Auto-generated method stub + + } + + /** + * Close. + */ + @Override + public void close() { + // todo Auto-generated method stub + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + // todo Auto-generated method stub + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + // todo Auto-generated method stub + return null; + } + + } + +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DeviceFetchConnectionStatusProducerTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceFetchConnectionStatusProducerTest.java new file mode 100644 index 0000000..efe2135 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceFetchConnectionStatusProducerTest.java @@ -0,0 +1,121 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.stream.dma.presencemanager.DeviceFetchConnectionStatusProducer; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoJUnitRunner; +import org.mockito.junit.MockitoRule; +import org.springframework.test.util.ReflectionTestUtils; + + +/** + * Unit test for {@link DeviceFetchConnectionStatusProducer}. + * + * @author karora + */ +@RunWith(MockitoJUnitRunner.class) +public class DeviceFetchConnectionStatusProducerTest { + + /** The mockito rule. */ + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + /** The fetch connection status producer. */ + @InjectMocks + private DeviceFetchConnectionStatusProducer fetchConnectionStatusProducer; + + /** The ctxt. */ + @Mock + private StreamProcessingContext, IgniteEvent> ctxt; + + /** The global message id generator. */ + @Mock + private GlobalMessageIdGenerator globalMessageIdGenerator; + + /** + * Setup for this test class. + * + * @throws Exception exception + */ + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + ReflectionTestUtils.setField(fetchConnectionStatusProducer, "fetchConnectionStatusTopic", + "fetchConnStatusTopic"); + } + + /** + * Test fetch connection status event. + */ + @Test + public void testFetchConnectionStatusEvent() { + long currTime = System.currentTimeMillis(); + long deliveryCutOff = currTime + (TestConstants.TWENTY * TestConstants.THREAD_SLEEP_TIME_1000 * 1); + DeviceMessageHeader msgHeader = new DeviceMessageHeader(); + msgHeader.withDeviceDeliveryCutoff(deliveryCutOff); + msgHeader.withRequestId("requestId123"); + msgHeader.withVehicleId("Vehicle1"); + msgHeader.withMessageId("msgId123"); + msgHeader.withPlatformId("platform1"); + msgHeader.withTimestamp((short) TestConstants.INT_60); + IgniteStringKey key = new IgniteStringKey("testEventKey"); + fetchConnectionStatusProducer.pushEventToFetchConnStatus(key, msgHeader, ctxt); + Mockito.verify(ctxt, Mockito.times(1)).forwardDirectly(ArgumentMatchers.any(IgniteKey.class), + ArgumentMatchers.any(IgniteEventImpl.class), ArgumentMatchers.startsWith("fetchConnStatusTopic")); + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DeviceMessagingEventSchedulerTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceMessagingEventSchedulerTest.java new file mode 100644 index 0000000..1ca5d09 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceMessagingEventSchedulerTest.java @@ -0,0 +1,216 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.stream.dma.dao.DMNextTtlExpirationTimer; +import org.eclipse.ecsp.stream.dma.dao.DMNextTtlExpirationTimerDAOImpl; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntry; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntryDAOMongoImpl; +import org.eclipse.ecsp.stream.dma.scheduler.DeviceMessagingEventScheduler; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoJUnitRunner; +import org.mockito.junit.MockitoRule; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Arrays; +import java.util.List; + + +/** + * {@link DeviceMessagingEventScheduler} UT class for {@link DeviceMessagingEventSchedulerTest}. + * + * @author karora + * + */ +@RunWith(MockitoJUnitRunner.class) +public class DeviceMessagingEventSchedulerTest { + + /** The mockito rule. */ + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + /** The device messaging event scheduler. */ + @InjectMocks + private DeviceMessagingEventScheduler deviceMessagingEventScheduler; + + /** The ctxt. */ + @Mock + private StreamProcessingContext ctxt; + + /** The global message id generator. */ + @Mock + private GlobalMessageIdGenerator globalMessageIdGenerator; + + /** The offline buffer DAO. */ + @Mock + private DMOfflineBufferEntryDAOMongoImpl offlineBufferDAO; + + /** The dm next ttl expiration timer DAO. */ + @Mock + private DMNextTtlExpirationTimerDAOImpl dmNextTtlExpirationTimerDAO; + + /** + * setUp(). + * + * @throws Exception Exception + */ + @Before + public void setUp() throws Exception { + + MockitoAnnotations.initMocks(this); + + List sourceTopics = Arrays.asList("topic1", "topic2"); + ReflectionTestUtils.setField(deviceMessagingEventScheduler, "schedulerAgentTopic", "scheduler"); + ReflectionTestUtils.setField(deviceMessagingEventScheduler, "sourceTopics", sourceTopics); + ReflectionTestUtils.setField(deviceMessagingEventScheduler, "dmaNotificationTopic", "dma-noti-topic"); + } + + /** + * Test schedule event new device message with earlier TTL. + */ + @Test + public void testScheduleEvent_newDeviceMessage_withEarlierTTL() { + + long currTime = System.currentTimeMillis(); + long deliveryCutOff = currTime + (Constants.TWENTY * Constants.THREAD_SLEEP_TIME_1000 * 1); + + DeviceMessageHeader msgHeader = new DeviceMessageHeader(); + msgHeader.withDeviceDeliveryCutoff(deliveryCutOff); + msgHeader.withRequestId("requestId123"); + msgHeader.withVehicleId("Vehicle1"); + msgHeader.withMessageId("msgId123"); + + DeviceMessage entity = new DeviceMessage(); + entity.setFeedBackTopic("ro"); + entity.setDeviceMessageHeader(msgHeader); + + long currSchTime = deliveryCutOff + (Constants.TWENTY * Constants.THREAD_SLEEP_TIME_1000 * 1); + DMNextTtlExpirationTimer timer = new DMNextTtlExpirationTimer(currSchTime); + Mockito.when(dmNextTtlExpirationTimerDAO.findById(ArgumentMatchers.anyString())).thenReturn(timer); + IgniteStringKey key = new IgniteStringKey("testEventKey"); + deviceMessagingEventScheduler.scheduleEvent(key, entity, ctxt); + + Mockito.verify(ctxt, Mockito.times(1)) + .forwardDirectly(ArgumentMatchers.any(IgniteKey.class), ArgumentMatchers.any(IgniteEventImpl.class), + ArgumentMatchers.startsWith("scheduler")); + } + + /** + * Test schedule event new device message with later TTL. + */ + @Test + public void testScheduleEvent_newDeviceMessage_withLaterTTL() { + + long currTime = System.currentTimeMillis(); + long deliveryCutOff = currTime + (Constants.TWENTY * Constants.THREAD_SLEEP_TIME_1000 * 1); + + DeviceMessageHeader msgHeader = new DeviceMessageHeader(); + msgHeader.withDeviceDeliveryCutoff(deliveryCutOff); + msgHeader.withRequestId("requestId123"); + msgHeader.withVehicleId("Vehicle1"); + msgHeader.withMessageId("msgId123"); + + DeviceMessage entity = new DeviceMessage(); + entity.setFeedBackTopic("ro"); + entity.setDeviceMessageHeader(msgHeader); + + long currSchTime = deliveryCutOff - (Constants.TWENTY * Constants.THREAD_SLEEP_TIME_1000 * 1); + DMNextTtlExpirationTimer timer = new DMNextTtlExpirationTimer(currSchTime); + Mockito.when(dmNextTtlExpirationTimerDAO.findById(ArgumentMatchers.anyString())).thenReturn(timer); + IgniteStringKey key = new IgniteStringKey("testEventKey"); + deviceMessagingEventScheduler.scheduleEvent(key, entity, ctxt); + + Mockito.verify(ctxt, Mockito.times(0)) + .forwardDirectly(ArgumentMatchers.any(IgniteKey.class), ArgumentMatchers.any(IgniteEventImpl.class), + ArgumentMatchers.startsWith("scheduler")); + } + + /** + * Test schedule event post notification event. + */ + @Test + public void testScheduleEvent_postNotificationEvent() { + + long currTime = System.currentTimeMillis(); + long deliveryCutOff = currTime + (Constants.TWENTY * Constants.THREAD_SLEEP_TIME_1000 * 1); + + DeviceMessageHeader msgHeader = new DeviceMessageHeader(); + msgHeader.withDeviceDeliveryCutoff(deliveryCutOff); + msgHeader.withRequestId("requestId123"); + msgHeader.withVehicleId("Vehicle1"); + msgHeader.withMessageId("msgId123"); + + DeviceMessage entity = new DeviceMessage(); + entity.setFeedBackTopic("ro"); + entity.setDeviceMessageHeader(msgHeader); + + DMOfflineBufferEntry entry = new DMOfflineBufferEntry(); + entry.setEvent(entity); + IgniteKey key = new IgniteStringKey("123"); + entry.setIgniteKey(key); + entry.setTtlExpirationTime(deliveryCutOff); + + Mockito.when(offlineBufferDAO.getOfflineBufferEntryWithEarliestTtl()).thenReturn(entry); + + deviceMessagingEventScheduler.scheduleEvent(ctxt); + + Mockito.verify(ctxt, Mockito.times(1)) + .forwardDirectly(ArgumentMatchers.any(IgniteKey.class), ArgumentMatchers.any(IgniteEventImpl.class), + ArgumentMatchers.startsWith("scheduler")); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DeviceStatusAPIInMemoryServiceImplTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceStatusAPIInMemoryServiceImplTest.java new file mode 100644 index 0000000..7df9927 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceStatusAPIInMemoryServiceImplTest.java @@ -0,0 +1,122 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdStatus; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusAPIInMemoryServiceImpl; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusDaoInMemoryCache; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.eclipse.ecsp.stream.dma.dao.DMAConstants.ACTIVE; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +/** + * {@link DeviceStatusAPIInMemoryServiceImplTest} UT class for {@link DeviceStatusAPIInMemoryServiceImpl}. + */ +public class DeviceStatusAPIInMemoryServiceImplTest { + + /** The device status API in memory service. */ + @InjectMocks + private DeviceStatusAPIInMemoryServiceImpl deviceStatusAPIInMemoryService; + + /** The key. */ + private String key = "Vehicle12345"; + + /** The device status dao. */ + @Mock + private DeviceStatusDaoInMemoryCache deviceStatusDao; + + /** + * Setup. + */ + @Before + public void setup() { + MockitoAnnotations.openMocks(this); + } + + /** + * Test get device id status from in memory. + */ + @Test + public void testGetDeviceIdStatusFromInMemory() { + VehicleIdDeviceIdStatus vehicleIdDeviceIdStatus = new VehicleIdDeviceIdStatus(); + when(deviceStatusDao.get(any())).thenReturn(vehicleIdDeviceIdStatus); + Assert.assertEquals(vehicleIdDeviceIdStatus, deviceStatusAPIInMemoryService.get(key)); + } + + /** + * Test get device id status when status not present in memory. + */ + @Test + public void testGetDeviceIdStatusWhenStatusNotPresentInMemory() { + VehicleIdDeviceIdStatus vehicleIdDeviceIdStatus = new VehicleIdDeviceIdStatus(); + Assert.assertNull(deviceStatusAPIInMemoryService.get(key)); + } + + /** + * Test update device id status when status not present in memory. + */ + @Test + public void testUpdateDeviceIdStatusWhenStatusNotPresentInMemory() { + deviceStatusAPIInMemoryService.update(key, "d1", ACTIVE); + verify(deviceStatusDao, times(1)).putIfAbsent(any(), any(), any(), any()); + } + + /** + * Test update device id status when status is present in memory. + */ + @Test + public void testUpdateDeviceIdStatusWhenStatusIsPresentInMemory() { + VehicleIdDeviceIdStatus vehicleIdDeviceIdStatus = new VehicleIdDeviceIdStatus(); + when(deviceStatusDao.get(any())).thenReturn(vehicleIdDeviceIdStatus); + deviceStatusAPIInMemoryService.update(key, "d1", ACTIVE); + verify(deviceStatusDao, times(0)).putIfAbsent(any(), any(), any(), any()); + verify(deviceStatusDao, times(1)).put(any(), any(), any()); + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/DeviceStatusServiceImplTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceStatusServiceImplTest.java new file mode 100644 index 0000000..d10e48c --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/DeviceStatusServiceImplTest.java @@ -0,0 +1,145 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdMapping; +import org.eclipse.ecsp.stream.dma.dao.DeviceConnStatusDAO; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusServiceImpl; +import org.eclipse.ecsp.utils.ConcurrentHashSet; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +/** + * {@link DeviceStatusServiceImplTest} test class for {@link DeviceStatusServiceImpl}. + */ +public class DeviceStatusServiceImplTest { + + /** The device status service impl. */ + @InjectMocks + private DeviceStatusServiceImpl deviceStatusServiceImpl; + + /** The device status DAO. */ + @Mock + private DeviceConnStatusDAO deviceStatusDAO; + + /** The key. */ + private String key = "Vehicle12345"; + + /** The device ids. */ + private ConcurrentHashSet deviceIds; + + /** + * setup. + */ + @Before + public void setup() { + deviceIds = new ConcurrentHashSet<>(); + deviceIds.add("deviceId1"); + deviceIds.add("deviceId2"); + MockitoAnnotations.openMocks(this); + } + + /** + * Test get device id if mapping found in memory. + */ + @Test + public void testGetDeviceIdIfMappingFoundInMemory() { + when(deviceStatusDAO.get(any())).thenReturn(new VehicleIdDeviceIdMapping(Version.V1_0, deviceIds)); + deviceStatusServiceImpl.get(key, Optional.empty()); + verify(deviceStatusDAO, Mockito.times(0)).put(any(), any(), any(), any()); + } + + /** + * Test get device id if mapping present in memory with null device ids. + */ + @Test + public void testGetDeviceIdIfMappingPresentInMemoryWithNullDeviceIds() { + when(deviceStatusDAO.get(any())).thenReturn(new VehicleIdDeviceIdMapping(Version.V1_0, null)); + when(deviceStatusDAO.forceGet(any(), any())).thenReturn(new VehicleIdDeviceIdMapping(Version.V1_0, deviceIds)); + deviceStatusServiceImpl.get(key, Optional.empty()); + verify(deviceStatusDAO, Mockito.times(1)).put(any(), any(), any(), any()); + } + + /** + * Test get device id if mapping present in memory with zero device ids. + */ + @Test + public void testGetDeviceIdIfMappingPresentInMemoryWithZeroDeviceIds() { + when(deviceStatusDAO.get(any())).thenReturn(new VehicleIdDeviceIdMapping(Version.V1_0, + new ConcurrentHashSet<>())); + when(deviceStatusDAO.forceGet(any(), any())).thenReturn(new VehicleIdDeviceIdMapping(Version.V1_0, deviceIds)); + deviceStatusServiceImpl.get(key, Optional.empty()); + verify(deviceStatusDAO, Mockito.times(1)).put(any(), any(), any(), any()); + } + + /** + * Test get device id if mapping is not present in memory. + */ + @Test + public void testGetDeviceIdIfMappingIsNotPresentInMemory() { + when(deviceStatusDAO.get(any())).thenReturn(null); + when(deviceStatusDAO.forceGet(any(), any())).thenReturn(new VehicleIdDeviceIdMapping(Version.V1_0, deviceIds)); + deviceStatusServiceImpl.get(key, Optional.of("subService1")); + verify(deviceStatusDAO, Mockito.times(1)).put(any(), any(), any(), any()); + } + + /** + * Test get device id if mapping is not present in memory and not present in redis. + */ + @Test + public void testGetDeviceIdIfMappingIsNotPresentInMemoryAndNotPresentInRedis() { + when(deviceStatusDAO.get(any())).thenReturn(null); + when(deviceStatusDAO.forceGet(any(), any())).thenReturn(new VehicleIdDeviceIdMapping(Version.V1_0, null)); + deviceStatusServiceImpl.get(key, Optional.of("subService1")); + verify(deviceStatusDAO, Mockito.times(0)).put(any(), any(), any(), any()); + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/KafkaDispatcherIntegrationTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/KafkaDispatcherIntegrationTest.java new file mode 100644 index 0000000..ed1c763 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/KafkaDispatcherIntegrationTest.java @@ -0,0 +1,389 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.Headers; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.serialization.ByteArraySerializer; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.entities.AbstractIgniteEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/** + * class KafkaDispatcherIntegrationTest extends KafkaStreamsApplicationTestBase. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@TestPropertySource("/dma-test-kafka-dispatch.properties") +public class KafkaDispatcherIntegrationTest extends KafkaStreamsApplicationTestBase { + + /** The Constant KAFKA_HEADER_KEY_1. */ + private static final String KAFKA_HEADER_KEY_1 = "header_key_1"; + + /** The Constant KAFKA_HEADER_KEY_2. */ + private static final String KAFKA_HEADER_KEY_2 = "header_key_2"; + + /** The Constant KAFKA_HEADER_KEY_3. */ + private static final String KAFKA_HEADER_KEY_3 = "header_key_3"; + + /** The Constant KAFKA_HEADER_VALUE_1. */ + private static final String KAFKA_HEADER_VALUE_1 = "header_value_1"; + + /** The Constant KAFKA_HEADER_VALUE_1_MODIFIED. */ + private static final String KAFKA_HEADER_VALUE_1_MODIFIED = "header_value_1_modified"; + + /** The Constant KAFKA_HEADER_VALUE_2. */ + private static final String KAFKA_HEADER_VALUE_2 = "header_value_2"; + + /** The Constant KAFKA_HEADER_VALUE_3. */ + private static final String KAFKA_HEADER_VALUE_3 = "header_value_3"; + + /** The Constant HEADER_VALUE_ASSERTION_FAILURE. */ + private static final String HEADER_VALUE_ASSERTION_FAILURE = "Header value does not match"; + + /** The vehicle id 1. */ + private static String vehicleId1 = "Vehicle12345"; + + /** The kafka headers 1. */ + List
    kafkaHeaders1; + + /** The kafka headers 2. */ + List
    kafkaHeaders2; + + /** The service name. */ + @Value("${service.name}") + private String serviceName; + + /** The source topic. */ + @Value("${source.topic.name}") + private String sourceTopic; + + /** The mqtt prefix. */ + @Value("${mqtt.service.topic.name.prefix}") + private String mqttPrefix; + + /** The to device. */ + @Value("${" + PropertyNames.MQTT_TOPIC_TO_DEVICE_INFIX + ":" + Constants.TO_DEVICE + "}") + private String toDevice; + + /** The mqtt topic. */ + @Value("${mqtt.service.topic.name}") + private String mqttTopic; + + /** The kafka dispatch topic name. */ + private String kafkaDispatchTopicName; + + /** The vehicle id 2. */ + private String vehicleId2 = "Vehicle6789"; + + /** + * setUp(). + * + * @throws Exception the exception + */ + @Before + public void setUp() throws Exception { + super.setup(); + kafkaDispatchTopicName = "kafka-dispatch-topic"; + createTopics(sourceTopic, kafkaDispatchTopicName); + launchApplication(); + Thread.sleep(Constants.THREAD_SLEEP_TIME_10000); + consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "demo_consumer"); + consumerProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + } + + /** + * Test with kafka headers. + * + * @throws ExecutionException the execution exception + * @throws InterruptedException the interrupted exception + * @throws TimeoutException the timeout exception + */ + @Test + public void testWithKafkaHeaders() throws ExecutionException, InterruptedException, TimeoutException { + + Properties producerProps = new Properties(); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + + kafkaHeaders1 = new ArrayList<>(); + kafkaHeaders1.add(new RecordHeader(KAFKA_HEADER_KEY_1, KAFKA_HEADER_VALUE_1.getBytes(StandardCharsets.UTF_8))); + kafkaHeaders1.add(new RecordHeader(KAFKA_HEADER_KEY_2, KAFKA_HEADER_VALUE_2.getBytes(StandardCharsets.UTF_8))); + kafkaHeaders2 = new ArrayList<>(); + String speedEvent1 = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\":" + + " {\"value\":20.0},\"MessageId\": \"1234\",\"CorrelationId\": \"1234\"," + + "\"BizTransactionId\": \"Biz1234\",\"ecuType\":\"testEcu\",\"VehicleId\": " + + "\"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + + kafkaHeaders2.add(new RecordHeader(KAFKA_HEADER_KEY_3, KAFKA_HEADER_VALUE_3.getBytes(StandardCharsets.UTF_8))); + sendMessages(sourceTopic, producerProps, Arrays.asList(vehicleId1.getBytes(), + speedEvent1.getBytes()), kafkaHeaders1); + Thread.sleep(Constants.THREAD_SLEEP_TIME_10000); + Map headersMap = getHeadersFromMessageWithKey(vehicleId1); + String headerValue1 = headersMap.get(KAFKA_HEADER_KEY_1); + assertTrue(headerValue1.equalsIgnoreCase(KAFKA_HEADER_VALUE_1_MODIFIED), HEADER_VALUE_ASSERTION_FAILURE); + String headerValue2 = headersMap.get(KAFKA_HEADER_KEY_2); + assertTrue(headerValue2.equalsIgnoreCase(KAFKA_HEADER_VALUE_2), HEADER_VALUE_ASSERTION_FAILURE); + String speedEvent2 = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\":" + + " {\"value\":20.0},\"MessageId\": \"567\",\"CorrelationId\": \"567\"," + + "\"BizTransactionId\": \"Biz567\",\"ecuType\":\"testEcu\",\"VehicleId\": " + + "\"Vehicle6789\",\"SourceDeviceId\": \"6789\"}"; + assertEquals(Constants.TWO, headersMap.entrySet().size()); + + sendMessages(sourceTopic, producerProps, Arrays.asList(vehicleId2.getBytes(), + speedEvent2.getBytes()), kafkaHeaders2); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + Map headersMap2 = getHeadersFromMessageWithKey(vehicleId2); + String headerValue3 = headersMap2.get(KAFKA_HEADER_KEY_3); + assertTrue(headerValue3.equalsIgnoreCase(KAFKA_HEADER_VALUE_3), HEADER_VALUE_ASSERTION_FAILURE); + assertEquals(1, headersMap2.entrySet().size()); + } + + /** + * Gets the headers from message with key. + * + * @param key the key + * @return the headers from message with key + * @throws InterruptedException the interrupted exception + * @throws TimeoutException the timeout exception + */ + private Map getHeadersFromMessageWithKey(String key) + throws InterruptedException, TimeoutException { + List> messages = getMessagesWithHeaders( + kafkaDispatchTopicName, consumerProps, Constants.TEN, Constants.INT_20000); + Map headersMap = new HashMap<>(); + + for (ConsumerRecord message : messages) { + if (message.key().equals(key)) { + Headers headers = message.headers(); + for (Header header : headers) { + headersMap.put(header.key(), new String(header.value(), StandardCharsets.UTF_8)); + } + } + } + return headersMap; + } + + /** + * Send messages. + * + * @param topic the topic + * @param producerProps the producer props + * @param bytes the bytes + * @param kafkaHeader the kafka header + * @throws ExecutionException the execution exception + * @throws InterruptedException the interrupted exception + */ + private void sendMessages(String topic, Properties producerProps, List bytes, List
    kafkaHeader) + throws ExecutionException, InterruptedException { + Collection> kvs = new ArrayList<>(); + for (int i = 1; i <= bytes.size(); i++) { + if (i % Constants.TWO == 0) { + kvs.add(new KeyValue(bytes.get(i - Constants.TWO), bytes.get(i - 1))); + } + } + KafkaTestUtils.produceKeyValuesSynchronouslyWithHeaders(topic, kvs, producerProps, kafkaHeader); + } + + /** + * Gets the messages with headers. + * + * @param the key type + * @param the value type + * @param topic the topic + * @param consumerProps the consumer props + * @param n the n + * @param waitTime the wait time + * @return the messages with headers + * @throws TimeoutException the timeout exception + * @throws InterruptedException the interrupted exception + */ + private List> getMessagesWithHeaders(String topic, + Properties consumerProps, int n, int waitTime) + throws TimeoutException, InterruptedException { + int timeWaited = 0; + int increment = Constants.THREAD_SLEEP_TIME_2000; + List> messages = new ArrayList<>(); + while ((messages.size() < n) && (timeWaited <= waitTime)) { + messages.addAll(KafkaTestUtils.readMessagesWithHeaders(topic, consumerProps, n)); + Thread.sleep(increment); + timeWaited = timeWaited + increment; + } + return messages; + } + + /** + * inner class KafkaDispatcherTestStreamProcessor implements IgniteEventStreamProcessor. + */ + public static class KafkaDispatcherTestStreamProcessor implements IgniteEventStreamProcessor { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "kafka-dis-sp"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + IgniteEvent value = kafkaRecord.value(); + if (!value.getEventId().equals(EventID.DEVICEMESSAGEFAILURE)) { + ((AbstractIgniteEvent) value).setDeviceRoutable(true); + System.out.println("Process for service processor called"); + + // modify headers for event with vehicleId1 + if (value.getVehicleId().equalsIgnoreCase(vehicleId1)) { + Map kafkaHeaders = value.getKafkaHeaders(); + kafkaHeaders.put(KAFKA_HEADER_KEY_1, KAFKA_HEADER_VALUE_1_MODIFIED); + ((AbstractIgniteEvent) value).setKafkaHeaders(kafkaHeaders); + } + spc.forward(kafkaRecord); + } + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + + } + + /** + * Close. + */ + @Override + public void close() { + + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + } +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/MsgIdAndCorrIdUpdaterTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/MsgIdAndCorrIdUpdaterTest.java new file mode 100644 index 0000000..0217e27 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/MsgIdAndCorrIdUpdaterTest.java @@ -0,0 +1,227 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.idgen.internal.ShortCounterIdPartGenerator; +import org.eclipse.ecsp.analytics.stream.base.idgen.internal.ShortHashCodeIdPartGenerator; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.stream.dma.handler.DeviceHeaderUpdater; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.junit.Assert; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + + +/** + * UT class {@link MsgIdAndCorrIdUpdaterTest}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/stream-base-test.properties") +public class MsgIdAndCorrIdUpdaterTest { + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS. */ + @ClassRule + public static final EmbeddedRedisServer REDIS = new EmbeddedRedisServer(); + + /** The Constant SERVICE_SP_NAME. */ + private static final String SERVICE_SP_NAME = "Ecall"; + + /** The device id updater. */ + @Autowired + private DeviceHeaderUpdater deviceIdUpdater; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** The source topic. */ + private String sourceTopic = "testTopic"; + + /** + * Test index appender. + */ + @Test + public void testIndexAppender() { + ShortCounterIdPartGenerator indexApp = new ShortCounterIdPartGenerator(); + int val = indexApp.getMsgIdSuffix().get(); + String incVal = indexApp.generateIdPart(SERVICE_SP_NAME); + Assert.assertEquals(incVal, ((val + 1) + "")); + } + + /** + * Test short hash code appender. + */ + @Test + public void testShortHashCodeAppender() { + ShortHashCodeIdPartGenerator shcApp = new ShortHashCodeIdPartGenerator(); + String incVal = shcApp.generateIdPart(SERVICE_SP_NAME); + Assert.assertEquals("21465", incVal); + } + + /** + * Message Id should be set if not present and correlationId should be null. + */ + + @Test + public void testAddingMessageId() { + + TestEvent value = new TestEvent(); + value.setVehicleId("vehicleId"); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(value), + Version.V1_0, value, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + DeviceMessageHeader valueWithHeaders = deviceIdUpdater.addMessageIdAndCorrelationIdIfNotPresent(entity) + .getDeviceMessageHeader(); + ShortHashCodeIdPartGenerator shcApp = new ShortHashCodeIdPartGenerator(); + String incVal = shcApp.generateIdPart(SERVICE_SP_NAME); + + boolean containsHashCode = valueWithHeaders.getMessageId().toString() + .startsWith(incVal); + // Assert.assertTrue(containsHashCode); + Assert.assertNull(valueWithHeaders.getCorrelationId()); + + } + + /** + * correlationId Id should be set if MessageId is present and MessageId should be updated with a new value. + */ + @Test + public void testAddingCorrelationId() { + TestEvent value = new TestEvent(); + value.setMessageId("12345"); + value.setVehicleId("vehicleId"); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(value), + Version.V1_0, value, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + + DeviceMessageHeader valueWithHeaders = deviceIdUpdater.addMessageIdAndCorrelationIdIfNotPresent(entity) + .getDeviceMessageHeader(); + ShortHashCodeIdPartGenerator shcApp = new ShortHashCodeIdPartGenerator(); + String incVal = shcApp.generateIdPart(SERVICE_SP_NAME); + + boolean containsHashCode = valueWithHeaders.getMessageId().toString() + .startsWith(incVal); + // Assert.assertTrue(containsHashCode); + Assert.assertEquals("12345", valueWithHeaders.getCorrelationId()); + } + + /** + * Test generating unique message ids. + */ + @Test + public void testGeneratingUniqueMessageIds() { + + TestEvent value1 = new TestEvent(); + value1.setVehicleId("vehicleId1"); + DeviceMessage entity1 = new DeviceMessage(transformer.toBlob(value1), + Version.V1_0, value1, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + DeviceMessageHeader valueWithHeaders1 = deviceIdUpdater.addMessageIdAndCorrelationIdIfNotPresent(entity1) + .getDeviceMessageHeader(); + + TestEvent value2 = new TestEvent(); + value2.setVehicleId("vehicleId1"); + DeviceMessage entity2 = new DeviceMessage(transformer.toBlob(value2), + Version.V1_0, value2, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + DeviceMessageHeader valueWithHeaders2 = deviceIdUpdater.addMessageIdAndCorrelationIdIfNotPresent(entity2) + .getDeviceMessageHeader(); + + TestEvent value3 = new TestEvent(); + value3.setVehicleId("vehicleId1"); + DeviceMessage entity3 = new DeviceMessage(transformer.toBlob(value3), + Version.V1_0, value3, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + DeviceMessageHeader valueWithHeaders3 = deviceIdUpdater.addMessageIdAndCorrelationIdIfNotPresent(entity3) + .getDeviceMessageHeader(); + + String incVal1 = valueWithHeaders1.getMessageId(); + String incVal2 = valueWithHeaders2.getMessageId(); + String incVal3 = valueWithHeaders3.getMessageId(); + + Assert.assertNotEquals(incVal1, incVal2); + Assert.assertNotEquals(incVal2, incVal3); + Assert.assertNotEquals(incVal3, incVal1); + + } + + /** + * inner class TestEvent extends IgniteEventImpl. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Sample"; + } + + } + +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/MsgIdUpdaterTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/MsgIdUpdaterTest.java new file mode 100644 index 0000000..b8d896c --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/MsgIdUpdaterTest.java @@ -0,0 +1,235 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.idgen.internal.ShortHashCodeIdPartGenerator; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.cache.redis.EmbeddedRedisServer; +import org.eclipse.ecsp.dao.utils.EmbeddedMongoDB; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.stream.dma.handler.DeviceHeaderUpdater; +import org.eclipse.ecsp.stream.dma.handler.DeviceMessageHandler; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.junit.Assert; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + + +/** + * UT class {@link MsgIdUpdaterTest}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/stream-base-test.properties") +public class MsgIdUpdaterTest { + + /** The Constant MONGO_SERVER. */ + @ClassRule + public static final EmbeddedMongoDB MONGO_SERVER = new EmbeddedMongoDB(); + + /** The Constant REDIS. */ + @ClassRule + public static final EmbeddedRedisServer REDIS = new EmbeddedRedisServer(); + + /** The Constant SERVICE_SP_NAME. */ + private static final String SERVICE_SP_NAME = "Ecall"; + + /** The received event. */ + private static DeviceMessage receivedEvent; + + /** The device id updater. */ + @Autowired + private DeviceHeaderUpdater deviceIdUpdater; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** The source topic. */ + private String sourceTopic = "testTopic"; + + /** + * Message id updation test. + */ + @Test + public void messageIdUpdationTest() { + TestEvent value = new TestEvent(); + value.setVehicleId("vehicleId1"); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(value), Version.V1_0, + value, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + DeviceMessage valueWithHeaders = deviceIdUpdater.addMessageIdIfNotPresent(entity); + ShortHashCodeIdPartGenerator shcApp = new ShortHashCodeIdPartGenerator(); + String incVal = shcApp.generateIdPart(SERVICE_SP_NAME); + + boolean containsHashCode = valueWithHeaders.getDeviceMessageHeader().getMessageId() + .startsWith(incVal); + // Assert.assertTrue(containsHashCode); + Assert.assertNull(valueWithHeaders.getDeviceMessageHeader().getCorrelationId()); + } + + /** + * Message id updation negative test. + */ + // MessageId is not overridden if already set. + @Test + public void messageIdUpdationNegativeTest() { + TestEvent value = new TestEvent(); + value.setMessageId("notOverRidden"); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(value), + Version.V1_0, value, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + + DeviceMessage valueWithHeaders = deviceIdUpdater.addMessageIdIfNotPresent(entity); + Assert.assertEquals("notOverRidden", valueWithHeaders.getDeviceMessageHeader().getMessageId()); + } + + /** + *

    + * event.header.updation.type=messageId + * + * Hence it sets a messageId if it is not set.(header updated) + * + * If a messageId is set the IgniteEvent is not updated. + * + * CorrelationId is not updated in either case. + *

    + */ + @Test + public void deviceHeaderUpdaterTest() { + deviceIdUpdater.setNextHandler(new TestHandler()); + IgniteStringKey key = new IgniteStringKey(); + key.setKey("vehicleABC"); + TestEvent value = new TestEvent(); + value.setVehicleId("vehicleABC"); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(value), + Version.V1_0, value, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + + Assert.assertNull(value.getMessageId()); + deviceIdUpdater.handle(key, entity); + Assert.assertNotNull(receivedEvent.getDeviceMessageHeader().getMessageId()); + Assert.assertNull(value.getCorrelationId()); + + value.setMessageId("notOverRidden"); + entity = new DeviceMessage(transformer.toBlob(value), Version.V1_0, + value, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + + deviceIdUpdater.handle(key, entity); + Assert.assertEquals("notOverRidden", receivedEvent.getDeviceMessageHeader().getMessageId()); + Assert.assertNull(value.getCorrelationId()); + } + + /** + * inner class TestHandler implements DeviceMessageHandler. + */ + public static final class TestHandler implements DeviceMessageHandler { + + /** + * Handle. + * + * @param key the key + * @param value the value + */ + @Override + public void handle(IgniteKey key, DeviceMessage value) { + receivedEvent = value; + + } + + /** + * Sets the next handler. + * + * @param handler the new next handler + */ + @Override + public void setNextHandler(DeviceMessageHandler handler) { + + } + + /** + * Close. + */ + @Override + public void close() { + + } + + } + + /** + * innerc class TestEvent extends IgniteEventImpl. + */ + public class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Sample"; + } + + } + +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/NoFilterDMOfflineBufferEntryImplTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/NoFilterDMOfflineBufferEntryImplTest.java new file mode 100644 index 0000000..b5e859e --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/NoFilterDMOfflineBufferEntryImplTest.java @@ -0,0 +1,136 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntry; +import org.eclipse.ecsp.stream.dma.handler.NoFilterDMOfflineBufferEntryImpl; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.InjectMocks; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + + +/** + * UT test class for {@link NoFilterDMOfflineBufferEntryImplTest}. + */ +public class NoFilterDMOfflineBufferEntryImplTest { + + /** The no filter DM offline buffer entry impl. */ + @InjectMocks + private NoFilterDMOfflineBufferEntryImpl noFilterDMOfflineBufferEntryImpl = new NoFilterDMOfflineBufferEntryImpl(); + + /** + * Sets the test filter DM offline buffer entry impl. + */ + @Test + public void setTestFilterDMOfflineBufferEntryImpl() { + DMOfflineBufferEntry bufferEntry = new DMOfflineBufferEntry(); + bufferEntry.setDeviceId("vehicle1"); + DeviceMessage event = new DeviceMessage(); + IgniteEventImpl eventImpl = new IgniteEventImpl(); + String eventId = "eventId1"; + eventImpl.setEventId(eventId); + event.setEvent(eventImpl); + DeviceMessageHeader deviceMessageHeader = new DeviceMessageHeader(); + deviceMessageHeader.withRequestId("reqId1"); + event.setDeviceMessageHeader(deviceMessageHeader); + bufferEntry.setEvent(event); + LocalDateTime eventTs = LocalDateTime.now(); + bufferEntry.setEventTs(eventTs); + IgniteStringKey igniteKey = new IgniteStringKey(); + igniteKey.setKey("Vehicle12345"); + bufferEntry.setIgniteKey(igniteKey); + bufferEntry.setVehicleId("vehicleId"); + List bufferedEntries = new ArrayList(); + bufferedEntries.add(bufferEntry); + + DMOfflineBufferEntry bufferEntry2 = new DMOfflineBufferEntry(); + bufferEntry2.setDeviceId("vehicle2"); + DeviceMessage event2 = new DeviceMessage(); + IgniteEventImpl eventImpl2 = new IgniteEventImpl(); + String eventId2 = "eventId2"; + eventImpl2.setEventId(eventId2); + DeviceMessageHeader deviceMessageHeader2 = new DeviceMessageHeader(); + deviceMessageHeader2.withRequestId("reqId2"); + event2.setDeviceMessageHeader(deviceMessageHeader2); + event2.setEvent(eventImpl2); + bufferEntry2.setEvent(event2); + LocalDateTime eventTs2 = LocalDateTime.now(); + bufferEntry2.setEventTs(eventTs2); + IgniteStringKey igniteKey2 = new IgniteStringKey(); + igniteKey.setKey("Vehicle12345"); + bufferEntry2.setIgniteKey(igniteKey2); + bufferEntry2.setVehicleId("Vehicle12345"); + bufferedEntries.add(bufferEntry2); + + DMOfflineBufferEntry bufferEntry3 = new DMOfflineBufferEntry(); + bufferEntry3.setDeviceId("vehicle3"); + DeviceMessage event3 = new DeviceMessage(); + IgniteEventImpl eventImpl3 = new IgniteEventImpl(); + String eventId3 = "eventId3"; + eventImpl3.setEventId(eventId3); + DeviceMessageHeader deviceMessageHeader3 = new DeviceMessageHeader(); + deviceMessageHeader3.withRequestId("reqId3"); + event3.setDeviceMessageHeader(deviceMessageHeader3); + event3.setEvent(eventImpl3); + bufferEntry3.setEvent(event3); + LocalDateTime eventTs3 = LocalDateTime.now(); + bufferEntry3.setEventTs(eventTs3); + IgniteStringKey igniteKey3 = new IgniteStringKey(); + igniteKey.setKey("Vehicle12345"); + bufferEntry3.setIgniteKey(igniteKey3); + bufferEntry3.setVehicleId("Vehicle12345"); + bufferedEntries.add(bufferEntry3); + + List testList = noFilterDMOfflineBufferEntryImpl + .filterAndUpdateDmOfflineBufferEntries(bufferedEntries); + + Assert.assertEquals(Constants.THREE, testList.size()); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/ShortHashCodeIdPartGeneratorTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/ShortHashCodeIdPartGeneratorTest.java new file mode 100644 index 0000000..7441a8a --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/ShortHashCodeIdPartGeneratorTest.java @@ -0,0 +1,90 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.analytics.stream.base.idgen.internal.ShortHashCodeIdPartGenerator; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/** + * ShortHashCodeIdPartGenerator test for short code generating. + * + * @author Binoy + */ +public class ShortHashCodeIdPartGeneratorTest { + + /** The Constant SHORT_CODE. */ + private static final String SHORT_CODE = "18351"; + + /** The Constant SERVICE_NAME. */ + private static final String SERVICE_NAME = "ECall"; + + /** The short hash code id part generator. */ + private ShortHashCodeIdPartGenerator shortHashCodeIdPartGenerator; + + /** + * Setup. + */ + @Before + public void setup() { + shortHashCodeIdPartGenerator = new ShortHashCodeIdPartGenerator(); + } + + /** + * Testing for non null object. + */ + @Test + public void testGenerateIdPart() { + String actualCode = shortHashCodeIdPartGenerator.generateIdPart(SERVICE_NAME); + assertEquals(SHORT_CODE, actualCode); + } + + /** + * Testing for empty String. + */ + @Test(expected = IllegalArgumentException.class) + public void testGenerateIdPartWithEmptyString() { + shortHashCodeIdPartGenerator.generateIdPart(""); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/SynchronizationIntegrationTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/SynchronizationIntegrationTest.java new file mode 100644 index 0000000..02a6a3c --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/SynchronizationIntegrationTest.java @@ -0,0 +1,301 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.InternalCacheConstants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.cache.GetEntityRequest; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.cache.PutEntityRequest; +import org.eclipse.ecsp.cache.PutMapOfEntitiesRequest; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.RetryRecord; +import org.eclipse.ecsp.entities.dma.RetryRecordIds; +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdMapping; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.stream.dma.dao.DMARetryBucketDAOCacheBackedInMemoryImpl; +import org.eclipse.ecsp.stream.dma.dao.DMARetryRecordDAOCacheBackedInMemoryImpl; +import org.eclipse.ecsp.stream.dma.dao.key.DeviceStatusKey; +import org.eclipse.ecsp.stream.dma.dao.key.RetryBucketKey; +import org.eclipse.ecsp.stream.dma.dao.key.RetryRecordKey; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.ecsp.utils.ConcurrentHashSet; +import org.junit.Assert; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + + +/** + * This test class is to verify whether the in-memory state store can sync-up with redis. + * + * @author avadakkootko + */ +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/dma-handler-test.properties") +public class SynchronizationIntegrationTest extends KafkaStreamsApplicationTestBase { + + /** The cache. */ + @Autowired + private IgniteCache cache; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** The retry bucket dao. */ + @Autowired + private DMARetryBucketDAOCacheBackedInMemoryImpl retryBucketDao; + + /** The retry record dao. */ + @Autowired + private DMARetryRecordDAOCacheBackedInMemoryImpl retryRecordDao; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The source topic. */ + private String sourceTopic = "testTopic"; + + /** The task id. */ + private String taskId = "taskId"; + + /** + * Test put get entity. + */ + @Test + public void testPutGetEntity() { + IgniteStringKey igniteKey = new IgniteStringKey(); + IgniteEventImpl igniteEvent = new IgniteEventImpl(); + igniteKey.setKey("abc"); + igniteEvent.setEventId("test"); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(igniteEvent), + Version.V1_0, igniteEvent, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + RetryRecord entityPut = new RetryRecord(igniteKey, entity, 0L); + + PutEntityRequest req = new PutEntityRequest(); + req.withKey("hello").withValue(entityPut).withNamespaceEnabled(false); + + cache.putEntity(req); + RetryRecord entityRead = cache.getEntity(new GetEntityRequest().withKey("hello").withNamespaceEnabled(false)); + Assert.assertEquals(entityPut.getDeviceMessage().getDeviceMessageHeader().toString(), + entityRead.getDeviceMessage().getDeviceMessageHeader().toString()); + Assert.assertEquals(entityPut.getLastRetryTimestamp(), entityRead.getLastRetryTimestamp()); + Assert.assertEquals(entityPut.getIgniteKey(), entityRead.getIgniteKey()); + } + + // @Test + // public void testDeviceConnSyncWithCacheIntegration() throws InterruptedException { + // ConcurrentHashSet value = new ConcurrentHashSet(); + // value.add("deviceId12345"); + // VehicleIdDeviceIdMapping mapping = new VehicleIdDeviceIdMapping(Version.V1_0, value); + // + // ConcurrentHashSet value2 = new ConcurrentHashSet(); + // value2.add("deviceId22345"); + // VehicleIdDeviceIdMapping mapping2 = new VehicleIdDeviceIdMapping(Version.V1_0, value2); + // + // ConcurrentHashSet value3 = new ConcurrentHashSet(); + // value3.add("deviceId32345"); + // VehicleIdDeviceIdMapping mapping3 = new VehicleIdDeviceIdMapping(Version.V1_0, value3); + // + // DeviceStatusKey abc = new DeviceStatusKey("abc"); + // DeviceStatusKey efg = new DeviceStatusKey("efg"); + // DeviceStatusKey hij = new DeviceStatusKey("hij"); + // + // // Device Connection status from now on will be put in to cache from + // // HiveMq and not by DMA + // + // putToCache(abc, mapping); + // putToCache(efg, mapping2); + // putToCache(hij, mapping3); + // deviceStatusCacheBackedInMemoryDAO.initialize(); + // + // Assert.assertEquals(mapping.toString(), + // deviceStatusCacheBackedInMemoryDAO.get(abc).toString()); + // Assert.assertEquals(mapping2.toString(), + // deviceStatusCacheBackedInMemoryDAO.get(efg).toString()); + // } + + /** + * Put to cache. + * + * @param key the key + * @param value the value + */ + private void putToCache(DeviceStatusKey key, VehicleIdDeviceIdMapping value) { + PutMapOfEntitiesRequest putRequest = new PutMapOfEntitiesRequest<>(); + putRequest.withKey(key.getMapKey(serviceName)); + Map pair = new HashMap(); + pair.put(key.convertToString(), value); + putRequest.withValue(pair); + putRequest.withNamespaceEnabled(false); + cache.putMapOfEntities(putRequest); + } + + /** + * Test retry record sync with cache integration. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testRetryRecordSyncWithCacheIntegration() throws InterruptedException { + + ConcurrentHashSet messageIdsSet1 = new ConcurrentHashSet(); + messageIdsSet1.add("message123"); + messageIdsSet1.add("message456"); + + ConcurrentHashSet messageIdsSet2 = new ConcurrentHashSet(); + messageIdsSet2.add("message223"); + messageIdsSet2.add("message256"); + + ConcurrentHashSet messageIdsSet3 = new ConcurrentHashSet(); + messageIdsSet3.add("message323"); + messageIdsSet3.add("message356"); + RetryRecordIds retryMsgIds3 = new RetryRecordIds(Version.V1_0, messageIdsSet3); + + retryBucketDao.setServiceName(serviceName); + retryBucketDao.initialize("taskId"); + + RetryBucketKey key123 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_123); + RetryBucketKey key223 = new RetryBucketKey(TestConstants.TWO_TWO_THREE); + RetryBucketKey key323 = new RetryBucketKey(TestConstants.THREE_TWO_THREE); + + String prefix = RetryBucketKey.getMapKey(serviceName, "taskId"); + RetryRecordIds retryMsgIds1 = new RetryRecordIds(Version.V1_0, messageIdsSet1); + retryBucketDao.putToMap(prefix, key123, retryMsgIds1, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_BUCKET); + RetryRecordIds retryMsgIds2 = new RetryRecordIds(Version.V1_0, messageIdsSet2); + retryBucketDao.putToMap(prefix, key223, retryMsgIds2, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_BUCKET); + retryBucketDao.putToMap(prefix, key323, retryMsgIds3, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_BUCKET); + Assert.assertNotNull(retryBucketDao.get(key323)); + retryBucketDao.deleteFromMap(prefix, key323, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_BUCKET); + retryBucketDao.close(); + Assert.assertNull(retryBucketDao.get(key123)); + retryBucketDao.initialize("taskId"); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_1000); + Assert.assertEquals(retryMsgIds1.toString(), retryBucketDao.get(key123).toString()); + Assert.assertEquals(retryMsgIds2.toString(), retryBucketDao.get(key223).toString()); + Assert.assertNull(retryBucketDao.get(key323)); + } + + /** + * Test sync with cache integration. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testSyncWithCacheIntegration() throws InterruptedException { + IgniteStringKey igniteKey = new IgniteStringKey(); + IgniteEventImpl igniteEvent = new IgniteEventImpl(); + igniteKey.setKey("abc"); + igniteEvent.setEventId("test"); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(igniteEvent), Version.V1_0, + igniteEvent, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + + RetryRecord retryEvent1 = new RetryRecord(igniteKey, entity, TestConstants.THREAD_SLEEP_TIME_2000); + RetryRecord retryEvent2 = new RetryRecord(igniteKey, entity, TestConstants.THREAD_SLEEP_TIME_1000); + RetryRecord retryEvent3 = new RetryRecord(igniteKey, entity, TestConstants.THREAD_SLEEP_TIME_3000); + RetryRecordKey abc = new RetryRecordKey("abc", taskId); + RetryRecordKey bcd = new RetryRecordKey("bcd", taskId); + RetryRecordKey efg = new RetryRecordKey("efg", taskId); + retryRecordDao.setServiceName(serviceName); + retryRecordDao.initialize(taskId); + + String prefix = RetryRecordKey.getMapKey(serviceName, taskId); + retryRecordDao.putToMap(prefix, abc, retryEvent1, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + retryRecordDao.putToMap(prefix, bcd, retryEvent2, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + retryRecordDao.putToMap(prefix, efg, retryEvent3, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + Assert.assertNotNull(retryRecordDao.get(efg)); + retryRecordDao.deleteFromMap(prefix, efg, Optional.empty(), + InternalCacheConstants.CACHE_TYPE_RETRY_RECORD); + retryRecordDao.close(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_1000); + Assert.assertNull(retryRecordDao.get(abc)); + retryRecordDao.initialize(taskId); + + Assert.assertEquals(retryEvent1.getIgniteKey().getKey(), + retryRecordDao + .get(abc) + .getIgniteKey().getKey()); + Assert.assertEquals(retryEvent2.getIgniteKey().getKey(), + retryRecordDao + .get(bcd) + .getIgniteKey().getKey()); + + Assert.assertEquals(retryEvent1.getDeviceMessage().getDeviceMessageHeader().toString(), + retryRecordDao + .get(abc) + .getDeviceMessage().getDeviceMessageHeader().toString()); + Assert.assertEquals(retryEvent2.getDeviceMessage().getDeviceMessageHeader().toString(), + retryRecordDao + .get(bcd) + .getDeviceMessage().getDeviceMessageHeader().toString()); + + Assert.assertNull(retryRecordDao + .get(efg)); + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/SynchronizationIntegrationWithSubServicesTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/SynchronizationIntegrationWithSubServicesTest.java new file mode 100644 index 0000000..6f87fbb --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/SynchronizationIntegrationWithSubServicesTest.java @@ -0,0 +1,154 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.cache.PutMapOfEntitiesRequest; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdMapping; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusDaoCacheBackedInMemoryImpl; +import org.eclipse.ecsp.stream.dma.dao.key.DeviceStatusKey; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.ecsp.utils.ConcurrentHashSet; +import org.junit.Assert; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; + +import java.util.HashMap; +import java.util.Map; + + +/** + * This test class is to verify whether the in-memory state store can sync-up with redis. + * + * @author hbadshah + */ +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/dma-handler-sub-services-test.properties") +public class SynchronizationIntegrationWithSubServicesTest extends KafkaStreamsApplicationTestBase { + + /** The device status cache backed in memory DAO. */ + @Autowired + private DeviceStatusDaoCacheBackedInMemoryImpl deviceStatusCacheBackedInMemoryDAO; + + /** The cache. */ + @Autowired + private IgniteCache cache; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** + * Test device conn sync with cache integration if sub services configured. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testDeviceConnSyncWithCacheIntegrationIfSubServicesConfigured() throws InterruptedException { + ConcurrentHashSet value = new ConcurrentHashSet(); + value.add("deviceId12345"); + VehicleIdDeviceIdMapping mapping = new VehicleIdDeviceIdMapping(Version.V1_0, value); + + ConcurrentHashSet value2 = new ConcurrentHashSet(); + value2.add("deviceId22345"); + VehicleIdDeviceIdMapping mapping2 = new VehicleIdDeviceIdMapping(Version.V1_0, value2); + + ConcurrentHashSet value3 = new ConcurrentHashSet(); + value3.add("deviceId32345"); + VehicleIdDeviceIdMapping mapping3 = new VehicleIdDeviceIdMapping(Version.V1_0, value3); + + DeviceStatusKey abc = new DeviceStatusKey("abc"); + DeviceStatusKey efg = new DeviceStatusKey("efg"); + DeviceStatusKey hij = new DeviceStatusKey("hij"); + + // Device Connection status from now on will be put in to cache from + // HiveMq and not by DMA + + putToCacheForSubServices(abc, mapping); + putToCacheForSubServices(efg, mapping2); + putToCacheForSubServices(hij, mapping3); + deviceStatusCacheBackedInMemoryDAO.initialize(); + + //After initialization, below as argument to DeviceStatusKey's constructor, is how the keys + //will be stored in DMA's in-memory map. Combination of VIN+subService. + DeviceStatusKey abcWithSubService = new DeviceStatusKey("abc;ecall/test/ubi"); + DeviceStatusKey efgWithSubService = new DeviceStatusKey("efg;ecall/test/ubi"); + DeviceStatusKey hijWithSubService = new DeviceStatusKey("hij;ecall/test/ftd"); + Assert.assertNull(deviceStatusCacheBackedInMemoryDAO.get(abcWithSubService)); + Assert.assertNull(deviceStatusCacheBackedInMemoryDAO.get(efgWithSubService)); + Assert.assertNull(deviceStatusCacheBackedInMemoryDAO.get(hijWithSubService)); + } + + /** + * Put to cache for sub services. + * + * @param key the key + * @param value the value + */ + private void putToCacheForSubServices(DeviceStatusKey key, VehicleIdDeviceIdMapping value) { + PutMapOfEntitiesRequest putRequest = new PutMapOfEntitiesRequest<>(); + if (key.convertToString().equals("hij")) { + putRequest.withKey("VEHICLE_DEVICE_MAPPING:ecall/test/ftd"); + } else { + putRequest.withKey("VEHICLE_DEVICE_MAPPING:ecall/test/ubi"); + } + Map pair = new HashMap(); + pair.put(key.convertToString(), value); + putRequest.withValue(pair); + putRequest.withNamespaceEnabled(false); + cache.putMapOfEntities(putRequest); + } +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/TestFilterDMOfflineEntryTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/TestFilterDMOfflineEntryTest.java new file mode 100644 index 0000000..091c0f6 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/TestFilterDMOfflineEntryTest.java @@ -0,0 +1,318 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.ByteArraySerializer; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.entities.AbstractIgniteEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntry; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntryDAOMongoImpl; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusService; +import org.eclipse.ecsp.stream.dma.handler.DeviceConnectionStatusHandler; +import org.eclipse.ecsp.stream.dma.handler.DeviceStatusBackDoorKafkaConsumer; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + + +/** + * TestFilterDMOfflineEntryTest is UT test class to test {@link TestFilterDMOfflineEntryTest}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/filter-dma-offline-test.properties") +public class TestFilterDMOfflineEntryTest extends KafkaStreamsApplicationTestBase { + + /** The service name. */ + @Value("${service.name}") + private String serviceName; + + /** The source topic. */ + @Value("${source.topic.name}") + private String sourceTopic; + + /** The mqtt prefix. */ + @Value("${mqtt.service.topic.name.prefix}") + private String mqttPrefix; + + /** The to device. */ + @Value("${" + PropertyNames.MQTT_TOPIC_TO_DEVICE_INFIX + ":" + Constants.TO_DEVICE + "}") + private String toDevice; + + /** The mqtt topic. */ + @Value("${mqtt.service.topic.name}") + private String mqttTopic; + + /** The device service. */ + @Autowired + private DeviceStatusService deviceService; + + /** The offline buffer dao. */ + @Autowired + private DMOfflineBufferEntryDAOMongoImpl offlineBufferDao; + + /** The device status back door kafka consumer. */ + @Autowired + DeviceStatusBackDoorKafkaConsumer deviceStatusBackDoorKafkaConsumer; + + /** The device connection status handler. */ + @Autowired + DeviceConnectionStatusHandler deviceConnectionStatusHandler; + + /** The device status topic name. */ + private String deviceStatusTopicName; + + /** The vehicle id. */ + private String vehicleId = "Vehicle12345"; + + /** + * setUp(). + * + * @throws Exception Exception + * @throws MqttException MqttException + */ + @Before + public void setUp() throws Exception, MqttException { + super.setup(); + deviceStatusTopicName = DMAConstants.DEVICE_STATUS_TOPIC_PREFIX + serviceName.toLowerCase(); + createTopics(sourceTopic, deviceStatusTopicName); + Properties kafkaConsumerProps = deviceStatusBackDoorKafkaConsumer.getKafkaConsumerProps(); + kafkaConsumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + deviceStatusBackDoorKafkaConsumer.addCallback(deviceConnectionStatusHandler.new DeviceStatusCallBack(), 0); + deviceStatusBackDoorKafkaConsumer.startBackDoorKafkaConsumer(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + launchApplication(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + subscibeToMqttTopic(mqttPrefix + "12345" + toDevice + "/" + mqttTopic); + } + + /** + * Test filter offline buffer. + * + * @throws ExecutionException the execution exception + * @throws InterruptedException the interrupted exception + * @throws TimeoutException the timeout exception + */ + @Test + public void testFilterOfflineBuffer() throws ExecutionException, InterruptedException, TimeoutException { + Properties producerProps = new Properties(); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + String deviceInactive = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\",\"Data\": " + + "{\"connStatus\":\"INACTIVE\",\"serviceName\":\"eCall\"},\"MessageId\": \"1234\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + sendMessages(deviceStatusTopicName, producerProps, + Arrays.asList(vehicleId.getBytes(), deviceInactive.getBytes())); + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + assertNull(deviceService.get(vehicleId, Optional.empty())); + + String speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": " + + "{\"value\":20.0},\"MessageId\": \"1234\",\"CorrelationId\": \"1234\",\"BizTransactionId\": " + + "\"Biz1234\",\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + sendMessages(sourceTopic, producerProps, + Arrays.asList(vehicleId.getBytes(), speedEvent.getBytes())); + String speedEvent1 = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": " + + "{\"value\":30.0},\"MessageId\": \"1235\",\"CorrelationId\": \"1237\",\"BizTransactionId\": " + + "\"Biz1235\",\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + await().atMost(Constants.THREAD_SLEEP_TIME_5000, TimeUnit.MILLISECONDS); + sendMessages(sourceTopic, producerProps, + Arrays.asList(vehicleId.getBytes(), speedEvent1.getBytes())); + await().atMost(Constants.THREAD_SLEEP_TIME_3000, TimeUnit.MILLISECONDS); + String speedEvent2 = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":40.0}," + + "\"MessageId\": \"1236\",\"CorrelationId\": \"1238\",\"BizTransactionId\": \"Biz1236\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + sendMessages(sourceTopic, producerProps, + Arrays.asList(vehicleId.getBytes(), speedEvent2.getBytes())); + String speedEvent3 = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":50.0}," + + "\"MessageId\": \"1237\",\"CorrelationId\": \"1239\",\"BizTransactionId\": \"Biz1237\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + await().atMost(Constants.THREAD_SLEEP_TIME_3000, TimeUnit.MILLISECONDS); + sendMessages(sourceTopic, producerProps, + Arrays.asList(vehicleId.getBytes(), speedEvent3.getBytes())); + await().atMost(Constants.THREAD_SLEEP_TIME_3000, TimeUnit.MILLISECONDS); + String speedEvent4 = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":60.0}," + + "\"MessageId\": \"1238\",\"CorrelationId\": \"2234\",\"BizTransactionId\": \"Biz1238\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + sendMessages(sourceTopic, producerProps, + Arrays.asList(vehicleId.getBytes(), speedEvent4.getBytes())); + await().atMost(Constants.THREAD_SLEEP_TIME_3000, TimeUnit.MILLISECONDS); + List bufferEntries = offlineBufferDao.getOfflineBufferEntriesSortedByPriority(vehicleId, + false, Optional.empty(), Optional.empty()); + assertEquals("Expected 5 entry", Constants.FIVE, bufferEntries.size()); + String deviceActive = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\",\"Data\": " + + "{\"connStatus\":\"ACTIVE\",\"serviceName\":\"eCall\"},\"MessageId\": \"1234\",\"VehicleId\": " + + "\"Vehicle12345\",\"SourceDeviceId\": \"12345\"}"; + sendMessages(deviceStatusTopicName, producerProps, + Arrays.asList(vehicleId.getBytes(), deviceActive.getBytes())); + await().atMost(Constants.THREAD_SLEEP_TIME_5000, TimeUnit.MILLISECONDS); + + String completeMqttTopic = mqttPrefix + "12345" + toDevice + "/" + mqttTopic; + List messages = getMessagesFromMqttTopic(completeMqttTopic, 1, Constants.THREAD_SLEEP_TIME_60000); + assertEquals("No of message expected", TestConstants.FOUR, messages.size()); + } + + /** + * Tear down. + */ + @After + public void tearDown() { + deviceStatusBackDoorKafkaConsumer.shutdown(); + } + + /** + * inner class DMOfflineBufferTestStreamProcessor implements IgniteEventStreamProcessor. + */ + public static class DMOfflineBufferTestStreamProcessor implements IgniteEventStreamProcessor { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "dma-sp"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + IgniteEvent value = kafkaRecord.value(); + if (!value.getEventId().equals(EventID.DEVICEMESSAGEFAILURE)) { + ((AbstractIgniteEvent) value).setDeviceRoutable(true); + spc.forward(kafkaRecord.withValue(value)); + } + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + } + + /** + * Close. + */ + @Override + public void close() { + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + } +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/dao/key/DeviceStatusKeyTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/dao/key/DeviceStatusKeyTest.java new file mode 100644 index 0000000..1b7c5a4 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/dao/key/DeviceStatusKeyTest.java @@ -0,0 +1,123 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao.key; + +import org.eclipse.ecsp.stream.dma.dao.key.DeviceStatusKey; +import org.junit.Assert; +import org.junit.Test; + + +/** + * Test class for {@link DeviceStatusKey}. + */ +public class DeviceStatusKeyTest { + + /** + * Test device status key string. + */ + @Test + public void testDeviceStatusKeyString() { + DeviceStatusKey key = new DeviceStatusKey("key"); + Assert.assertEquals("key", key.getKey()); + } + + /** + * Test convert from string. + */ + @Test + public void testConvertFromString() { + DeviceStatusKey deviceStatusKey = new DeviceStatusKey(); + DeviceStatusKey converted = deviceStatusKey.convertFrom("key"); + Assert.assertEquals("key", converted.getKey()); + } + + /** + * Test hash code. + */ + @Test + public void testHashCode() { + DeviceStatusKey key1 = new DeviceStatusKey("key"); + DeviceStatusKey key2 = new DeviceStatusKey("key"); + Assert.assertEquals(key1.hashCode(), key2.hashCode()); + DeviceStatusKey key3 = new DeviceStatusKey("key3"); + Assert.assertNotEquals(key1.hashCode(), key3.hashCode()); + } + + /** + * Test get key. + */ + @Test + public void testGetKey() { + DeviceStatusKey key = new DeviceStatusKey("key"); + Assert.assertEquals("key", key.getKey()); + } + + /** + * Test set key. + */ + @Test + public void testSetKey() { + DeviceStatusKey key = new DeviceStatusKey(); + key.setKey("keyNew"); + Assert.assertEquals("keyNew", key.getKey()); + } + + /** + * Test convert to string. + */ + @Test + public void testConvertToString() { + DeviceStatusKey key = new DeviceStatusKey("keyConvert"); + Assert.assertEquals("keyConvert", key.convertToString()); + } + + /** + * Test equals object. + */ + @Test + public void testEqualsObject() { + DeviceStatusKey key1 = new DeviceStatusKey("key"); + DeviceStatusKey key2 = new DeviceStatusKey("key"); + Assert.assertEquals(key1, key2); + DeviceStatusKey key3 = new DeviceStatusKey("key3"); + Assert.assertNotEquals(key1, key3); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/dao/key/RetryBucketKeyTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/dao/key/RetryBucketKeyTest.java new file mode 100644 index 0000000..dbf4b69 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/dao/key/RetryBucketKeyTest.java @@ -0,0 +1,122 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao.key; + +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.stream.dma.dao.key.RetryBucketKey; +import org.junit.Assert; +import org.junit.Test; + + +/** + * {@link RetryBucketKeyTest}: UT class for {@link RetryBucketKey}. + */ +public class RetryBucketKeyTest { + + /** + * Test retry bucket key long. + */ + @Test + public void testRetryBucketKeyLong() { + RetryBucketKey key = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_1000); + Assert.assertEquals(TestConstants.THREAD_SLEEP_TIME_1000, key.getTimestamp()); + } + + /** + * Test compare to. + */ + @Test + public void testCompareTo() { + RetryBucketKey key1 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_1000); + RetryBucketKey key2 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_1000); + RetryBucketKey key3 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_2000); + RetryBucketKey key4 = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_500); + Assert.assertEquals(0, key1.compareTo(key2)); + Assert.assertEquals(-TestConstants.ONE, key1.compareTo(key3)); + Assert.assertEquals(1, key1.compareTo(key4)); + } + + /** + * Test convert from string. + */ + @Test + public void testConvertFromString() { + RetryBucketKey key = new RetryBucketKey(); + RetryBucketKey newkey = key.convertFrom("12345"); + Assert.assertEquals(TestConstants.LONG_12345, newkey.getTimestamp()); + } + + /** + * Test convert to string. + */ + @Test + public void testConvertToString() { + RetryBucketKey key = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_1000); + String ts = key.convertToString(); + Assert.assertEquals("1000", ts); + } + + /** + * Test get timestamp. + */ + @Test + public void testGetTimestamp() { + RetryBucketKey key = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_1000); + Assert.assertEquals(TestConstants.THREAD_SLEEP_TIME_1000, key.getTimestamp()); + } + + /** + * Test set timestamp. + */ + @Test + public void testSetTimestamp() { + RetryBucketKey key = new RetryBucketKey(TestConstants.THREAD_SLEEP_TIME_2000); + key.setTimestamp(TestConstants.THREAD_SLEEP_TIME_1000); + Assert.assertEquals(TestConstants.THREAD_SLEEP_TIME_1000, key.getTimestamp()); + } + + /** + * Test get map key. + */ + @Test + public void testGetMapKey() { + Assert.assertEquals("RETRY_BUCKET:service:task", RetryBucketKey.getMapKey("service", "task")); + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/dao/key/RetryRecordKeyTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/dao/key/RetryRecordKeyTest.java new file mode 100644 index 0000000..8e62426 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/dao/key/RetryRecordKeyTest.java @@ -0,0 +1,156 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao.key; + +import org.eclipse.ecsp.stream.dma.dao.key.RetryRecordKey; +import org.junit.Assert; +import org.junit.Test; + + +/** + * class {@link RetryRecordKeyTest}. + */ +public class RetryRecordKeyTest { + + /** + * Test hash code. + */ + @Test + public void testHashCode() { + RetryRecordKey key1 = new RetryRecordKey("msg1", "taskId1"); + RetryRecordKey key2 = new RetryRecordKey("msg1", "taskId1"); + RetryRecordKey key3 = new RetryRecordKey("msg2", "taskId2"); + RetryRecordKey key4 = new RetryRecordKey("msg3", "taskId1"); + Assert.assertEquals(key1.hashCode(), key2.hashCode()); + Assert.assertNotEquals(key1, key3); + Assert.assertNotEquals(key3, key4); + } + + /** + * Test retry record key string. + */ + @Test + public void testRetryRecordKeyString() { + RetryRecordKey key1 = new RetryRecordKey("msg1", "taskId1"); + Assert.assertEquals("msg1", key1.getKey()); + Assert.assertEquals("taskId1", key1.getTaskID()); + } + + /** + * Test get key. + */ + @Test + public void testGetKey() { + RetryRecordKey key1 = new RetryRecordKey("msg1", "taskId1"); + Assert.assertEquals("msg1", key1.getKey()); + } + + /** + * Test get task id. + */ + @Test + public void testGetTaskId() { + RetryRecordKey key1 = new RetryRecordKey("msg1", "taskId1"); + Assert.assertEquals("taskId1", key1.getTaskID()); + } + + /** + * Test set key. + */ + @Test + public void testSetKey() { + RetryRecordKey key = new RetryRecordKey(); + key.setKey("keyNew"); + Assert.assertEquals("keyNew", key.getKey()); + } + + /** + * Test set task id. + */ + @Test + public void testSetTaskId() { + RetryRecordKey key = new RetryRecordKey(); + key.setTaskId("taskId1"); + Assert.assertEquals("taskId1", key.getTaskID()); + } + + /** + * Test convert from string. + */ + @Test + public void testConvertFromString() { + RetryRecordKey key1 = new RetryRecordKey(); + RetryRecordKey key = key1.convertFrom("taskId1:msg1"); + Assert.assertEquals("msg1", key.getKey()); + Assert.assertEquals("taskId1", key.getTaskID()); + } + + /** + * Test convert to string. + */ + @Test + public void testConvertToString() { + RetryRecordKey key = new RetryRecordKey("msg1", "taskId1"); + Assert.assertEquals("taskId1:msg1", key.convertToString()); + } + + /** + * Test equals object. + */ + @Test + public void testEqualsObject() { + RetryRecordKey key1 = new RetryRecordKey("msg1", "taskId1"); + RetryRecordKey key2 = new RetryRecordKey("msg1", "taskId1"); + RetryRecordKey key3 = new RetryRecordKey("msg2", "taskId1"); + RetryRecordKey key4 = new RetryRecordKey("msg3", "taskId1"); + Assert.assertEquals(key1, key2); + Assert.assertNotEquals(key1, key3); + Assert.assertNotEquals(key3, key4); + } + + /** + * Test get map key. + */ + @Test + public void testGetMapKey() { + Assert.assertEquals("RETRY_MESSAGEID:service:task", RetryRecordKey.getMapKey("service", "task")); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/dao/key/RetryVehicleIdKeyTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/dao/key/RetryVehicleIdKeyTest.java new file mode 100644 index 0000000..78d72a3 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/dao/key/RetryVehicleIdKeyTest.java @@ -0,0 +1,107 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao.key; + +import org.eclipse.ecsp.stream.dma.dao.key.RetryVehicleIdKey; +import org.junit.Assert; +import org.junit.Test; + + +/** + * UT class RetryVehicleIdKeyTest. + */ +public class RetryVehicleIdKeyTest { + + /** The key. */ + String key = "retry_test_key"; + + /** + * Test get key. + */ + @Test + public void testGetKey() { + RetryVehicleIdKey retryKey = new RetryVehicleIdKey(key); + Assert.assertEquals(key, retryKey.getKey()); + retryKey.setKey(key + "1"); + Assert.assertEquals(key + "1", retryKey.getKey()); + } + + /** + * Test convert from. + */ + @Test + public void testConvertFrom() { + RetryVehicleIdKey retryKey = new RetryVehicleIdKey(); + retryKey = retryKey.convertFrom(key); + Assert.assertEquals(key, retryKey.getKey()); + } + + /** + * Test hash code. + */ + @Test + public void testHashCode() { + RetryVehicleIdKey retryKey = new RetryVehicleIdKey(key); + int result = retryKey.hashCode(); + Assert.assertNotEquals(0, result); + } + + /** + * Test equals. + */ + @Test + public void testEquals() { + RetryVehicleIdKey retryKey = new RetryVehicleIdKey(key); + Assert.assertEquals(retryKey, retryKey); + Assert.assertNotEquals(null, retryKey); + + String key = "abc"; + Assert.assertNotEquals(retryKey, key); + + RetryVehicleIdKey retryKey2 = new RetryVehicleIdKey(); + Assert.assertNotEquals(retryKey2, retryKey); + + retryKey2.setKey(key); + Assert.assertNotEquals(retryKey2, retryKey); + + retryKey2.setKey(this.key); + Assert.assertEquals(retryKey2, retryKey); + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/dao/key/ShoulderTapRetryBucketKeyTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/dao/key/ShoulderTapRetryBucketKeyTest.java new file mode 100644 index 0000000..94457c6 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/dao/key/ShoulderTapRetryBucketKeyTest.java @@ -0,0 +1,125 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.dao.key; + +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.stream.dma.dao.key.ShoulderTapRetryBucketKey; +import org.junit.Assert; +import org.junit.Test; + + +/** + * {@link ShoulderTapRetryBucketKeyTest} test class for {@link ShoulderTapRetryBucketKey}. + */ +public class ShoulderTapRetryBucketKeyTest { + + /** + * Test shoulder tap shoulder tap retry bucket key long string string. + */ + @Test + public void testShoulderTapShoulderTapRetryBucketKeyLongStringString() { + ShoulderTapRetryBucketKey key = new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_1000); + Assert.assertEquals(TestConstants.THREAD_SLEEP_TIME_1000, key.getTimestamp()); + } + + /** + * Test compare to. + */ + @Test + public void testCompareTo() { + ShoulderTapRetryBucketKey key1 = new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_1000); + ShoulderTapRetryBucketKey key2 = new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_1000); + ShoulderTapRetryBucketKey key3 = new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_2000); + ShoulderTapRetryBucketKey key4 = new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_500); + Assert.assertEquals(0, key1.compareTo(key2)); + Assert.assertEquals(Constants.LONG_MINUS_ONE, key1.compareTo(key3)); + Assert.assertEquals(1, key1.compareTo(key4)); + } + + /** + * Test convert from string. + */ + @Test + public void testConvertFromString() { + String keyStr = "12345"; + ShoulderTapRetryBucketKey key = new ShoulderTapRetryBucketKey(); + ShoulderTapRetryBucketKey newkey = key.convertFrom(keyStr); + Assert.assertEquals(TestConstants.LONG_12345, newkey.getTimestamp()); + } + + /** + * Test convert to string. + */ + @Test + public void testConvertToString() { + ShoulderTapRetryBucketKey key = new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_1000); + String ts = key.convertToString(); + Assert.assertEquals("1000", ts); + } + + /** + * Test get timestamp. + */ + @Test + public void testGetTimestamp() { + ShoulderTapRetryBucketKey key = new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_1000); + Assert.assertEquals(TestConstants.THREAD_SLEEP_TIME_1000, key.getTimestamp()); + } + + /** + * Test set timestamp. + */ + @Test + public void testSetTimestamp() { + ShoulderTapRetryBucketKey key = new ShoulderTapRetryBucketKey(TestConstants.THREAD_SLEEP_TIME_2000); + key.setTimestamp(TestConstants.THREAD_SLEEP_TIME_1000); + Assert.assertEquals(TestConstants.THREAD_SLEEP_TIME_1000, key.getTimestamp()); + } + + /** + * Test get map key. + */ + @Test + public void testGetMapKey() { + Assert.assertEquals("SHOULDER_TAP_RETRY_BUCKET:service:task", + ShoulderTapRetryBucketKey.getMapKey("service", "task")); + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/DMAConfigResolverTestImpl.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/DMAConfigResolverTestImpl.java new file mode 100644 index 0000000..9d0a18c --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/DMAConfigResolverTestImpl.java @@ -0,0 +1,72 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.stream.dma.config.DMAConfigResolver; +import org.springframework.stereotype.Component; + + +/** + * {@link DMAConfigResolverTestImpl} implements {@link DMAConfigResolver}. + */ +@Component +public class DMAConfigResolverTestImpl implements DMAConfigResolver { + + /** + * Gets the retry interval. + * + * @param event the event + * @return the retry interval + */ + @Override + public long getRetryInterval(IgniteEvent event) { + if (event.getEcuType() == null) { + return Constants.THREAD_SLEEP_TIME_5000; + } + if (event.getEcuType().equalsIgnoreCase("TELEMATICS")) { + return Constants.THREAD_SLEEP_TIME_4000; + } else if (event.getEcuType().equalsIgnoreCase("ecu1")) { + return Constants.THREAD_SLEEP_TIME_3000; + } + return 0; + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/DMAFeedbackTopicTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/DMAFeedbackTopicTest.java new file mode 100644 index 0000000..3c75e65 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/DMAFeedbackTopicTest.java @@ -0,0 +1,272 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.eclipse.ecsp.entities.AbstractIgniteEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.List; +import java.util.Properties; + +import static java.util.concurrent.CompletableFuture.delayedExecutor; +import static java.util.concurrent.CompletableFuture.runAsync; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + + +/** + * class {@link DMAFeedbackTopicTest} extends {@link KafkaStreamsApplicationTestBase}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/dma-connectionstatus-handler-test.properties") +public class DMAFeedbackTopicTest extends KafkaStreamsApplicationTestBase { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(DMAFeedbackTopicTest.class); + + /** The source topic name. */ + private static String sourceTopicName; + + /** The i. */ + private static int i = 0; + + /** The vehicle id. */ + private final String vehicleId = "Vehicle12345"; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The device message feedback topic. */ + @Value("${" + PropertyNames.DEVICE_MESSAGE_FEEDBACK_TOPIC + ":#{null}}") + private String deviceMessageFeedbackTopic; + + /** + * Gets the service name. + * + * @return the service name + */ + public String getServiceName() { + return serviceName; + } + + /** + * Sets the service name. + * + * @param serviceName the new service name + */ + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + /** + * setup() to initialize producerProps. + * + * @throws Exception Exception + */ + @Before + public void setup() throws Exception { + super.setup(); + i++; + sourceTopicName = "sourceTopic" + i; + createTopics(sourceTopicName, "dff-dfn-updates"); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, sourceTopicName); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "tc-consumer"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + Serdes.String().deserializer().getClass().getName()); + } + + /** + * Test with feed back topic name. + * + * @throws Exception the exception + */ + @Test + public void testWithFeedBackTopicName() throws Exception { + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, DMATestServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + launchApplication(); + runAsync(() -> {}, delayedExecutor(TestConstants.THREAD_SLEEP_TIME_10000, MILLISECONDS)).join(); + String speedEventWithVehicleId = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": " + + "{\"value\":20.0},\"MessageId\": \"1234\",\"CorrelationId\": \"1234\"," + + "\"BizTransactionId\": \"Biz1234\",\"VehicleId\": \"Vehicle12345\"}"; + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, vehicleId.getBytes(), + speedEventWithVehicleId.getBytes()); + runAsync(() -> {}, delayedExecutor(TestConstants.THREAD_SLEEP_TIME_10000, MILLISECONDS)).join(); + List messages = KafkaTestUtils.getMessages(deviceMessageFeedbackTopic, + consumerProps, 1, TestConstants.TEN_THOUSAND); + Assert.assertNotNull(deviceMessageFeedbackTopic); + Assert.assertNotNull(messages.get(0)[1]); + shutDownApplication(); + } + + /** + * inner class {@link DMATestServiceProcessor} implements {@link IgniteEventStreamProcessor}. + */ + public static final class DMATestServiceProcessor implements IgniteEventStreamProcessor { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "DmaTestServiceProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + AbstractIgniteEvent event = (AbstractIgniteEvent) kafkaRecord.value(); + event.setDeviceRoutable(true); + spc.forward(kafkaRecord); + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + } + + /** + * Close. + */ + @Override + public void close() { + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { sourceTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] {}; + } + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceConnectionStatusHandlerUnitTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceConnectionStatusHandlerUnitTest.java new file mode 100644 index 0000000..f63a4ae --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceConnectionStatusHandlerUnitTest.java @@ -0,0 +1,1005 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.DefaultDeviceConnectionStatusRetriever; +import org.eclipse.ecsp.domain.DeviceConnStatusV1_0.ConnectionStatus; +import org.eclipse.ecsp.domain.DeviceMessageFailureEventDataV1_0; +import org.eclipse.ecsp.domain.SpeedV1_0; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageErrorCode; +import org.eclipse.ecsp.entities.dma.DeviceMessageHeader; +import org.eclipse.ecsp.entities.dma.VehicleIdDeviceIdStatus; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntry; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntryDAOMongoImpl; +import org.eclipse.ecsp.stream.dma.dao.DeviceMessagingException; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusAPIInMemoryService; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusDaoCacheBackedInMemoryImpl; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusDaoInMemoryCache; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusService; +import org.eclipse.ecsp.stream.dma.dao.key.DeviceStatusKey; +import org.eclipse.ecsp.stream.dma.shouldertap.DeviceShoulderTapService; +import org.eclipse.ecsp.utils.ConcurrentHashSet; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + + +/** + * UT class for {@link DeviceConnectionStatusHandler}. + */ +public class DeviceConnectionStatusHandlerUnitTest { + + /** The mockito rule. */ + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + /** The test filter DM offline buffer entry impl. */ + @Mock + TestFilterDMOfflineBufferEntryImpl testFilterDMOfflineBufferEntryImpl; + + /** The no filter DM offline buffer entry impl. */ + @Mock + NoFilterDMOfflineBufferEntryImpl noFilterDMOfflineBufferEntryImpl; + + /** The status API in memory service. */ + @Mock + DeviceStatusAPIInMemoryService statusAPIInMemoryService; + + /** The status retriever. */ + @Mock + DefaultDeviceConnectionStatusRetriever statusRetriever; + + /** The in memory dao. */ + @Mock + DeviceStatusDaoInMemoryCache inMemoryDao; + + /** The device status dao. */ + @Mock + DeviceStatusDaoCacheBackedInMemoryImpl deviceStatusDao; + + /** The device connection status handler. */ + @InjectMocks + private DeviceConnectionStatusHandler deviceConnectionStatusHandler; + + /** The next handler. */ + @Mock + private DeviceMessageHandler nextHandler; + + /** The spc. */ + @Mock + private StreamProcessingContext spc; + + /** The device service. */ + @Mock + private DeviceStatusService deviceService; + + /** The device message utils. */ + @Mock + private DeviceMessageUtils deviceMessageUtils; + + /** The offline buffer DAO. */ + @Mock + private DMOfflineBufferEntryDAOMongoImpl offlineBufferDAO; + + /** The device shoulder tap service. */ + @Mock + private DeviceShoulderTapService deviceShoulderTapService; + + /** The test key. */ + private RetryTestKey testKey = new RetryTestKey(); + + /** + * setup(). + */ + + @Before + public void setup() { + MockitoAnnotations.openMocks(this); + testKey.setKey("Vehicle12345"); + deviceConnectionStatusHandler.setNextHandler(nextHandler); + } + + /** + * Test get connection status if found active in memory. + */ + @Test + public void testGetConnectionStatusIfFoundActiveInMemory() { + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + event.setEventData(speed); + String vehicleId = "Vehicle12345"; + String deviceId = "Device12345"; + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId); + + String payload = "payload"; + DeviceMessage msg = new DeviceMessage(payload.getBytes(), Version.V1_0, event, + "topic", Constants.THREAD_SLEEP_TIME_60000); + msg.isOtherBrokerConfigured(true); + + ConcurrentHashMap map = new ConcurrentHashMap<>(); + map.put(deviceId, ConnectionStatus.ACTIVE); + VehicleIdDeviceIdStatus mapping = new VehicleIdDeviceIdStatus(Version.V1_0, map); + + Mockito.when(statusAPIInMemoryService.get(vehicleId)).thenReturn(mapping); + deviceConnectionStatusHandler.handle(testKey, msg); + Mockito.verify(nextHandler, Mockito.times(1)).handle(testKey, msg); + } + + /** + * Test get connection status if found inactive in memory. + */ + @Test + public void testGetConnectionStatusIfFoundInactiveInMemory() { + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + event.setEventData(speed); + String vehicleId = "Vehicle12345"; + String deviceId = "Device12345"; + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId); + + String payload = "payload"; + DeviceMessage msg = new DeviceMessage(payload.getBytes(), Version.V1_0, event, + "topic", Constants.THREAD_SLEEP_TIME_60000); + msg.isOtherBrokerConfigured(true); + + ConcurrentHashMap map = new ConcurrentHashMap<>(); + map.put(deviceId, ConnectionStatus.INACTIVE); + VehicleIdDeviceIdStatus mapping = new VehicleIdDeviceIdStatus(Version.V1_0, map); + + Mockito.when(statusAPIInMemoryService.get(vehicleId)).thenReturn(mapping); + deviceConnectionStatusHandler.handle(testKey, msg); + + Mockito.verify(statusAPIInMemoryService, Mockito.times(1)).get(Mockito.anyString()); + Mockito.verify(nextHandler, Mockito.times(0)).handle(testKey, msg); + + DeviceMessageFailureEventDataV1_0 data = new DeviceMessageFailureEventDataV1_0(); + data.setFailedIgniteEvent(msg.getEvent()); + data.setErrorCode(DeviceMessageErrorCode.DEVICE_STATUS_INACTIVE); + data.setDeviceStatusInactive(true); + + Mockito.verify(deviceMessageUtils, Mockito.times(1)) + .postFailureEvent(data, testKey, spc, msg.getFeedBackTopic()); + Mockito.verify(offlineBufferDAO, Mockito.times(1)) + .addOfflineBufferEntry(vehicleId, testKey, msg, null); + String service = "ecall"; + Mockito.verify(deviceShoulderTapService, Mockito.times(0)) + .wakeUpDevice("req", + vehicleId, service, testKey, msg, new HashMap<>()); + } + + /** + * Test get connection status with one vehicle multiple devices. + */ + @Test + public void testGetConnectionStatusWithOneVehicleMultipleDevices() { + String requestId = "req124"; + String service = "ecall"; + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setRequestId(requestId); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + event.setEventData(speed); + String vehicleId = "Vehicle12345"; + String deviceId1 = "Device12345"; + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId1); + + String payload = "payload"; + DeviceMessage msg = new DeviceMessage(payload.getBytes(), Version.V1_0, + event, "topic", Constants.THREAD_SLEEP_TIME_60000); + msg.isOtherBrokerConfigured(true); + + Mockito.when(statusAPIInMemoryService.get(vehicleId)).thenReturn(null); + ConcurrentHashMap deviceStatus = new ConcurrentHashMap<>(); + deviceStatus.put(deviceId1, ConnectionStatus.ACTIVE); + VehicleIdDeviceIdStatus mapping = new VehicleIdDeviceIdStatus(Version.V1_0, deviceStatus); + Mockito.when(statusRetriever.getConnectionStatusData(requestId, vehicleId, deviceId1)).thenReturn(mapping); + + deviceConnectionStatusHandler.handle(testKey, msg); + + Mockito.verify(statusRetriever, Mockito.times(1)) + .getConnectionStatusData(requestId, vehicleId, deviceId1); + Mockito.verify(statusAPIInMemoryService, Mockito.times(1)) + .update(vehicleId, deviceId1, ConnectionStatus.ACTIVE.toString()); + Mockito.verify(nextHandler, Mockito.times(1)).handle(testKey, msg); + + //prepare one more event with another deviceId + speed.setValue(Constants.THREAD_SLEEP_TIME_200); + event.setRequestId("requestId2"); + event.setMessageId("Msg12"); + event.setBizTransactionId("Biz12"); + event.setEventData(speed); + String deviceId2 = "Device786"; + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId2); + msg = new DeviceMessage(payload.getBytes(), Version.V1_0, event, "topic", Constants.THREAD_SLEEP_TIME_60000); + msg.isOtherBrokerConfigured(true); + + Mockito.when(statusAPIInMemoryService.get(vehicleId)).thenReturn(mapping); + // Mock returning of the existing mapping in-memory cache which is: + // Vehicle12345={Device12345=ACTIVE} + Mockito.when(inMemoryDao.get(Mockito.any(DeviceStatusKey.class))).thenReturn(mapping); + // prepare new mapping for deviceId: Device786 and same VIN + ConcurrentHashMap deviceStatus2 = new ConcurrentHashMap<>(); + deviceStatus2.put(deviceId2, ConnectionStatus.INACTIVE); + VehicleIdDeviceIdStatus mapping2 = new VehicleIdDeviceIdStatus(Version.V1_0, deviceStatus2); + Mockito.when(statusRetriever.getConnectionStatusData("requestId2", vehicleId, deviceId2)).thenReturn(mapping2); + //send new request for Vehicle12345 and Device786 + deviceConnectionStatusHandler.handle(testKey, msg); + + //verify that for Device786 too API got invoked, even though + // data for VIN already existed in in-memory but it was + //for the other deviceId. + Mockito.verify(statusRetriever, Mockito.times(1)) + .getConnectionStatusData("requestId2", vehicleId, deviceId2); + //verify for Device786 existing mapping got updated. + Mockito.verify(statusAPIInMemoryService, Mockito.times(1)) + .update(vehicleId, deviceId2, ConnectionStatus.INACTIVE.toString()); + } + + /** + * Test get connection status redisdevice ids in cache is null. + */ + @Test + public void testGetConnectionStatusRedisdeviceIdsInCacheIsNull() { + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + String vehicleId = "Vehicle12345"; + String deviceId = "Device12345"; + event.setEventData(speed); + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId); + + String payload = "payload"; + DeviceMessage msg = new DeviceMessage(payload.getBytes(), Version.V1_0, + event, "topic", Constants.THREAD_SLEEP_TIME_60000); + msg.isOtherBrokerConfigured(false); + + ConcurrentHashMap map = new ConcurrentHashMap<>(); + map.put(deviceId, ConnectionStatus.INACTIVE); + VehicleIdDeviceIdStatus mapping = new VehicleIdDeviceIdStatus(Version.V1_0, map); + + Mockito.when(statusAPIInMemoryService.get(vehicleId)).thenReturn(mapping); + ConcurrentHashSet deviceIdsInCache = new ConcurrentHashSet<>(); + Mockito.when(deviceService.get(Mockito.any(), Mockito.any())).thenReturn(deviceIdsInCache); + deviceConnectionStatusHandler.handle(testKey, msg); + Mockito.verify(offlineBufferDAO, Mockito.times(1)).addOfflineBufferEntry(vehicleId, testKey, msg, null); + } + + /** + * Test get connection status if not found in memory. + */ + @Test + public void testGetConnectionStatusIfNotFoundInMemory() { + String requestId = "req124"; + String service = "ecall"; + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setRequestId(requestId); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + event.setEventData(speed); + String vehicleId = "Vehicle12345"; + String deviceId = "Device12345"; + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId); + + String payload = "payload"; + DeviceMessage msg = new DeviceMessage(payload.getBytes(), Version.V1_0, + event, "topic", Constants.THREAD_SLEEP_TIME_60000); + msg.isOtherBrokerConfigured(true); + + Mockito.when(statusAPIInMemoryService.get(vehicleId)).thenReturn(null); + ConcurrentHashMap deviceStatus = new ConcurrentHashMap<>(); + deviceStatus.put(deviceId, ConnectionStatus.ACTIVE); + VehicleIdDeviceIdStatus mapping = new VehicleIdDeviceIdStatus(Version.V1_0, deviceStatus); + Mockito.when(statusRetriever.getConnectionStatusData(requestId, vehicleId, deviceId)).thenReturn(mapping); + + deviceConnectionStatusHandler.handle(testKey, msg); + + Mockito.verify(statusRetriever, Mockito.times(1)) + .getConnectionStatusData(requestId, vehicleId, deviceId); + Mockito.verify(statusAPIInMemoryService, Mockito.times(1)) + .update(vehicleId, deviceId, ConnectionStatus.ACTIVE.toString()); + } + + /** + * Test broad cast message. + */ + @Test + public void testBroadCastMessage() { + ConcurrentHashSet deviceIdsInCache = new ConcurrentHashSet(); + String deviceId = "Device12345"; + deviceIdsInCache.add(deviceId); + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + event.setEventData(speed); + String vehicleId = "Vehicle12345"; + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId); + event.setDevMsgGlobalTopic("GlobalMqttTopics"); + + String payload = "payload"; + DeviceMessage msg = new DeviceMessage(payload.getBytes(), Version.V1_0, + event, "topic", Constants.THREAD_SLEEP_TIME_60000); + + deviceConnectionStatusHandler.handle(testKey, msg); + // Here we want to verify that this handler acted as a passthrough. One + // way is to test if deviceService ever gets invoked because if its not + // a passthrough the deviceService will definitely get invoked. + Mockito.verify(deviceService, Mockito.times(0)).get(vehicleId, Optional.empty()); + Mockito.verify(nextHandler, Mockito.times(1)).handle(testKey, msg); + } + + /** + * Test handle device active state for sub service. + */ + @Test + public void testHandleDeviceActiveStateForSubService() { + String deviceId = "Device12345"; + ConcurrentHashSet deviceIdsInCache = new ConcurrentHashSet(); + deviceIdsInCache.add(deviceId); + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + String vehicleId = "Vehicle12345"; + event.setEventData(speed); + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId); + event.setDevMsgTopicSuffix("Ecall/test_service/ubi"); + + deviceConnectionStatusHandler.setSubServicesList( + Arrays.asList("ecall/test_service/ubi", "ecall/test_service/ftd")); + deviceConnectionStatusHandler.setProcessPerSubService(true); + + Mockito.when(deviceService.get(vehicleId, Optional.of("ecall/test_service/ubi"))) + .thenReturn(deviceIdsInCache); + String payload = "payload"; + DeviceMessage msg = new DeviceMessage(payload.getBytes(), Version.V1_0, + event, "topic", Constants.THREAD_SLEEP_TIME_60000); + deviceConnectionStatusHandler.handle(testKey, msg); + Mockito.verify(nextHandler, Mockito.times(1)) + .handle(testKey, msg); + } + + /** + * Test handle device active state. + */ + @Test + public void testHandleDeviceActiveState() { + ConcurrentHashSet deviceIdsInCache = new ConcurrentHashSet(); + String deviceId = "Device12345"; + deviceIdsInCache.add(deviceId); + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + event.setEventData(speed); + String vehicleId = "Vehicle12345"; + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId); + + String payload = "payload"; + DeviceMessage msg = new DeviceMessage(payload.getBytes(), Version.V1_0, + event, "topic", Constants.THREAD_SLEEP_TIME_60000); + Mockito.when(deviceService.get(vehicleId, Optional.empty())) + .thenReturn(deviceIdsInCache); + deviceConnectionStatusHandler.handle(testKey, msg); + Mockito.verify(nextHandler, Mockito.times(1)).handle(testKey, msg); + } + + /** + * Test handle device inactive state with sub service. + */ + @Test + public void testHandleDeviceInactiveStateWithSubService() { + + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + String vehicleId = "Vehicle12345"; + String deviceId = "Device12345"; + String subService = "ecall/test_service/ubi"; + event.setEventData(speed); + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId); + event.setDevMsgTopicSuffix(subService); + + String payload = "payload"; + ReflectionTestUtils.setField(deviceConnectionStatusHandler, "connStatusRetrieverImplClass", + "org.eclipse.ecsp.analytics.stream.base.utils.DefaultDeviceConnectionStatusRetriever"); + deviceConnectionStatusHandler.setup("0", null); + String service = "ecall"; + DeviceMessage msg = new DeviceMessage(payload.getBytes(), Version.V1_0, + event, "topic", Constants.THREAD_SLEEP_TIME_60000); + deviceConnectionStatusHandler.setServiceName(service); + deviceConnectionStatusHandler.setProcessPerSubService(true); + deviceConnectionStatusHandler.handleDeviceInactiveState(testKey, msg); + DeviceMessageFailureEventDataV1_0 data = new DeviceMessageFailureEventDataV1_0(); + data.setFailedIgniteEvent(msg.getEvent()); + data.setErrorCode(DeviceMessageErrorCode.DEVICE_STATUS_INACTIVE); + data.setDeviceStatusInactive(true); + + Mockito.verify(deviceMessageUtils, Mockito.times(1)) + .postFailureEvent(data, testKey, spc, msg.getFeedBackTopic()); + Mockito.verify(offlineBufferDAO, Mockito.times(1)) + .addOfflineBufferEntry(vehicleId, testKey, msg, subService); + Mockito.verify(deviceShoulderTapService, Mockito.times(0)) + .wakeUpDevice("req", vehicleId, service, testKey, msg, new HashMap<>()); + } + + /** + * Test handle device inactive state. + */ + @Test + public void testHandleDeviceInactiveState() { + + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setMessageId("Msg123"); + String vehicleId = "Vehicle12345"; + String deviceId = "Device12345"; + event.setBizTransactionId("Biz123"); + event.setEventData(speed); + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId); + + String payload = "payload"; + ReflectionTestUtils.setField(deviceConnectionStatusHandler, "connStatusRetrieverImplClass", + "org.eclipse.ecsp.analytics.stream.base.utils.DefaultDeviceConnectionStatusRetriever"); + + DeviceMessage msg = new DeviceMessage(payload.getBytes(), Version.V1_0, + event, "topic", Constants.THREAD_SLEEP_TIME_60000); + deviceConnectionStatusHandler.setup("0", null); + String service = "ecall"; + deviceConnectionStatusHandler.setServiceName(service); + deviceConnectionStatusHandler.handleDeviceInactiveState(testKey, msg); + + DeviceMessageFailureEventDataV1_0 data = new DeviceMessageFailureEventDataV1_0(); + data.setFailedIgniteEvent(msg.getEvent()); + data.setErrorCode(DeviceMessageErrorCode.DEVICE_STATUS_INACTIVE); + data.setDeviceStatusInactive(true); + + Mockito.verify(deviceMessageUtils, Mockito.times(1)) + .postFailureEvent(data, testKey, spc, msg.getFeedBackTopic()); + Mockito.verify(offlineBufferDAO, Mockito.times(1)) + .addOfflineBufferEntry(vehicleId, testKey, msg, null); + Mockito.verify(deviceShoulderTapService, Mockito.times(0)) + .wakeUpDevice("req", vehicleId, service, testKey, msg, new HashMap<>()); + + } + + /** + * Test handle device inactive state with shoulder tap. + */ + @Test + public void testHandleDeviceInactiveStateWithShoulderTap() { + + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + String vehicleId = "Vehicle12345"; + String deviceId = "Device12345"; + String reqId = "Req123"; + event.setEventData(speed); + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId); + event.setShoulderTapEnabled(true); + event.setRequestId(reqId); + + String payload = "payload"; + ReflectionTestUtils.setField(deviceConnectionStatusHandler, "connStatusRetrieverImplClass", + "org.eclipse.ecsp.analytics.stream.base.utils.DefaultDeviceConnectionStatusRetriever"); + String service = "ecall"; + DeviceMessage msg = new DeviceMessage(payload.getBytes(), Version.V1_0, + event, "topic", Constants.THREAD_SLEEP_TIME_60000); + deviceConnectionStatusHandler.setup("0", null); + deviceConnectionStatusHandler.setServiceName(service); + deviceConnectionStatusHandler.handleDeviceInactiveState(testKey, msg); + + DeviceMessageFailureEventDataV1_0 data = new DeviceMessageFailureEventDataV1_0(); + data.setFailedIgniteEvent(msg.getEvent()); + data.setErrorCode(DeviceMessageErrorCode.DEVICE_STATUS_INACTIVE); + data.setDeviceStatusInactive(true); + + Map extraParameters = new HashMap<>(); + String bizTransactionId = msg.getEvent().getBizTransactionId(); + extraParameters.put(DMAConstants.BIZ_TRANSACTION_ID, bizTransactionId); + + Mockito.verify(deviceMessageUtils, Mockito.times(1)) + .postFailureEvent(data, testKey, spc, msg.getFeedBackTopic()); + Mockito.verify(offlineBufferDAO, Mockito.times(1)) + .addOfflineBufferEntry(vehicleId, testKey, msg, null); + Mockito.verify(deviceShoulderTapService, Mockito.times(1)) + .wakeUpDevice(reqId, vehicleId, service, testKey, msg, extraParameters); + + } + + /** + * Test perform action when status active for sub service. + */ + @Test + public void testPerformActionWhenStatusActiveForSubService() { + String deviceId = "Device12345"; + ConcurrentHashSet deviceIdsInCache = new ConcurrentHashSet(); + deviceIdsInCache.add(deviceId); + ReflectionTestUtils.setField(deviceConnectionStatusHandler, "connStatusRetrieverImplClass", + "org.eclipse.ecsp.analytics.stream.base.utils.DefaultDeviceConnectionStatusRetriever"); + String service = "ecall"; + deviceConnectionStatusHandler.setup("0", null); + deviceConnectionStatusHandler.setServiceName(service); + deviceConnectionStatusHandler.setOfflineBufferPerDevice(false); + deviceConnectionStatusHandler.setFilteredBufferEntry(noFilterDMOfflineBufferEntryImpl); + deviceConnectionStatusHandler.setSubServicesList(Arrays + .asList("ecall/test_service/ubi", "ecall/test_service/ftd")); + String vehicleId = "Vehicle12345"; + IgniteStringKey igniteKey = new IgniteStringKey(); + igniteKey.setKey(vehicleId); + IgniteEventImpl connStatusEvent = new IgniteEventImpl(); + connStatusEvent.setVehicleId(vehicleId); + connStatusEvent.setSourceDeviceId(deviceId); + String subService = "ecall/test_service/ubi"; + List bufferedEntries = new ArrayList(); + Mockito.when(deviceService.get(vehicleId, Optional.of(subService))).thenReturn(deviceIdsInCache); + Mockito.when(offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.empty(), Optional.of(subService))).thenReturn(bufferedEntries); + deviceConnectionStatusHandler.performActionWhenStatusActive(vehicleId, deviceId, null, subService, true, false); + + Mockito.verify(deviceService, Mockito.times(1)) + .put(vehicleId, deviceIdsInCache, Optional.empty(), Optional.of(subService)); + Mockito.verify(offlineBufferDAO, Mockito.times(1)) + .getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.empty(), Optional.of(subService)); + Mockito.verify(offlineBufferDAO, Mockito.times(0)) + .getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.ofNullable(deviceId), Optional.of(subService)); + } + + /** + * Test perform action when status active. + */ + @Test + public void testPerformActionWhenStatusActive() { + + ConcurrentHashSet deviceIdsInCache = new ConcurrentHashSet(); + String deviceId = "Device12345"; + deviceIdsInCache.add(deviceId); + ReflectionTestUtils.setField(deviceConnectionStatusHandler, "connStatusRetrieverImplClass", + "org.eclipse.ecsp.analytics.stream.base.utils.DefaultDeviceConnectionStatusRetriever"); + String service = "ecall"; + deviceConnectionStatusHandler.setup("0", null); + deviceConnectionStatusHandler.setServiceName(service); + deviceConnectionStatusHandler.setOfflineBufferPerDevice(false); + deviceConnectionStatusHandler.setFilteredBufferEntry(noFilterDMOfflineBufferEntryImpl); + + String vehicleId = "Vehicle12345"; + IgniteStringKey igniteKey = new IgniteStringKey(); + igniteKey.setKey(vehicleId); + IgniteEventImpl connStatusEvent = new IgniteEventImpl(); + connStatusEvent.setVehicleId(vehicleId); + connStatusEvent.setSourceDeviceId(deviceId); + List bufferedEntries = new ArrayList(); + Mockito.when(offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.empty(), Optional.empty())).thenReturn(bufferedEntries); + deviceConnectionStatusHandler.performActionWhenStatusActive(vehicleId, deviceId, null, null, false, false); + + Mockito.verify(offlineBufferDAO, Mockito.times(1)).getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.empty(), Optional.empty()); + Mockito.verify(offlineBufferDAO, Mockito.times(0)).getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.ofNullable(deviceId), Optional.empty()); + } + + /** + * Test perform action when status active for ecu type. + */ + @Test + public void testPerformActionWhenStatusActiveForEcuType() { + String service = "ecall"; + + deviceConnectionStatusHandler.setServiceName(service); + deviceConnectionStatusHandler.setOfflineBufferPerDevice(false); + deviceConnectionStatusHandler.setFilteredBufferEntry(noFilterDMOfflineBufferEntryImpl); + + String vehicleId = "Vehicle12345"; + String deviceId = "Device12345"; + IgniteStringKey igniteKey = new IgniteStringKey(); + igniteKey.setKey(vehicleId); + IgniteEventImpl connStatusEvent = new IgniteEventImpl(); + connStatusEvent.setVehicleId(vehicleId); + connStatusEvent.setSourceDeviceId(deviceId); + List bufferedEntries = new ArrayList(); + Mockito.when(offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.empty(), Optional.empty())).thenReturn(bufferedEntries); + deviceConnectionStatusHandler.performActionWhenStatusActive(vehicleId, deviceId, null, null, false, true); + + Mockito.verify(statusAPIInMemoryService, Mockito.times(1)).update(vehicleId, deviceId, DMAConstants.ACTIVE); + Mockito.verify(offlineBufferDAO, Mockito.times(1)).getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.empty(), Optional.empty()); + Mockito.verify(offlineBufferDAO, Mockito.times(0)).getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.ofNullable(deviceId), Optional.empty()); + // verify that the other flow is not getting executed. + Mockito.verify(deviceService, Mockito.times(0)).get(vehicleId, null); + Mockito.verify(deviceService, Mockito.times(0)).put(vehicleId, new ConcurrentHashSet(), null, null); + } + + /** + * Test perform action when status inactive for ecu type. + */ + @Test + public void testPerformActionWhenStatusInactiveForEcuType() { + String vehicleId = "Vehicle12345"; + String deviceId = "Device12345"; + String service = "ecall"; + + deviceConnectionStatusHandler.setServiceName(service); + deviceConnectionStatusHandler.performActionWhenStatusInactive(vehicleId, deviceId, null, null, false, true); + + Mockito.verify(statusAPIInMemoryService, Mockito.times(1)).update(vehicleId, deviceId, DMAConstants.INACTIVE); + Mockito.verify(deviceService, Mockito.times(0)).get(vehicleId, null); + Mockito.verify(deviceService, Mockito.times(0)).delete(vehicleId, deviceId, null, null); + } + + /** + * Test perform action when status active one vehicle to many device. + */ + @Test + public void testPerformActionWhenStatusActiveOneVehicleToManyDevice() { + String deviceId = "Device12345"; + ConcurrentHashSet deviceIdsInCache = new ConcurrentHashSet(); + deviceIdsInCache.add(deviceId); + ReflectionTestUtils.setField(deviceConnectionStatusHandler, "connStatusRetrieverImplClass", + "org.eclipse.ecsp.analytics.stream.base.utils.DefaultDeviceConnectionStatusRetriever"); + String service = "ecall"; + deviceConnectionStatusHandler.setup("0", null); + deviceConnectionStatusHandler.setServiceName(service); + deviceConnectionStatusHandler.setOfflineBufferPerDevice(true); + deviceConnectionStatusHandler.setFilteredBufferEntry(noFilterDMOfflineBufferEntryImpl); + String vehicleId = "Vehicle12345"; + IgniteStringKey igniteKey = new IgniteStringKey(); + igniteKey.setKey(vehicleId); + IgniteEventImpl connStatusEvent = new IgniteEventImpl(); + connStatusEvent.setVehicleId(vehicleId); + connStatusEvent.setSourceDeviceId(deviceId); + List bufferedEntries = new ArrayList(); + Mockito.when(offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.ofNullable(deviceId), Optional.empty())).thenReturn(bufferedEntries); + deviceConnectionStatusHandler.performActionWhenStatusActive(vehicleId, deviceId, null, null, false, false); + + Mockito.verify(offlineBufferDAO, Mockito.times(0)).getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.empty(), Optional.empty()); + Mockito.verify(offlineBufferDAO, Mockito.times(1)).getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.ofNullable(deviceId), Optional.empty()); + } + + /** + * Test filter DM off line entry. + */ + @Test + public void testFilterDMOffLineEntry() { + String deviceId = "Device12345"; + ConcurrentHashSet deviceIdsInCache = new ConcurrentHashSet(); + deviceIdsInCache.add(deviceId); + ReflectionTestUtils.setField(deviceConnectionStatusHandler, "connStatusRetrieverImplClass", + "org.eclipse.ecsp.analytics.stream.base.utils.DefaultDeviceConnectionStatusRetriever"); + String service = "ecall"; + deviceConnectionStatusHandler.setup("0", null); + deviceConnectionStatusHandler.setServiceName(service); + + DMOfflineBufferEntry bufferEntry = new DMOfflineBufferEntry(); + bufferEntry.setDeviceId("vehicle1"); + DeviceMessage event = new DeviceMessage(); + IgniteEventImpl eventImpl = new IgniteEventImpl(); + String eventId = "eventId1"; + eventImpl.setEventId(eventId); + event.setEvent(eventImpl); + String vehicleId = "Vehicle12345"; + DeviceMessageHeader deviceMessageHeader = new DeviceMessageHeader(); + deviceMessageHeader.withRequestId("reqId1").withVehicleId(vehicleId); + event.setDeviceMessageHeader(deviceMessageHeader); + bufferEntry.setEvent(event); + LocalDateTime eventTs = LocalDateTime.now(); + bufferEntry.setEventTs(eventTs); + IgniteStringKey igniteKey = new IgniteStringKey(); + igniteKey.setKey("Vehicle12345"); + bufferEntry.setIgniteKey(igniteKey); + List bufferedEntries = new ArrayList(); + bufferEntry.setVehicleId(vehicleId); + bufferedEntries.add(bufferEntry); + + DMOfflineBufferEntry bufferEntry2 = new DMOfflineBufferEntry(); + bufferEntry2.setDeviceId("vehicle2"); + DeviceMessage event2 = new DeviceMessage(); + IgniteEventImpl eventImpl2 = new IgniteEventImpl(); + String eventId2 = "eventId2"; + eventImpl2.setEventId(eventId2); + DeviceMessageHeader deviceMessageHeader2 = new DeviceMessageHeader(); + deviceMessageHeader2.withRequestId("reqId2").withVehicleId(vehicleId); + event2.setDeviceMessageHeader(deviceMessageHeader2); + event2.setEvent(eventImpl2); + bufferEntry2.setEvent(event2); + LocalDateTime eventTs2 = LocalDateTime.now(); + bufferEntry2.setEventTs(eventTs2); + IgniteStringKey igniteKey2 = new IgniteStringKey(); + igniteKey.setKey("Vehicle12345"); + bufferEntry2.setIgniteKey(igniteKey2); + bufferEntry2.setVehicleId(vehicleId); + bufferedEntries.add(bufferEntry2); + + getBufferedEntries("vehicle3", "eventId3", "reqId3", igniteKey, bufferedEntries); + + Mockito.when(offlineBufferDAO.getOfflineBufferEntriesSortedByPriority(vehicleId, + true, Optional.empty(), Optional.empty())).thenReturn(bufferedEntries); + Mockito.when(testFilterDMOfflineBufferEntryImpl.filterAndUpdateDmOfflineBufferEntries(bufferedEntries)) + .thenReturn(bufferedEntries); + + deviceConnectionStatusHandler.setFilteredBufferEntry(testFilterDMOfflineBufferEntryImpl); + deviceConnectionStatusHandler.performActionWhenStatusActive(vehicleId, deviceId, null, null, false, false); + + Mockito.verify(testFilterDMOfflineBufferEntryImpl, + Mockito.times(1)).filterAndUpdateDmOfflineBufferEntries(bufferedEntries); + Mockito.verify(offlineBufferDAO, Mockito.times(Constants.THREE)) + .removeOfflineBufferEntry(Mockito.any(String.class)); + } + + /** + * Gets the buffered entries. + * + * @param vehicle3 the vehicle 3 + * @param eventId3 the event id 3 + * @param reqId3 the req id 3 + * @param igniteKey the ignite key + * @param bufferedEntries the buffered entries + * @return the buffered entries + */ + private static void getBufferedEntries(String vehicle3, String eventId3, String reqId3, IgniteStringKey igniteKey, + List bufferedEntries) { + DMOfflineBufferEntry bufferEntry3 = new DMOfflineBufferEntry(); + bufferEntry3.setDeviceId(vehicle3); + IgniteEventImpl eventImpl3 = new IgniteEventImpl(); + String vehicleId = "Vehicle12345"; + String deviceId = "Device12345"; + eventImpl3.setEventId(eventId3); + eventImpl3.setVehicleId(vehicleId); + eventImpl3.setSourceDeviceId(deviceId); + DeviceMessage event3 = new DeviceMessage(); + DeviceMessageHeader deviceMessageHeader3 = new DeviceMessageHeader(); + deviceMessageHeader3.withRequestId("reqId3").withVehicleId(vehicleId); + event3.setDeviceMessageHeader(deviceMessageHeader3); + event3.setEvent(eventImpl3); + bufferEntry3.setEvent(event3); + LocalDateTime eventTs3 = LocalDateTime.now(); + bufferEntry3.setEventTs(eventTs3); + IgniteStringKey igniteKey3 = new IgniteStringKey(); + igniteKey.setKey("Vehicle12345"); + bufferEntry3.setIgniteKey(igniteKey3); + bufferEntry3.setVehicleId(vehicleId); + bufferedEntries.add(bufferEntry3); + } + + /** + * Test skip offline buffer with skip events. + */ + @Test + public void testSkipOfflineBufferWithSkipEvents() { + deviceConnectionStatusHandler.setSkipOfflineBufferEvents( + Arrays.asList("Speed", "RPM", "RemoteOperationEngine")); + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + event.setEventData(speed); + String vehicleId = "Vehicle12345"; + String deviceId = "Device12345"; + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId); + event.setEventId("RPM"); + + String payload = "payload"; + ReflectionTestUtils.setField(deviceConnectionStatusHandler, "connStatusRetrieverImplClass", + "org.eclipse.ecsp.analytics.stream.base.utils.DefaultDeviceConnectionStatusRetriever"); + deviceConnectionStatusHandler.setup("0", null); + String service = "ecall"; + DeviceMessage msg = new DeviceMessage(payload.getBytes(), Version.V1_0, + event, "topic", Constants.THREAD_SLEEP_TIME_60000); + deviceConnectionStatusHandler.setServiceName(service); + deviceConnectionStatusHandler.handleDeviceInactiveState(testKey, msg); + DeviceMessageFailureEventDataV1_0 data = new DeviceMessageFailureEventDataV1_0(); + data.setFailedIgniteEvent(msg.getEvent()); + data.setErrorCode(DeviceMessageErrorCode.DEVICE_STATUS_INACTIVE); + data.setDeviceStatusInactive(true); + + Mockito.verify(deviceMessageUtils, Mockito.times(1)) + .postFailureEvent(data, testKey, spc, msg.getFeedBackTopic()); + Mockito.verify(offlineBufferDAO, Mockito.times(0)) + .addOfflineBufferEntry(vehicleId, testKey, msg, null); + } + + /** + * Test skip offline buffer with not to skip events. + */ + @Test + public void testSkipOfflineBufferWithNotToSkipEvents() { + deviceConnectionStatusHandler.setSkipOfflineBufferEvents( + Arrays.asList("Speed", "RPM", "RemoteOperationEngine")); + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + event.setEventData(speed); + String deviceId = "Device12345"; + String vehicleId = "Vehicle12345"; + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId); + event.setEventId("Collision"); + + String payload = "payload"; + String service = "ecall"; + ReflectionTestUtils.setField(deviceConnectionStatusHandler, "connStatusRetrieverImplClass", + "org.eclipse.ecsp.analytics.stream.base.utils.DefaultDeviceConnectionStatusRetriever"); + DeviceMessage msg = new DeviceMessage(payload.getBytes(), Version.V1_0, + event, "topic", Constants.THREAD_SLEEP_TIME_60000); + deviceConnectionStatusHandler.setup("0", null); + deviceConnectionStatusHandler.setServiceName(service); + deviceConnectionStatusHandler.handleDeviceInactiveState(testKey, msg); + + DeviceMessageFailureEventDataV1_0 data = new DeviceMessageFailureEventDataV1_0(); + data.setFailedIgniteEvent(msg.getEvent()); + data.setErrorCode(DeviceMessageErrorCode.DEVICE_STATUS_INACTIVE); + data.setDeviceStatusInactive(true); + + Mockito.verify(deviceMessageUtils, Mockito.times(1)) + .postFailureEvent(data, testKey, spc, msg.getFeedBackTopic()); + Mockito.verify(offlineBufferDAO, Mockito.times(1)) + .addOfflineBufferEntry(vehicleId, testKey, msg, null); + } + + /** + * Test skip offline buffer with empty event list. + */ + @Test + public void testSkipOfflineBufferWithEmptyEventList() { + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + String vehicleId = "Vehicle12345"; + String deviceId = "Device12345"; + event.setEventData(speed); + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId); + event.setEventId("Collision"); + + String payload = "payload"; + DeviceMessage msg = new DeviceMessage(payload.getBytes(), Version.V1_0, + event, "topic", Constants.THREAD_SLEEP_TIME_60000); + + deviceConnectionStatusHandler.setup("0", null); + String service = "ecall"; + deviceConnectionStatusHandler.setServiceName(service); + deviceConnectionStatusHandler.handleDeviceInactiveState(testKey, msg); + + DeviceMessageFailureEventDataV1_0 data = new DeviceMessageFailureEventDataV1_0(); + data.setFailedIgniteEvent(msg.getEvent()); + data.setErrorCode(DeviceMessageErrorCode.DEVICE_STATUS_INACTIVE); + data.setDeviceStatusInactive(true); + + Mockito.verify(deviceMessageUtils, Mockito.times(1)) + .postFailureEvent(data, testKey, spc, msg.getFeedBackTopic()); + Mockito.verify(offlineBufferDAO, Mockito.times(1)) + .addOfflineBufferEntry(vehicleId, testKey, msg, null); + } + + /** + * Test get device id if active if target device id absent. + */ + @Test(expected = DeviceMessagingException.class) + public void testGetDeviceIdIfActiveIfTargetDeviceIdAbsent() { + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + String vehicleId = "Vehicle12345"; + event.setEventData(speed); + event.setVehicleId(vehicleId); + event.setEventId("Collision"); + + ConcurrentHashSet deviceIds = new ConcurrentHashSet<>(); + deviceIds.add("device123"); + deviceIds.add("device456"); + + Mockito.when(deviceService.get(vehicleId, Optional.empty())).thenReturn(deviceIds); + String payload = "payload"; + DeviceMessage msg = new DeviceMessage(payload.getBytes(), + Version.V1_0, event, "topic", Constants.THREAD_SLEEP_TIME_60000); + deviceConnectionStatusHandler.handle(testKey, msg); + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessagingHandlerChainTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessagingHandlerChainTest.java new file mode 100644 index 0000000..20537d9 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceMessagingHandlerChainTest.java @@ -0,0 +1,318 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.analytics.stream.base.KafkaStreamsProcessorContext; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.config.DMAConfigResolver; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.ecsp.transform.Transformer; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + + +/** + * {@link DeviceMessagingHandlerChainTest} UT class for {@link DeviceMessagingHandlerChain}. + */ +public class DeviceMessagingHandlerChainTest { + + /** The Constant CONFIG_RESOLVER. */ + private static final String CONFIG_RESOLVER = "configResolver"; + + /** The Constant DMA_POST_DISPATCH_HANDLER. */ + private static final String DMA_POST_DISPATCH_HANDLER = "dmaPostDispatchHandler"; + + /** The Constant DEVICE_MESSAGING_EVENT_TRANSFORMER. */ + private static final String DEVICE_MESSAGING_EVENT_TRANSFORMER = "deviceMessagingEventTransformer"; + + /** The Constant DEFAULT_POST_DISPATCH_HANDLER_CLASS_NAME. */ + private static final String DEFAULT_POST_DISPATCH_HANDLER_CLASS_NAME = + "org.eclipse.ecsp.stream.dma.handler.DefaultPostDispatchHandler"; + + /** The handler chain. */ + @InjectMocks + public DeviceMessagingHandlerChain handlerChain = new DeviceMessagingHandlerChain(); + + /** The config resolver. */ + @Mock + public DMAConfigResolver configResolver = Mockito.mock(DMAConfigResolverTestImpl.class); + + /** The message handler. */ + @Mock + public DeviceMessageHandler messageHandler = Mockito.mock(DefaultPostDispatchHandler.class); + + /** The transformer. */ + @InjectMocks + public Transformer transformer = new DeviceMessageIgniteEventTransformer(); + + /** The spc. */ + @Mock + public StreamProcessingContext spc = Mockito.mock(KafkaStreamsProcessorContext.class); + + /** The msg validator. */ + @Mock + public DeviceMessageValidator msgValidator = Mockito.mock(DeviceMessageValidator.class); + + /** The header updater. */ + @Mock + public DeviceHeaderUpdater headerUpdater = Mockito.mock(DeviceHeaderUpdater.class); + + /** The retry handler. */ + @Mock + public RetryHandler retryHandler = Mockito.mock(RetryHandler.class); + + /** The thrown. */ + @Rule + public ExpectedException thrown = ExpectedException.none(); + + /** The dispatch handler. */ + @Mock + private DispatchHandler dispatchHandler = Mockito.mock(DispatchHandler.class); + + /** The key. */ + private RetryTestKey key; + + /** The ignite event. */ + private IgniteEventImpl igniteEvent; + + /** The conn status handler. */ + @Mock + private DeviceConnectionStatusHandler connStatusHandler = Mockito.mock(DeviceConnectionStatusHandler.class); + + /** The dma post dispatch handler. */ + @Mock + private DeviceMessageHandler dmaPostDispatchHandler = Mockito.mock(DeviceMessageHandler.class); + + /** + * setup(): to initialize ignite event. + */ + @Before + public void setup() { + igniteEvent = new IgniteEventImpl(); + igniteEvent.setEventId("test"); + igniteEvent.setDeviceRoutable(true); + key = new RetryTestKey(); + key.setKey("vehicle123"); + ReflectionTestUtils.setField(handlerChain, "dmEventTransformer", transformer); + ReflectionTestUtils.setField(handlerChain, "spc", spc); + ReflectionTestUtils.setField(handlerChain, CONFIG_RESOLVER, configResolver); + ReflectionTestUtils.setField(handlerChain, + "deviceMessageValidator", msgValidator); + ReflectionTestUtils.setField(handlerChain, + "dmaPostDispatchHandlerClass", DEFAULT_POST_DISPATCH_HANDLER_CLASS_NAME); + ReflectionTestUtils.setField(handlerChain, DEVICE_MESSAGING_EVENT_TRANSFORMER, + "org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer"); + } + + /** + * Test populate map. + */ + @Test + public void testPopulateMap() { + String broker1 = "broker1"; + String ecuType1 = "ecuType1"; + String ecuType2 = "ecuType2"; + String testTopic1 = "testTopic1"; + String testTopic2 = "testTopic2"; + + String item1 = broker1 + ":" + ecuType1 + "#" + testTopic1 + "," + ecuType2 + "#" + testTopic2; + + List ecuTypes = new ArrayList<>(Arrays.asList(item1)); + ReflectionTestUtils.setField(handlerChain, "ecuTypes", ecuTypes); + handlerChain.setUp(); + + Map> map = (Map>) ReflectionTestUtils + .getField(handlerChain, "brokerToEcuTypesMapping"); + String actualBrokerName = (String) map.keySet().toArray()[0]; + Assert.assertEquals("broker1", actualBrokerName); + + Map innerMap = map.get(actualBrokerName); + Assert.assertEquals(testTopic1, innerMap.get(ecuType1.toLowerCase())); + Assert.assertEquals(testTopic2, innerMap.get(ecuType2.toLowerCase())); + } + + /** + * Test setup with default dma post dispatch handler. + */ + @Test + public void testSetupWithDefaultDmaPostDispatchHandler() { + handlerChain.setUp(); + Assert.assertEquals("Incorrect post dispatch handler implementation class", + DEFAULT_POST_DISPATCH_HANDLER_CLASS_NAME, + ReflectionTestUtils.getField(handlerChain, DMA_POST_DISPATCH_HANDLER).getClass().getCanonicalName()); + } + + /** + * Test setup with null dma config resolver class. + */ + @Test + public void testSetupWithNullDmaConfigResolverClass() { + ReflectionTestUtils.setField(handlerChain, CONFIG_RESOLVER, null); + handlerChain.setUp(); + DMAConfigResolver configResolverInstance = (DMAConfigResolver) + ReflectionTestUtils.getField(handlerChain, CONFIG_RESOLVER); + Assert.assertNotNull(configResolverInstance); + } + + /** + * Test get device routable entity with empty dm feedback topic. + */ + @Test + public void testGetDeviceRoutableEntityWithEmptyDmFeedbackTopic() { + Mockito.when(spc.streamName()).thenReturn("ecall"); + Mockito.when(configResolver.getRetryInterval(Mockito.any(IgniteEventImpl.class))) + .thenReturn(TestConstants.THREAD_SLEEP_TIME_1000); + ReflectionTestUtils.setField(handlerChain, "deviceMessageFeedbackTopic", ""); + handlerChain.handle(key, igniteEvent); + + verify(msgValidator).handle(Mockito.any(IgniteKey.class), Mockito.any(DeviceMessage.class)); + } + + /** + * Test get device routable entity for invalid retry interval. + */ + @Test(expected = IllegalArgumentException.class) + public void testGetDeviceRoutableEntityForInvalidRetryInterval() { + Mockito.when(spc.streamName()).thenReturn("ecall"); + Mockito.when(configResolver.getRetryInterval(Mockito.any(IgniteEventImpl.class))).thenReturn(0L); + handlerChain.handle(key, igniteEvent); + } + + /** + * Test get device routable entity with emtpy stream name. + */ + @Test(expected = RuntimeException.class) + public void testGetDeviceRoutableEntityWithEmtpyStreamName() { + Mockito.when(spc.streamName()).thenReturn(""); + handlerChain.handle(key, igniteEvent); + } + + /** + * Test setup with empty device message transformer. + */ + @Test(expected = IllegalArgumentException.class) + public void testSetupWithEmptyDeviceMessageTransformer() { + ReflectionTestUtils.setField(handlerChain, DEVICE_MESSAGING_EVENT_TRANSFORMER, ""); + handlerChain.setUp(); + } + + /** + * Test setup with invalid device message transformer. + */ + @Test(expected = IllegalArgumentException.class) + public void testSetupWithInvalidDeviceMessageTransformer() { + ReflectionTestUtils.setField(handlerChain, DEVICE_MESSAGING_EVENT_TRANSFORMER, + "org.eclipse.ecsp.analytics.InvalidTransformer"); + handlerChain.setUp(); + } + + /** + * Test setup with invalid dma config resolver class. + */ + @Test(expected = IllegalArgumentException.class) + public void testSetupWithInvalidDmaConfigResolverClass() { + ReflectionTestUtils.setField(handlerChain, "dmaConfigResolverImplClass", + "org.eclipse.ecsp.dma.InvalidConfigResolve"); + handlerChain.setUp(); + } + + /** + * Test construct chain with empty stream processing context. + */ + @Test(expected = RuntimeException.class) + public void testConstructChainWithEmptyStreamProcessingContext() { + handlerChain.constructChain("taskId", null); + } + + /** + * Test close method. + */ + @Test + public void testCloseMethod() { + ReflectionTestUtils.setField(handlerChain, "connStatusHandler", connStatusHandler); + ReflectionTestUtils.setField(handlerChain, "headerUpdater", headerUpdater); + ReflectionTestUtils.setField(handlerChain, "retryHandler", retryHandler); + ReflectionTestUtils.setField(handlerChain, "dispatchHandler", dispatchHandler); + ReflectionTestUtils.setField(handlerChain, "dmaPostDispatchHandler", dmaPostDispatchHandler); + + handlerChain.close(); + verify(msgValidator).close(); + verify(dispatchHandler).close(); + } + + /** + * Test construct chain method. + */ + @Test + public void testConstructChainMethod() { + ReflectionTestUtils.setField(handlerChain, "connStatusHandler", connStatusHandler); + ReflectionTestUtils.setField(handlerChain, "headerUpdater", headerUpdater); + ReflectionTestUtils.setField(handlerChain, "retryHandler", retryHandler); + ReflectionTestUtils.setField(handlerChain, "dispatchHandler", dispatchHandler); + ReflectionTestUtils.setField(handlerChain, "dmaPostDispatchHandler", dmaPostDispatchHandler); + + handlerChain.constructChain("one", spc); + verify(connStatusHandler).setNextHandler(any()); + verify(retryHandler).setNextHandler(any()); + verify(headerUpdater).setNextHandler(any()); + verify(dispatchHandler).setNextHandler(any()); + verify(msgValidator).setNextHandler(any()); + + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceStatusBackDoorKafkaConsumerIntegrationTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceStatusBackDoorKafkaConsumerIntegrationTest.java new file mode 100644 index 0000000..56a34bf --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceStatusBackDoorKafkaConsumerIntegrationTest.java @@ -0,0 +1,199 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.Serdes; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.BackdoorKafkaConsumerCallback; +import org.eclipse.ecsp.analytics.stream.base.kafka.internal.OffsetMetadata; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + + +/** + * class DeviceStatusBackDoorKafkaConsumerIntegrationTest extends KafkaStreamsApplicationTestBase. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@EnableRuleMigrationSupport +@TestPropertySource("/dma-handler-test.properties") +public class DeviceStatusBackDoorKafkaConsumerIntegrationTest extends KafkaStreamsApplicationTestBase { + + /** The device status back door kafka consumer. */ + @Autowired + DeviceStatusBackDoorKafkaConsumer deviceStatusBackDoorKafkaConsumer; + + /** The device status topic. */ + private String deviceStatusTopic; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** + * setUp. + * + * @throws Exception Exception + */ + @Before + public void setup() throws Exception { + super.setup(); + deviceStatusTopic = DMAConstants.DEVICE_STATUS_TOPIC_PREFIX + serviceName.toLowerCase(); + createTopics(deviceStatusTopic); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + + } + + /** + * Test kafka consumer client. + * + * @throws TimeoutException the timeout exception + * @throws ExecutionException the execution exception + * @throws InterruptedException the interrupted exception + */ + @Test + public void testKafkaConsumerClient() throws TimeoutException, ExecutionException, InterruptedException { + TestCallBack callBack = new TestCallBack(); + deviceStatusBackDoorKafkaConsumer.addCallback(callBack, 0); + + Properties kafkaConsumerProps = deviceStatusBackDoorKafkaConsumer.getKafkaConsumerProps(); + kafkaConsumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + deviceStatusBackDoorKafkaConsumer.startBackDoorKafkaConsumer(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + Assert.assertTrue(deviceStatusBackDoorKafkaConsumer.isHealthy(true)); + String speedEvent = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\"," + + "\"Data\": {\"serviceName\":\"ecall\",\"connStatus\":\"ACTIVE\"},\"MessageId\": \"1235\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"12345\",\"BizTransactionId\": \"Biz1234\"}"; + String deviceId = "12345"; + KafkaTestUtils.sendMessages(deviceStatusTopic, producerProps, deviceId.getBytes(), speedEvent.getBytes()); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + Assert.assertEquals(deviceId, callBack.getKey()); + Assert.assertEquals("DeviceConnStatus", callBack.getValue().getEventId()); + Assert.assertEquals("1.0", callBack.getValue().getVersion().getValue()); + Assert.assertEquals("DeviceConnStatusV1_0 [serviceName=ecall, connStatus=ACTIVE]", + callBack.getValue().getEventData().toString()); + Assert.assertEquals("1235", callBack.getValue().getMessageId()); + Assert.assertEquals("Vehicle12345", callBack.getValue().getVehicleId()); + Assert.assertEquals("Biz1234", callBack.getValue().getBizTransactionId()); + deviceStatusBackDoorKafkaConsumer.shutdown(); + Assert.assertFalse(deviceStatusBackDoorKafkaConsumer.isHealthy(false)); + Assert.assertFalse(deviceStatusBackDoorKafkaConsumer.isHealthy(true)); + } + + /** + * The Class TestCallBack. + */ + class TestCallBack implements BackdoorKafkaConsumerCallback { + + /** The key. */ + private String key; + + /** The value. */ + private IgniteEvent value; + + /** + * Gets the key. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Gets the value. + * + * @return the value + */ + public IgniteEvent getValue() { + return value; + } + + /** + * Process. + * + * @param key the key + * @param value the value + * @param meta the meta + */ + @Override + public void process(IgniteKey key, IgniteEvent value, OffsetMetadata meta) { + this.key = key.getKey().toString(); + this.value = value; + } + + /** + * Gets the committable offset. + * + * @return the committable offset + */ + @Override + public Optional getCommittableOffset() { + return Optional.empty(); + } + + } + +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceStatusBackDoorKafkaConsumerUnitTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceStatusBackDoorKafkaConsumerUnitTest.java new file mode 100644 index 0000000..5dbf128 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/DeviceStatusBackDoorKafkaConsumerUnitTest.java @@ -0,0 +1,124 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusDaoCacheBackedInMemoryImpl; +import org.eclipse.ecsp.stream.dma.handler.DeviceStatusBackDoorKafkaConsumer; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + + +/** + * Test class for {@link DeviceStatusBackDoorKafkaConsumer}. + */ +public class DeviceStatusBackDoorKafkaConsumerUnitTest { + + /** The mockito rule. */ + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + /** The backdoor consumer. */ + @InjectMocks + private DeviceStatusBackDoorKafkaConsumer backdoorConsumer; + + /** The connection status dao. */ + @Mock + private DeviceStatusDaoCacheBackedInMemoryImpl connectionStatusDao; + + /** + * Sets the up. + */ + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + } + + /** + * Test monitor name. + */ + @Test + public void testMonitorName() { + Assert.assertEquals("DEVICE_STATUS_BACKDOOR_HEALTH_MONITOR", backdoorConsumer.monitorName()); + } + + /** + * Test metric name. + */ + @Test + public void testMetricName() { + Assert.assertEquals("DEVICE_STATUS_BACKDOOR_HEALTH_GUAGE", backdoorConsumer.metricName()); + } + + /** + * Test needs restart on failure. + */ + @Test + public void testNeedsRestartOnFailure() { + backdoorConsumer.setNeedsRestartOnFailure(true); + Assert.assertTrue(backdoorConsumer.needsRestartOnFailure()); + backdoorConsumer.setNeedsRestartOnFailure(false); + Assert.assertFalse(backdoorConsumer.needsRestartOnFailure()); + } + + /** + * Test is enabled. + */ + @Test + public void testIsEnabled() { + backdoorConsumer.setHealthMonitorEnabled(true); + backdoorConsumer.setIsDmaEnabled(true); + Assert.assertTrue(backdoorConsumer.isEnabled()); + + backdoorConsumer.setIsDmaEnabled(false); + Assert.assertFalse(backdoorConsumer.isEnabled()); + + backdoorConsumer.setIsDmaEnabled(true); + backdoorConsumer.setHealthMonitorEnabled(false); + Assert.assertFalse(backdoorConsumer.isEnabled()); + } + +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/DispatchHandlerTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/DispatchHandlerTest.java new file mode 100644 index 0000000..7ec4989 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/DispatchHandlerTest.java @@ -0,0 +1,145 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaDispatcher; +import org.eclipse.ecsp.analytics.stream.base.utils.MqttDispatcher; +import org.eclipse.ecsp.domain.SpeedV1_0; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import java.util.HashMap; +import java.util.Map; + + +/** + * test class for {@link DispatchHandler}. + */ +public class DispatchHandlerTest { + + /** The handler. */ + @InjectMocks + DispatchHandler handler = new DispatchHandler(); + + /** The mqtt dispatcher. */ + @Mock + MqttDispatcher mqttDispatcher; + + /** The kafka dispatcher. */ + @Mock + KafkaDispatcher kafkaDispatcher; + + /** The test key. */ + private RetryTestKey testKey = new RetryTestKey(); + + /** + * Setup. + */ + @Before + public void setup() { + MockitoAnnotations.openMocks(this); + testKey.setKey("Vehicle123"); + } + + /** + * Test handle. + */ + @Test + public void testHandle() { + Map ecuTypeToTopicMap = new HashMap<>(); + ecuTypeToTopicMap.put("testecu", "testKafkaTopic"); + Map> brokerToEcuTypeMap = new HashMap<>(); + brokerToEcuTypeMap.put("kafka", ecuTypeToTopicMap); + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + event.setEventData(speed); + String payload = "payload"; + String vehicleId = "Vehicle12345"; + String deviceId = "Device12345"; + + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId); + //set ecuType for which dispatch of msg needs to be done on kafka topic + event.setEcuType("testecu"); + DeviceMessage msg = new DeviceMessage(payload.getBytes(), + Version.V1_0, event, "topic", Constants.THREAD_SLEEP_TIME_60000); + + handler.setup("taskId", brokerToEcuTypeMap); + handler.handle(testKey, msg); + + Mockito.verify(kafkaDispatcher, Mockito.times(1)).dispatch(testKey, msg); + } + + /** + * Test handle if no broker to ecu type map configured. + */ + @Test + public void testHandleIfNoBrokerToEcuTypeMapConfigured() { + IgniteEventImpl event = new IgniteEventImpl(); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(Constants.THREAD_SLEEP_TIME_100); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + event.setEventData(speed); + String payload = "payload"; + String vehicleId = "Vehicle12345"; + String deviceId = "Device12345"; + event.setVehicleId(vehicleId); + event.setSourceDeviceId(deviceId); + DeviceMessage msg = new DeviceMessage(payload.getBytes(), + Version.V1_0, event, "topic", Constants.THREAD_SLEEP_TIME_60000); + + handler.handle(testKey, msg); + + Mockito.verify(kafkaDispatcher, Mockito.times(0)).dispatch(testKey, msg); + Mockito.verify(mqttDispatcher, Mockito.times(1)).dispatch(testKey, msg); + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/EventConfigProviderTestImpl.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/EventConfigProviderTestImpl.java new file mode 100644 index 0000000..14ace50 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/EventConfigProviderTestImpl.java @@ -0,0 +1,65 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.stream.dma.config.EventConfigProvider; +import org.springframework.stereotype.Component; + + +/** + * class EventConfigProviderTestImpl implements EventConfigProvider. + */ +@Component +public class EventConfigProviderTestImpl implements EventConfigProvider { + + /** + * Gets the event config. + * + * @param eventId the event id + * @return the event config + */ + @Override + public EventConfigTestImpl getEventConfig(String eventId) { + if (eventId.equals("test_Speed")) { + return new EventConfigTestImpl(); + } + return null; + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/EventConfigTestImpl.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/EventConfigTestImpl.java new file mode 100644 index 0000000..9764c48 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/EventConfigTestImpl.java @@ -0,0 +1,59 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.stream.dma.config.EventConfig; + + +/** + * class EventConfigTestImpl implements EventConfig. + */ +public class EventConfigTestImpl implements EventConfig { + + /** + * Fallback to TTL on max retry exhausted. + * + * @return true, if successful + */ + @Override + public boolean fallbackToTTLOnMaxRetryExhausted() { + return true; + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/MaxFailuresUncaughtExceptionHandlerTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/MaxFailuresUncaughtExceptionHandlerTest.java new file mode 100644 index 0000000..853d69b --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/MaxFailuresUncaughtExceptionHandlerTest.java @@ -0,0 +1,85 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.junit.Before; +import org.junit.Test; + +import static org.apache.kafka.streams.errors.StreamsUncaughtExceptionHandler.StreamThreadExceptionResponse.REPLACE_THREAD; +import static org.junit.Assert.assertEquals; + + +/** + * class MaxFailuresUncaughtExceptionHandlerTest. + */ +public class MaxFailuresUncaughtExceptionHandlerTest { + + /** The works on my box exception. */ + private final IllegalStateException worksOnMyBoxException = + new IllegalStateException("Strange, It worked on my box"); + + /** The exception handler. */ + private MaxFailuresUncaughtExceptionHandler exceptionHandler; + + /** + * setUp(). + */ + @Before + public void setUp() { + long maxTimeMillis = Constants.THREAD_SLEEP_TIME_100; + int maxFailures = Constants.TWO; + exceptionHandler = new MaxFailuresUncaughtExceptionHandler(maxFailures, maxTimeMillis); + } + + /** + * Should replace thread when errors not within max time. + * + * @throws Exception the exception + */ + @Test + public void shouldReplaceThreadWhenErrorsNotWithinMaxTime() throws Exception { + for (int i = 0; i < Constants.TEN; i++) { + assertEquals(REPLACE_THREAD, exceptionHandler.handle(worksOnMyBoxException)); + Thread.sleep(Constants.THREAD_SLEEP_TIME_200); + } + } + +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryHandlerIntegrationTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryHandlerIntegrationTest.java new file mode 100644 index 0000000..e9b6688 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryHandlerIntegrationTest.java @@ -0,0 +1,1023 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.DefaultMqttTopicNameGeneratorImpl; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.eclipse.ecsp.analytics.stream.base.utils.PahoMqttDispatcher; +import org.eclipse.ecsp.domain.DeviceMessageFailureEventDataV1_0; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.AbstractIgniteEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageErrorCode; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntryDAOMongoImpl; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; +import org.eclipse.paho.client.mqttv3.MqttCallback; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.ExecutionException; + + +/** + * class RetryHandlerIntegrationTest extends KafkaStreamsApplicationTestBase. + */ + +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/dma-handler-test.properties") +public class RetryHandlerIntegrationTest extends KafkaStreamsApplicationTestBase { + + /** The conn status topic. */ + private static String connStatusTopic; + + /** The source topic name. */ + private static String sourceTopicName; + + /** The i. */ + private static int i = 0; + + /** The failure event list. */ + private static LinkedList failureEventList; + + /** The vehicle id. */ + private String vehicleId = "Vehicle12345"; + + /** The retry test key. */ + private RetryTestKey retryTestKey = new RetryTestKey(); + + /** The default mqtt topic name generator impl. */ + @Autowired + private DefaultMqttTopicNameGeneratorImpl defaultMqttTopicNameGeneratorImpl; + + /** The transformer. */ + @Autowired + private DeviceMessageIgniteEventTransformer transformer; + + /** The device conn status handler. */ + @Autowired + private DeviceConnectionStatusHandler deviceConnStatusHandler; + + /** The device status back door kafka consumer. */ + @Autowired + DeviceStatusBackDoorKafkaConsumer deviceStatusBackDoorKafkaConsumer; + + /** The retry handler. */ + @Autowired + private RetryHandler retryHandler; + + /** The paho mqtt dispatcher. */ + @Autowired + private PahoMqttDispatcher pahoMqttDispatcher; + + /** The offline buffer DAO. */ + @Autowired + private DMOfflineBufferEntryDAOMongoImpl offlineBufferDAO; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The task id. */ + private String taskId = "taskId"; + + /** + * setup(). + * + * @throws Exception Exception + */ + @Before + public void setup() throws Exception { + failureEventList = new LinkedList(); + retryTestKey.setKey(vehicleId); + super.setup(); + i++; + sourceTopicName = "sourceTopic" + i; + connStatusTopic = DMAConstants.DEVICE_STATUS_TOPIC_PREFIX + serviceName.toLowerCase(); + createTopics(connStatusTopic, sourceTopicName); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, sourceTopicName); + + Properties kafkaConsumerProps = deviceStatusBackDoorKafkaConsumer.getKafkaConsumerProps(); + kafkaConsumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + deviceStatusBackDoorKafkaConsumer.addCallback(deviceConnStatusHandler.new DeviceStatusCallBack(), 0); + deviceStatusBackDoorKafkaConsumer.startBackDoorKafkaConsumer(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + retryHandler.setup(taskId); + } + + /** + * Tear down. + */ + @After + public void tearDown() { + deviceStatusBackDoorKafkaConsumer.shutdown(); + } + + /** + * Retry act as a passthrough. + * + * @throws MqttException MqttException + * @throws Exception the exception + * @throws InterruptedException InterruptedException + * @throws ExecutionException ExecutionException + */ + @Test + public void retryHandlerTestAckNotSet() throws MqttException, Exception, + InterruptedException, ExecutionException { + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, DMARetryTestAckNotSetServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + launchApplication(); + Thread.sleep(Constants.THREAD_SLEEP_TIME_10000); + String deviceConnStatusEvent = "{\"EventID\": \"DeviceConnStatus\"," + + "\"Version\": \"1.0\",\"Data\": {\"connStatus\":\"ACTIVE\"," + + "\"serviceName\":\"eCall\"},\"MessageId\": \"1234\",\"VehicleId\":" + + " \"Vehicle12345\",\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, + vehicleId.getBytes(), deviceConnStatusEvent.getBytes()); + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + IgniteEventImpl retryEvent = new RetryTestEvent(); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(retryEvent), + Version.V1_0, retryEvent, sourceTopicName, Constants.THREAD_SLEEP_TIME_60000); + + MqttClient client = getMqttClient(entity); + List messageList = new ArrayList(); + client.setCallback(new MqttCallback() { + String msgReceived; + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + msgReceived = message.toString(); + messageList.add(msgReceived); + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + + } + + @Override + public void connectionLost(Throwable cause) { + + } + }); + + String speedEventWithVehicleIdAndSourceDeviceId = "{\"EventID\": \"Speed\"," + + "\"Version\": \"1.0\",\"Data\": {\"value\":20.0},\"MessageId\":\"1237\"," + + "\"BizTransactionId\": \"Biz1237\",\"VehicleId\": \"Vehicle12345\"," + + "\"SourceDeviceId\": \"Device12345\"}"; + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, + vehicleId.getBytes(), + speedEventWithVehicleIdAndSourceDeviceId.getBytes()); + Thread.sleep(Constants.TWENTY_THOUSAND); + // Retry will act as a passthrough in this scenario + Assert.assertEquals(1, messageList.size()); + shutDown(); + } + + /** + * Retry handler test when ack received. + * + * @throws MqttException the mqtt exception + * @throws Exception the exception + * @throws InterruptedException the interrupted exception + * @throws ExecutionException the execution exception + */ + @Test + public void retryHandlerTestWhenAckReceived() throws MqttException, + Exception, InterruptedException, ExecutionException { + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, DMARetryTestServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + launchApplication(); + DeviceMessage entity = getEntity(); + MqttClient client = getMqttClient(entity); + List messageList = new ArrayList(); + client.setCallback(new MqttCallback() { + String msgReceived; + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + msgReceived = message.toString(); + messageList.add(msgReceived); + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + + } + + @Override + public void connectionLost(Throwable cause) { + + } + }); + + String speedEventWithVehicleIdAndSourceDeviceId = "{\"EventID\": " + + "\"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":20.0}," + + "\"MessageId\":\"1237\",\"BizTransactionId\": \"Biz1237\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, + vehicleId.getBytes(), + speedEventWithVehicleIdAndSourceDeviceId.getBytes()); + Thread.sleep(Constants.THREAD_SLEEP_TIME_1000); + String ackMsg = "{\"EventID\": \"Ack\",\"Version\": \"1.0\",\"Data\": {}," + + "\"MessageId\":\"9876\",\"CorrelationId\":\"1237\",\"BizTransactionId\":" + + " \"Biz1237\",\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, + vehicleId.getBytes(), ackMsg.getBytes()); + Thread.sleep(Constants.THREAD_SLEEP_TIME_15000); + // once an ack is received Message should not be retried. + int expectedAtmost = Constants.TWO; + boolean flag = messageList.size() <= expectedAtmost; + Assert.assertTrue(flag); + shutDown(); + } + + /** + * Retry handler test with ECU type based retries. + * + * @throws MqttException the mqtt exception + * @throws Exception the exception + * @throws InterruptedException the interrupted exception + * @throws ExecutionException the execution exception + */ + @Test + public void retryHandlerTestWithECUTypeBasedRetries() throws + MqttException, Exception, InterruptedException, ExecutionException { + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, DMARetryTestServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + launchApplication(); + Thread.sleep(Constants.THREAD_SLEEP_TIME_10000); + String speedEventForTelematics = "{\"EventID\": \"Speed\",\"Version\": \"1.0\"," + + "\"Data\": {\"value\":20.0},\"MessageId\":\"1237\",\"BizTransactionId\": " + + "\"Biz1237\",\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"Device12345\"," + + "\"ecuType\": \"TELEMATICS\"}"; + String deviceConnStatusEvent = "{\"EventID\": \"DeviceConnStatus\"," + + "\"Version\": \"1.0\",\"Data\": {\"connStatus\":\"ACTIVE\"," + + "\"serviceName\":\"eCall\"},\"MessageId\": \"1234\",\"VehicleId\": " + + "\"Vehicle12345\",\"SourceDeviceId\": \"Device12345\"}"; + + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, + vehicleId.getBytes(), deviceConnStatusEvent.getBytes()); + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, + vehicleId.getBytes(), speedEventForTelematics.getBytes()); + String speedEventForEcu1 = "{\"EventID\": \"Speed\",\"Version\": " + + "\"1.0\",\"Data\": {\"value\":20.0},\"MessageId\":\"1237\"," + + "\"BizTransactionId\": \"Biz1237\",\"VehicleId\": \"Vehicle12345\"," + + "\"SourceDeviceId\": \"Device12345\",\"ecuType\": \"ecu1\"}"; + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, + vehicleId.getBytes(), speedEventForEcu1.getBytes()); + + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + /* + * get a client to subscribe to the required topic topic + */ + IgniteEventImpl retryEvent = new RetryTestEvent(); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(retryEvent), + Version.V1_0, retryEvent, sourceTopicName, Constants.THREAD_SLEEP_TIME_60000); + MqttClient client = getMqttClient(entity); + List messageList = new ArrayList(); + client.setCallback(new MqttCallback() { + String msgReceived; + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + msgReceived = message.toString(); + System.out.println("message arrived *************************************" + msgReceived); + messageList.add(msgReceived); + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + + } + + @Override + public void connectionLost(Throwable cause) { + + } + }); + + int expectedAtmost = Constants.THREE; + boolean flag = messageList.size() <= expectedAtmost; + System.out.println("message list :---------------------->" + messageList.size()); + Assert.assertTrue(flag); + shutDown(); + } + + /** + * Retry handler test fallback to TTL on max retry exhausted. + * + * @throws MqttException the mqtt exception + * @throws Exception the exception + * @throws InterruptedException the interrupted exception + * @throws ExecutionException the execution exception + */ + @Test + public void retryHandlerTestFallbackToTTLOnMaxRetryExhausted() + throws MqttException, Exception, InterruptedException, ExecutionException { + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, DMARetryTestServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + launchApplication(); + Thread.sleep(Constants.THREAD_SLEEP_TIME_10000); + String deviceConnStatusEvent = "{\"EventID\": \"DeviceConnStatus\", \"Version\": " + + "\"1.0\",\"Data\": {\"connStatus\":\"ACTIVE\", \"serviceName\":\"eCall\"}," + + "\"MessageId\": \"1234\",\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, + vehicleId.getBytes(), deviceConnStatusEvent.getBytes()); + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + /* + * get a client to subscribe to the required topic topic + */ + IgniteEventImpl retryEvent = new RetryTestEvent(); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(retryEvent), + Version.V1_0, retryEvent, sourceTopicName, Constants.THREAD_SLEEP_TIME_60000); + MqttClient client = getMqttClient(entity); + List messageList = new ArrayList(); + client.setCallback(new MqttCallback() { + String msgReceived; + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + msgReceived = message.toString(); + messageList.add(msgReceived); + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + + } + + @Override + public void connectionLost(Throwable cause) { + + } + }); + + String speedEventWithVehicleIdAndSourceDeviceId = "{\"EventID\": " + + "\"test_Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":20.0}," + + "\"MessageId\":\"1237\",\"BizTransactionId\": \"Biz1237\",\"VehicleId\":" + + " \"Vehicle12345\",\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, + vehicleId.getBytes(), + speedEventWithVehicleIdAndSourceDeviceId.getBytes()); + Thread.sleep(Constants.INT_20000); + // message will be send once to device then retried Constants.THREE + // (Constants.THREE is value of max retry set in property file) times. + // So, totally each message will be sent 4 times to mqtt. + // Expected noOfMessages * 4 messages. + int expected = Constants.FOUR; + Assert.assertEquals(expected, messageList.size()); + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + /* + * Expected failed events = Constants.THREE for Constants.THREE RETRYING_DEVICE_MESSAGE + * We don't send RETRY_ATTEMPTS_EXCEEDED one for this use case. + */ + Assert.assertEquals(Constants.THREE, failureEventList.size()); + /* + * Event will be saved to offline buffer collection in mongo + * upon exhaustion of max retries. + */ + Assert.assertEquals(1, offlineBufferDAO.getOfflineBufferEntriesSortedByPriority( + "Vehicle12345", false, Optional.of("Device12345"), + Optional.empty()).size()); + DeviceMessageFailureEventDataV1_0 data1 = (DeviceMessageFailureEventDataV1_0) + failureEventList.get(0).getEventData(); + Assert.assertEquals(DeviceMessageErrorCode.RETRYING_DEVICE_MESSAGE, data1.getErrorCode()); + Assert.assertEquals(1, data1.getRetryAttempts()); + + DeviceMessageFailureEventDataV1_0 data2 = (DeviceMessageFailureEventDataV1_0) + failureEventList.get(1).getEventData(); + Assert.assertEquals(DeviceMessageErrorCode.RETRYING_DEVICE_MESSAGE, data2.getErrorCode()); + Assert.assertEquals(Constants.TWO, data2.getRetryAttempts()); + + DeviceMessageFailureEventDataV1_0 data3 = (DeviceMessageFailureEventDataV1_0) + failureEventList.get(Constants.TWO).getEventData(); + Assert.assertEquals(DeviceMessageErrorCode.RETRYING_DEVICE_MESSAGE, data3.getErrorCode()); + Assert.assertEquals(Constants.THREE, data3.getRetryAttempts()); + shutDown(); + } + + /** + * Retry handler test. + * + * @throws MqttException the mqtt exception + * @throws Exception the exception + * @throws InterruptedException the interrupted exception + * @throws ExecutionException the execution exception + */ + @Test + public void retryHandlerTest() throws MqttException, Exception, InterruptedException, ExecutionException { + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, DMARetryTestServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + launchApplication(); + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + DeviceMessage entity = getEntity(); + + MqttClient client = getMqttClient(entity); + List messageList = new ArrayList(); + client.setCallback(new MqttCallback() { + String msgReceived; + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + msgReceived = message.toString(); + messageList.add(msgReceived); + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + + } + + @Override + public void connectionLost(Throwable cause) { + + } + }); + + String speedEventWithVehicleIdAndSourceDeviceId = "{\"EventID\": \"Speed\"," + + "\"Version\": \"1.0\",\"Data\": {\"value\":20.0},\"MessageId\":" + + "\"1237\",\"BizTransactionId\": \"Biz1237\",\"VehicleId\": " + + "\"Vehicle12345\",\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, + vehicleId.getBytes(), + speedEventWithVehicleIdAndSourceDeviceId.getBytes()); + Thread.sleep(Constants.INT_20000); + // message will be send once to device then retried 3 + // (3 is value of max retry set in property file) times. + // So, totally each message will be sent 4 times to mqtt. + // Expected noOfMessages * 4 messages. + int expected = Constants.FOUR; + Assert.assertEquals(expected, messageList.size()); + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + Assert.assertEquals(expected, failureEventList.size()); + + DeviceMessageFailureEventDataV1_0 data1 = (DeviceMessageFailureEventDataV1_0) + failureEventList.get(0).getEventData(); + Assert.assertEquals(data1.getErrorCode(), + DeviceMessageErrorCode.RETRYING_DEVICE_MESSAGE); + Assert.assertEquals(data1.getRetryAttempts(), 1); + + DeviceMessageFailureEventDataV1_0 data2 = (DeviceMessageFailureEventDataV1_0) + failureEventList.get(1).getEventData(); + Assert.assertEquals(data2.getErrorCode(), + DeviceMessageErrorCode.RETRYING_DEVICE_MESSAGE); + Assert.assertEquals(data2.getRetryAttempts(), Constants.TWO); + + DeviceMessageFailureEventDataV1_0 data3 = (DeviceMessageFailureEventDataV1_0) + failureEventList.get(Constants.TWO).getEventData(); + Assert.assertEquals(data3.getErrorCode(), + DeviceMessageErrorCode.RETRYING_DEVICE_MESSAGE); + Assert.assertEquals(data3.getRetryAttempts(), Constants.THREE); + + DeviceMessageFailureEventDataV1_0 data4 = (DeviceMessageFailureEventDataV1_0) + failureEventList.get(Constants.THREE).getEventData(); + Assert.assertEquals(data4.getErrorCode(), + DeviceMessageErrorCode.RETRY_ATTEMPTS_EXCEEDED); + Assert.assertEquals(data3.getRetryAttempts(), Constants.THREE); + + } + + /** + * Gets the entity. + * + * @return the entity + * @throws ExecutionException the execution exception + * @throws InterruptedException the interrupted exception + */ + private DeviceMessage getEntity() throws ExecutionException, InterruptedException { + String deviceConnStatusEvent = "{\"EventID\": \"DeviceConnStatus\"," + + "\"Version\": \"1.0\",\"Data\": {\"connStatus\":\"ACTIVE\"," + + "\"serviceName\":\"eCall\"},\"MessageId\": \"1234\",\"VehicleId\": " + + "\"Vehicle12345\",\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, + vehicleId.getBytes(), deviceConnStatusEvent.getBytes()); + IgniteEventImpl retryEvent = new RetryTestEvent(); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(retryEvent), + Version.V1_0, retryEvent, sourceTopicName, Constants.THREAD_SLEEP_TIME_60000); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + return entity; + } + + /** + * Retry handler test retry attempts not reset to zero when event saved in offline buffer. + * + * @throws Exception the exception + */ + @Test + public void retryHandlerTestRetryAttemptsNotResetToZeroWhenEventSavedInOfflineBuffer() + throws Exception { + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, DMARetryTestServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + launchApplication(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_10000); + getEntity(); + /* + * get a client to subscribe to the required topic topic + */ + List messageList = new ArrayList(); + MqttClient client = getMqttClient(); + client.setCallback(new MqttCallback() { + String msgReceived; + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + msgReceived = message.toString(); + messageList.add(msgReceived); + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + + } + + @Override + public void connectionLost(Throwable cause) { + + } + }); + + String speedEventWithVehicleIdAndSourceDeviceId = "{\"EventID\": " + + "\"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":20.0}," + + "\"MessageId\":\"1237\",\"BizTransactionId\": \"Biz1237\"," + + "\"VehicleId\": \"Vehicle12345\",\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, + vehicleId.getBytes(), + speedEventWithVehicleIdAndSourceDeviceId.getBytes()); + // below sleep is given to ensure that above sent event is retried at least once + Thread.sleep(Constants.THREAD_SLEEP_TIME_60000); + // making device inactive to save this event in mongo + String inactiveDeviceConnStatusEvent = "{\"EventID\": \"DeviceConnStatus\"," + + "\"Version\": \"1.0\",\"Data\": {\"connStatus\":\"INACTIVE\"," + + "\"serviceName\":\"eCall\"},\"MessageId\": \"1234\",\"VehicleId\":" + + " \"Vehicle12345\",\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, + vehicleId.getBytes(), inactiveDeviceConnStatusEvent.getBytes()); + Thread.sleep(Constants.THREAD_SLEEP_TIME_3000); + String deviceConnStatusEvent = setKafkaUtils(); + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, + vehicleId.getBytes(), deviceConnStatusEvent.getBytes()); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_13000); + // message will be send once to device then retried Constants.THREE + // (Constants.THREE is value of max retry set in property file) times. + // So, totally each message will be sent 4 times to mqtt. + // Expected noOfMessages * 4 messages. And this proves that even though + // event was saved in + // offline buffer, retry count wasn't reset to 0. + int expected = Constants.FOUR; + Assert.assertEquals(expected, messageList.size()); + + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + Assert.assertEquals(expected, failureEventList.size()); + + DeviceMessageFailureEventDataV1_0 data1 = (DeviceMessageFailureEventDataV1_0) + failureEventList.get(0).getEventData(); + Assert.assertEquals(data1.getErrorCode(), DeviceMessageErrorCode.RETRYING_DEVICE_MESSAGE); + Assert.assertEquals(data1.getRetryAttempts(), 1); + + DeviceMessageFailureEventDataV1_0 data2 = (DeviceMessageFailureEventDataV1_0) + failureEventList.get(1).getEventData(); + Assert.assertEquals(data2.getErrorCode(), DeviceMessageErrorCode.RETRYING_DEVICE_MESSAGE); + Assert.assertEquals(data2.getRetryAttempts(), Constants.TWO); + + DeviceMessageFailureEventDataV1_0 data3 = (DeviceMessageFailureEventDataV1_0) + failureEventList.get(Constants.TWO).getEventData(); + Assert.assertEquals(data3.getErrorCode(), DeviceMessageErrorCode.RETRYING_DEVICE_MESSAGE); + Assert.assertEquals(data3.getRetryAttempts(), Constants.THREE); + + DeviceMessageFailureEventDataV1_0 data4 = (DeviceMessageFailureEventDataV1_0) + failureEventList.get(Constants.THREE).getEventData(); + Assert.assertEquals(data4.getErrorCode(), DeviceMessageErrorCode.RETRY_ATTEMPTS_EXCEEDED); + Assert.assertEquals(data4.getRetryAttempts(), Constants.THREE); + } + + /** + * Sets the kafka utils. + * + * @return the string + * @throws Exception the exception + */ + private String setKafkaUtils() throws Exception { + launchApplication(); + Thread.sleep(Constants.THREAD_SLEEP_TIME_10000); + String deviceConnStatusEvent = "{\"EventID\": \"DeviceConnStatus\"," + + "\"Version\": \"1.0\",\"Data\": {\"connStatus\":\"ACTIVE\",\"serviceName\"" + + ":\"eCall\"},\"MessageId\": \"1234\",\"VehicleId\": \"Vehicle12345\"," + + "\"SourceDeviceId\": \"Device12345\"}"; + + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, + vehicleId.getBytes(), deviceConnStatusEvent.getBytes()); + + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + return deviceConnStatusEvent; + } + + /** + * Gets the mqtt client. + * + * @return the mqtt client + * @throws MqttException the mqtt exception + */ + private MqttClient getMqttClient() throws MqttException { + IgniteEventImpl retryEvent = new RetryTestEvent(); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(retryEvent), + Version.V1_0, retryEvent, sourceTopicName, Constants.THREAD_SLEEP_TIME_60000); + + MqttClient client = getMqttClient(entity); + return client; + } + + /** + * Gets the mqtt client. + * + * @param entity the entity + * @return the mqtt client + * @throws MqttException the mqtt exception + */ + private MqttClient getMqttClient(DeviceMessage entity) throws MqttException { + String mqttTopicToSubscribe = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(retryTestKey, + entity.getDeviceMessageHeader(), null).get(); + + MqttClient client = pahoMqttDispatcher.getMqttClient(PropertyNames.DEFAULT_PLATFORMID).get(); + client.subscribe(mqttTopicToSubscribe); + return client; + } + + /** + * Retry handler test when devlivery cut offexceeded. + * + * @throws MqttException the mqtt exception + * @throws Exception the exception + * @throws InterruptedException the interrupted exception + * @throws ExecutionException the execution exception + */ + @Test + public void retryHandlerTestWhenDevliveryCutOffexceeded() throws + MqttException, Exception, InterruptedException, ExecutionException { + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, DMARetryTestServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + launchApplication(); + Thread.sleep(Constants.THREAD_SLEEP_TIME_10000); + String deviceConnStatusEvent = "{\"EventID\": \"DeviceConnStatus\",\"Version\": " + + "\"1.0\",\"Data\": {\"connStatus\":\"ACTIVE\",\"serviceName\":\"eCall\"}," + + "\"MessageId\": \"1234\",\"VehicleId\": \"Vehicle12345\"," + + "\"SourceDeviceId\": \"Device12345\"}"; + + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, + vehicleId.getBytes(), deviceConnStatusEvent.getBytes()); + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + /* + * get a client to subscribe to the required topic topic + */ + IgniteEventImpl retryEvent = new RetryTestEvent(); + DeviceMessage entity = new DeviceMessage(transformer + .toBlob(retryEvent), Version.V1_0, retryEvent, + sourceTopicName, Constants.THREAD_SLEEP_TIME_60000); + String mqttTopicToSubscribe = defaultMqttTopicNameGeneratorImpl.getMqttTopicName(retryTestKey, + entity.getDeviceMessageHeader(), null).get(); + MqttClient client = pahoMqttDispatcher.getMqttClient(PropertyNames.DEFAULT_PLATFORMID).get(); + List messageList = new ArrayList(); + client.subscribe(mqttTopicToSubscribe); + client.setCallback(new MqttCallback() { + String msgReceived; + + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + msgReceived = message.toString(); + messageList.add(msgReceived); + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + + } + + @Override + public void connectionLost(Throwable cause) { + + } + }); + + long pastTs = System.currentTimeMillis() - TestConstants.THREAD_SLEEP_TIME_10000; + String speedEventWithVehicleIdAndSourceDeviceId = "{\"EventID\": " + + "\"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":20.0}," + + "\"MessageId\":\"1237\",\"BizTransactionId\": \"Biz1237\",\"VehicleId\": " + + "\"Vehicle12345\",\"SourceDeviceId\": \"Device12345\",\"DeviceDeliveryCutoff\": " + + pastTs + "}"; + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, + vehicleId.getBytes(), + speedEventWithVehicleIdAndSourceDeviceId.getBytes()); + Thread.sleep(Constants.THREAD_SLEEP_TIME_10000); + // message will be send once to device then retried Constants.THREE + // (Constants.THREE is value of max retry set in property file) times. + // So, totally each message will be sent 4 times to mqtt. + // Expected noOfMessages * 4 messages. + int expected = 0; + Assert.assertEquals(expected, messageList.size()); + shutDown(); + } + + /** + * inner class DMARetryTestServiceProcessor implements IgniteEventStreamProcessor. + */ + public static final class DMARetryTestServiceProcessor implements IgniteEventStreamProcessor { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + + return "DMAretryTestServiceProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + IgniteEvent value = kafkaRecord.value(); + AbstractIgniteEvent event = (AbstractIgniteEvent) value; + if (!value.getEventId().equals(EventID.DEVICEMESSAGEFAILURE)) { + if (!value.getEventId().equals("Ack")) { + event.setDeviceRoutable(true); + event.setResponseExpected(true); + } + spc.forward(kafkaRecord.withValue(event)); + } else { + failureEventList.add(value); + } + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + + } + + /** + * Close. + */ + @Override + public void close() { + + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { sourceTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] {}; + } + } + + /** + * inner class DMARetryTestAckNotSetServiceProcessor implements IgniteEventStreamProcessor. + */ + public static final class DMARetryTestAckNotSetServiceProcessor implements IgniteEventStreamProcessor { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + + return "DMAretryTestServiceProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + IgniteEvent value = kafkaRecord.value(); + if (!value.getEventId().equals(EventID.DEVICEMESSAGEFAILURE)) { + AbstractIgniteEvent event = (AbstractIgniteEvent) value; + event.setDeviceRoutable(true); + kafkaRecord.withValue(event); + spc.forward(kafkaRecord); + } + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + + + } + + /** + * Close. + */ + @Override + public void close() { + + + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + + + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { sourceTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] {}; + } + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryHandlerTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryHandlerTest.java new file mode 100644 index 0000000..a82f5cd --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryHandlerTest.java @@ -0,0 +1,862 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.state.KeyValueIterator; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.domain.DeviceMessageFailureEventDataV1_0; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageErrorCode; +import org.eclipse.ecsp.entities.dma.RetryRecord; +import org.eclipse.ecsp.entities.dma.RetryRecordIds; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.config.EventConfig; +import org.eclipse.ecsp.stream.dma.dao.DMARetryBucketDAOCacheBackedInMemoryImpl; +import org.eclipse.ecsp.stream.dma.dao.DMARetryRecordDAOCacheBackedInMemoryImpl; +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntryDAOMongoImpl; +import org.eclipse.ecsp.stream.dma.dao.key.RetryBucketKey; +import org.eclipse.ecsp.stream.dma.dao.key.RetryRecordKey; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.ecsp.utils.ConcurrentHashSet; +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ConcurrentSkipListMap; + +import static java.util.concurrent.CompletableFuture.delayedExecutor; +import static java.util.concurrent.CompletableFuture.runAsync; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + + +/** + * UT class {@link RetryHandlerTest} for {@link RetryHandler}. + */ +public class RetryHandlerTest { + + /** The mockito rule. */ + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + /** The retry test key. */ + private RetryTestKey retryTestKey = new RetryTestKey(); + + /** The max retry. */ + private int maxRetry = 3; + + /** The retry interval. */ + private long retryInterval = 5000; + + /** The task id. */ + private String taskId = "taskId"; + + /** The retry min threshold. */ + private int retryMinThreshold = 100; + + /** The retry handler. */ + @InjectMocks + private RetryHandler retryHandler; + + /** The source topic. */ + private String sourceTopic = "testTopic"; + + /** The retry bucket DAO. */ + @Mock + private DMARetryBucketDAOCacheBackedInMemoryImpl retryBucketDAO; + + /** The msg id generator. */ + @Mock + private GlobalMessageIdGenerator msgIdGenerator; + + /** The device message utils. */ + @Mock + private DeviceMessageUtils deviceMessageUtils; + + /** The retry event DAO. */ + @Mock + private DMARetryRecordDAOCacheBackedInMemoryImpl retryEventDAO; + + /** The connection status handler. */ + @Mock + private DeviceConnectionStatusHandler connectionStatusHandler; + + /** The transformer. */ + private DeviceMessageIgniteEventTransformer transformer = new DeviceMessageIgniteEventTransformer(); + + /** The spc. */ + @Mock + private StreamProcessingContext, IgniteEvent> spc; + + /** The event config map. */ + @Mock + private ConcurrentMap eventConfigMap = new ConcurrentHashMap<>(); + + /** The offline buffer DAO. */ + @Mock + private DMOfflineBufferEntryDAOMongoImpl offlineBufferDAO; + + /** The service name. */ + private String serviceName = "service"; + + /** + * setup(). + */ + @Before + public void setUp() { + retryTestKey.setKey("Vehicle12345"); + MockitoAnnotations.initMocks(this); + retryHandler.close(); + // Reset values from property file + retryHandler.setRetryIntervalDivisor(Constants.FOUR); + retryHandler.setMaxRetry(maxRetry); + retryHandler.setRetryMinThreshold(retryMinThreshold); + retryHandler.setRetryInterval(retryInterval); + retryHandler.setup(taskId); + } + + /** + * Close. + */ + @After + public void close() { + retryHandler.close(); + } + + /** + * Test retry handle when max retry less than zero. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testRetryHandleWhenMaxRetryLessThanZero() throws InterruptedException { + TestHandler handler = new TestHandler(); + retryHandler.close(); + retryHandler.setMaxRetry(Constants.INT_MINUS_ONE); + retryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + retryHandler.setRetryInterval(Constants.THREAD_SLEEP_TIME_1000); + retryHandler.setNextHandler(handler); + retryHandler.setup(taskId); + + RetryTestEvent event = new RetryTestEvent(); + event.setVehicleId("vehicleId"); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), Version.V1_0, + event, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + Mockito.when(connectionStatusHandler.getDeviceIdIfActive(retryTestKey, + entity.getDeviceMessageHeader(), "vehicleId")) + .thenReturn(Optional.of("vehicleId")); + retryHandler.handle(retryTestKey, entity); + + Mockito.verify(retryBucketDAO, Mockito.times(0)).update(Mockito.anyString(), Mockito.any(RetryBucketKey.class), + Mockito.anyString()); + // DMA will not try to attempt retry, event will be send to device just + // once. + Assert.assertEquals(1, handler.getEventList().size()); + + } + + /** + * Test retry handle when global topic is provided. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testRetryHandleWhenGlobalTopicIsProvided() throws InterruptedException { + TestHandler handler = new TestHandler(); + retryHandler.close(); + retryHandler.setMaxRetry(Constants.INT_MINUS_ONE); + retryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + retryHandler.setRetryInterval(Constants.THREAD_SLEEP_TIME_1000); + retryHandler.setNextHandler(handler); + retryHandler.setup(taskId); + + RetryTestEvent event = new RetryTestEvent(); + event.setVehicleId("vehicleId"); + event.setDevMsgGlobalTopic("test"); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), Version.V1_0, + event, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + + Mockito.when(connectionStatusHandler.getDeviceIdIfActive(retryTestKey, + entity.getDeviceMessageHeader(), "vehicleId")) + .thenReturn(Optional.of("vehicleId")); + retryHandler.handle(retryTestKey, entity); + + Mockito.verify(retryBucketDAO, Mockito.times(0)).update(Mockito.anyString(), Mockito.any(RetryBucketKey.class), + Mockito.anyString()); + // DMA will not try to attempt retry, event will be send to device just + // once. + Assert.assertEquals(1, handler.getEventList().size()); + + } + + /** + * Test retry handle when fallback to TTL on max retry exhausted. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testRetryHandleWhenFallbackToTTLOnMaxRetryExhausted() throws InterruptedException { + TestHandler handler = new TestHandler(); + retryHandler.close(); + retryHandler.setMaxRetry(Constants.INT_MINUS_ONE); + retryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + retryHandler.setRetryInterval(Constants.THREAD_SLEEP_TIME_1000); + retryHandler.setNextHandler(handler); + retryHandler.setup(taskId); + + RetryTestEvent event = new RetryTestEvent(); + event.setVehicleId("vehicleId"); + event.setEventId("123"); + event.setResponseExpected(true); + EventConfig config = new EventConfigTestImpl(); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), + Version.V1_0, event, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + Mockito.when(eventConfigMap.get(Mockito.any())).thenReturn(config); + Mockito.when(connectionStatusHandler.getDeviceIdIfActive(retryTestKey, + entity.getDeviceMessageHeader(), "vehicleId")) + .thenReturn(Optional.of("vehicleId")); + retryHandler.handle(retryTestKey, entity); + + Assert.assertEquals(Constants.TWO, handler.getEventList().size()); + + } + + /** + * Test retry handle save to offline buffer and delete from cache. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testRetryHandleSaveToOfflineBufferAndDeleteFromCache() throws InterruptedException { + TestHandler handler = new TestHandler(); + retryHandler.close(); + retryHandler.setMaxRetry(Constants.INT_MINUS_ONE); + retryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + retryHandler.setRetryInterval(Constants.THREAD_SLEEP_TIME_1000); + retryHandler.setNextHandler(handler); + retryHandler.setup(taskId); + + RetryTestEvent event = new RetryTestEvent(); + event.setVehicleId("vehicleId"); + event.setEventId("123"); + event.setResponseExpected(true); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), Version.V1_0, + event, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + long currentTime = System.currentTimeMillis() - TestConstants.THREAD_SLEEP_TIME_10000; + RetryRecord record = new RetryRecord(retryTestKey, entity, currentTime); + record.addAttempt(currentTime + Constants.TEN); + DeviceMessage message = record.getDeviceMessage(); + message.setDeviceMessageHeader(message.getDeviceMessageHeader().withPendingRetries(1)); + record.setDeviceMessage(message); + EventConfig config = new EventConfigTestImpl(); + Mockito.when(eventConfigMap.get(Mockito.any())).thenReturn(config); + Mockito.when(retryEventDAO.get(Mockito.any())).thenReturn(record); + Mockito.doNothing().when(offlineBufferDAO).addOfflineBufferEntry(Mockito.anyString(), + Mockito.any(), Mockito.any(), Mockito.any()); + Mockito.when(connectionStatusHandler.getDeviceIdIfActive(retryTestKey, entity.getDeviceMessageHeader(), + "vehicleId")).thenReturn(Optional.of("vehicleId")); + retryHandler.handle(retryTestKey, entity); + + Assert.assertEquals(1, handler.getEventList().size()); + + } + + /** + * Test retry handle when attempts are less than max retry. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testRetryHandleWhenAttemptsAreLessThanMaxRetry() throws InterruptedException { + TestHandler handler = new TestHandler(); + retryHandler.close(); + retryHandler.setMaxRetry(Constants.FIVE); + retryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + retryHandler.setRetryInterval(Constants.THREAD_SLEEP_TIME_1000); + retryHandler.setNextHandler(handler); + retryHandler.setup(taskId); + + RetryTestEvent event = new RetryTestEvent(); + event.setVehicleId("vehicleId"); + event.setEventId("123"); + event.setResponseExpected(true); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), Version.V1_0, + event, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + long currentTime = System.currentTimeMillis() - TestConstants.THREAD_SLEEP_TIME_10000; + RetryRecord record = new RetryRecord(retryTestKey, entity, currentTime); + record.addAttempt(currentTime + Constants.TEN); + DeviceMessage message = record.getDeviceMessage(); + message.setDeviceMessageHeader(message.getDeviceMessageHeader().withPendingRetries(1)); + record.setDeviceMessage(message); + String retryRecordKey = "vehicleId;msg123"; + EventConfig config = new EventConfigTestImpl(); + Mockito.when(eventConfigMap.get(Mockito.any())).thenReturn(config); + Mockito.when(retryEventDAO.get(Mockito.any())).thenReturn(record); + Mockito.doNothing().when(offlineBufferDAO).addOfflineBufferEntry(Mockito.anyString(), + Mockito.any(), Mockito.any(), Mockito.any()); + Mockito.when(connectionStatusHandler.getDeviceIdIfActive(retryTestKey, entity.getDeviceMessageHeader(), + "vehicleId")).thenReturn(Optional.of("vehicleId")); + retryHandler.handle(retryTestKey, entity); + Assert.assertEquals(1, handler.getEventList().size()); + + } + + /** + * Test retry handle when response expected is not set. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testRetryHandleWhenResponseExpectedIsNotSet() throws InterruptedException { + TestHandler handler = new TestHandler(); + retryHandler.close(); + retryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + retryHandler.setRetryInterval(Constants.THREAD_SLEEP_TIME_1000); + retryHandler.setNextHandler(handler); + retryHandler.setup(taskId); + + RetryTestEvent event = new RetryTestEvent(); + event.setVehicleId("vehicleId"); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), Version.V1_0, + event, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + + Mockito.when(connectionStatusHandler.getDeviceIdIfActive(retryTestKey, + entity.getDeviceMessageHeader(), "vehicleId")) + .thenReturn(Optional.of("vehicleId")); + retryHandler.handle(retryTestKey, entity); + + Mockito.verify(retryBucketDAO, Mockito.times(0)).update(Mockito.anyString(), Mockito.any(RetryBucketKey.class), + Mockito.anyString()); + // DMA will not try to attempt retry, event will be send to device just + // once. + Assert.assertEquals(1, handler.getEventList().size()); + + } + + /** + * Test retry handle when max retry threshold has not been reached. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testRetryHandleWhenMaxRetryThresholdHasNotBeenReached() throws InterruptedException { + retryHandler.close(); + String retryRecordKey = "vehicleId;msg123"; + ConcurrentHashSet retryRecordKeys = new ConcurrentHashSet(); + retryRecordKeys.add(retryRecordKey); + // It should be able to retry past keys, hence we are subtracting 10 + // seconds + long currentTime = System.currentTimeMillis() - TestConstants.THREAD_SLEEP_TIME_10000; + ConcurrentSkipListMap map = + new ConcurrentSkipListMap(); + map.put((new RetryBucketKey(currentTime)), new RetryRecordIds(Version.V1_0, retryRecordKeys)); + TestKVIterator itr = new TestKVIterator(map); + Mockito.when(retryBucketDAO.getHead(Mockito.any(RetryBucketKey.class))).thenReturn(itr); + RetryTestEvent event = getRetryTestEvent(); + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), Version.V1_0, event, + sourceTopic, TestConstants.THREAD_SLEEP_TIME_60000); + Mockito.when(connectionStatusHandler.getDeviceIdIfActive(retryTestKey, + entity.getDeviceMessageHeader(), "vehicleId")) + .thenReturn(Optional.of("vehicleId")); + // We are creating a RetryRecord in which attempts is 1 less than + // maxretry. + RetryRecord record = getRetryRecord(currentTime, entity); + Mockito.when(retryEventDAO.get(new RetryRecordKey(retryRecordKey, taskId))).thenReturn(record); + // Max Retry is set to 2 here. + getRetryHandler(); + TestHandler handler = new TestHandler(); + retryHandler.setNextHandler(handler); + + runAsync(() -> {}, delayedExecutor(Constants.THREAD_SLEEP_TIME_200, MILLISECONDS)).join(); + retryHandler.close(); + Mockito.verify(retryEventDAO, Mockito.times(1)).putToMap(Mockito.anyString(), + Mockito.any(RetryRecordKey.class), Mockito.any(RetryRecord.class), Mockito.any(Optional.class), + Mockito.anyString()); + ArgumentCaptor recordKeyArgument = ArgumentCaptor.forClass(RetryRecordKey.class); + ArgumentCaptor recordArgument = ArgumentCaptor.forClass(RetryRecord.class); + ArgumentCaptor parentKeyArgument = ArgumentCaptor.forClass(String.class); + Mockito.verify(retryEventDAO).putToMap(parentKeyArgument.capture(), recordKeyArgument.capture(), + recordArgument.capture(), Mockito.any(Optional.class), Mockito.anyString()); + RetryRecordKey actualKey = recordKeyArgument.getValue(); + RetryRecord actualValue = recordArgument.getValue(); + Assert.assertEquals(RetryRecordKey.getMapKey(serviceName, taskId), parentKeyArgument.getValue()); + Assert.assertEquals(retryRecordKey, actualKey.getKey()); + Assert.assertEquals(event, actualValue.getDeviceMessage().getEvent()); + Assert.assertEquals(Constants.TWO, actualValue.getAttempts()); + + ArgumentCaptor retryBucketMapKeyArgument = ArgumentCaptor.forClass(String.class); + ArgumentCaptor retryRecordKeyArgument = ArgumentCaptor.forClass(String.class); + Mockito.verify(retryBucketDAO, Mockito.times(1)) + .update(retryBucketMapKeyArgument.capture(), Mockito.any(RetryBucketKey.class), + retryRecordKeyArgument.capture()); + String actualRetryBucketMapKey = retryBucketMapKeyArgument.getValue(); + String actualRetryRecordKey = retryRecordKeyArgument.getValue(); + Assert.assertEquals(RetryBucketKey.getMapKey(serviceName, taskId), actualRetryBucketMapKey); + Assert.assertEquals(retryRecordKey, actualRetryRecordKey); + + // Event will be retried only once + Assert.assertEquals(1, handler.getEventList().size()); + + ArgumentCaptor failDataArg = ArgumentCaptor + .forClass(DeviceMessageFailureEventDataV1_0.class); + Mockito.verify(deviceMessageUtils).postFailureEvent(failDataArg.capture(), Mockito.any(IgniteKey.class), + Mockito.any(StreamProcessingContext.class), Mockito.anyString()); + + DeviceMessageFailureEventDataV1_0 actual = failDataArg.getValue(); + Assert.assertEquals(DeviceMessageErrorCode.RETRYING_DEVICE_MESSAGE, actual.getErrorCode()); + Assert.assertEquals(0, actual.getShoudlerTapRetryAttempts()); + Assert.assertEquals(Constants.TWO, actual.getRetryAttempts()); + Assert.assertEquals(false, actual.isDeviceStatusInactive()); + Assert.assertEquals(false, actual.isDeviceDeliveryCutoffExceeded()); + } + + /** + * Gets the retry record. + * + * @param currentTime the current time + * @param entity the entity + * @return the retry record + */ + @NotNull + private RetryRecord getRetryRecord(long currentTime, DeviceMessage entity) { + RetryRecord record = new RetryRecord(retryTestKey, entity, currentTime); + record.addAttempt(currentTime + Constants.TEN); + DeviceMessage message = record.getDeviceMessage(); + message.setDeviceMessageHeader(message.getDeviceMessageHeader().withPendingRetries(1)); + record.setDeviceMessage(message); + return record; + } + + /** + * Gets the retry handler. + * + * @return the retry handler + */ + private void getRetryHandler() { + retryHandler.setMaxRetry(Constants.TWO); + retryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + retryHandler.setRetryInterval(Constants.THREAD_SLEEP_TIME_1000); + retryHandler.setServiceName(serviceName); + retryHandler.setup(taskId); + } + + /** + * Gets the retry test event. + * + * @return the retry test event + */ + @NotNull + private static RetryTestEvent getRetryTestEvent() { + RetryTestEvent event = new RetryTestEvent(); + event.setResponseExpected(true); + String msgId = "msg123"; + event.setMessageId(msgId); + event.setVehicleId("vehicleId"); + return event; + } + + /** + * Test retry handle when max retry threshold has reached. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testRetryHandleWhenMaxRetryThresholdHasReached() throws InterruptedException { + retryHandler.close(); + String retryRecordKey = "vehicleId;msg123"; + ConcurrentHashSet retryRecordKeys = new ConcurrentHashSet(); + retryRecordKeys.add(retryRecordKey); + + // It should be able to retry past keys, hence we are subtracting 10 + // seconds + long currentTime = System.currentTimeMillis() - TestConstants.THREAD_SLEEP_TIME_10000; + ConcurrentSkipListMap map = + new ConcurrentSkipListMap(); + map.put((new RetryBucketKey(currentTime)), new RetryRecordIds(Version.V1_0, retryRecordKeys)); + TestKVIterator itr = new TestKVIterator(map); + Mockito.when(retryBucketDAO.getHead(Mockito.any(RetryBucketKey.class))).thenReturn(itr); + + RetryTestEvent event = new RetryTestEvent(); + event.setResponseExpected(true); + String msgId = "msg123"; + event.setMessageId(msgId); + event.setVehicleId("vehicleId"); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), + Version.V1_0, event, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + Mockito.when(spc.streamName()).thenReturn("topic"); + String failMsgId = "fail123"; + Mockito.when(msgIdGenerator.generateUniqueMsgId(Mockito.anyString())).thenReturn(failMsgId); + Mockito.when(connectionStatusHandler.getDeviceIdIfActive(retryTestKey, + entity.getDeviceMessageHeader(), "vehicleId")).thenReturn(Optional.of("vehicleId")); + // We are creating a RetryRecord in which attempts is 2 to match the + // maxretry. + RetryRecord record = new RetryRecord(retryTestKey, entity, currentTime); + record.addAttempt(currentTime + Constants.TEN); + record.addAttempt(currentTime + Constants.TWENTY); + + Mockito.when(retryEventDAO.get(new RetryRecordKey(retryRecordKey, taskId))).thenReturn(record); + + // Max Retry is set to 2 here. + retryHandler.setMaxRetry(Constants.TWO); + retryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + retryHandler.setRetryInterval(Constants.THREAD_SLEEP_TIME_1000); + TestHandler handler = new TestHandler(); + retryHandler.setNextHandler(handler); + retryHandler.setServiceName(serviceName); + retryHandler.setup(taskId); + runAsync(() -> {}, delayedExecutor(Constants.THREAD_SLEEP_TIME_200, MILLISECONDS)).join(); + retryHandler.close(); + + ArgumentCaptor retryEventMapKeyArgument = ArgumentCaptor.forClass(String.class); + ArgumentCaptor retryRecordKeyArgument = ArgumentCaptor.forClass(RetryRecordKey.class); + Mockito.verify(retryEventDAO, Mockito.times(1)) + .deleteFromMap(retryEventMapKeyArgument.capture(), retryRecordKeyArgument.capture(), + Mockito.any(Optional.class), Mockito.anyString()); + String actualRetryEventMapKey = retryEventMapKeyArgument.getValue(); + RetryRecordKey actualRetryRecordKey = retryRecordKeyArgument.getValue(); + Assert.assertEquals(RetryRecordKey.getMapKey(serviceName, taskId), actualRetryEventMapKey); + Assert.assertEquals(retryRecordKey, actualRetryRecordKey.getKey()); + + ArgumentCaptor failDataArg = ArgumentCaptor + .forClass(DeviceMessageFailureEventDataV1_0.class); + Mockito.verify(deviceMessageUtils).postFailureEvent(failDataArg.capture(), Mockito.any(IgniteKey.class), + Mockito.any(StreamProcessingContext.class), Mockito.anyString()); + + DeviceMessageFailureEventDataV1_0 actual = failDataArg.getValue(); + Assert.assertEquals(DeviceMessageErrorCode.RETRY_ATTEMPTS_EXCEEDED, actual.getErrorCode()); + Assert.assertEquals(0, actual.getShoudlerTapRetryAttempts()); + Assert.assertEquals(Constants.TWO, actual.getRetryAttempts()); + Assert.assertEquals(false, actual.isDeviceStatusInactive()); + Assert.assertEquals(false, actual.isDeviceDeliveryCutoffExceeded()); + + // Here in eventDao we have set that the event has already been retried + // twice and max retry is also set 2 hence no more retrires will be + // attempted. + Assert.assertEquals(0, handler.getEventList().size()); + + } + + /** + * Test retry handle when no data present in retry event DAO. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testRetryHandleWhenNoDataPresentInRetryEventDAO() throws InterruptedException { + retryHandler.close(); + String retryRecordKey = "vehicleId;msg123"; + ConcurrentHashSet retryRecordKeys = new ConcurrentHashSet(); + retryRecordKeys.add(retryRecordKey); + + long currentTime = System.currentTimeMillis(); + ConcurrentSkipListMap map = + new ConcurrentSkipListMap(); + map.put((new RetryBucketKey(currentTime)), new RetryRecordIds(Version.V1_0, retryRecordKeys)); + TestKVIterator itr = new TestKVIterator(map); + Mockito.when(retryBucketDAO.getHead(Mockito.any(RetryBucketKey.class))).thenReturn(itr); + + retryHandler.setMaxRetry(Constants.THREE); + retryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + retryHandler.setRetryInterval(Constants.THREAD_SLEEP_TIME_1000); + + TestHandler handler = new TestHandler(); + retryHandler.setNextHandler(handler); + retryHandler.setup(taskId); + runAsync(() -> {}, delayedExecutor(TestConstants.THREAD_SLEEP_TIME_5000, MILLISECONDS)).join(); + // Comment the two lines below while debugging in eclipse. + Mockito.verify(retryBucketDAO, Mockito.atLeast(Constants.TEN)).getHead(Mockito.any(RetryBucketKey.class)); + Mockito.verify(retryBucketDAO, Mockito.atMost(TestConstants.TWELVE)).getHead(Mockito.any(RetryBucketKey.class)); + Mockito.verify(retryEventDAO, Mockito.times(1)).get(Mockito.any(RetryRecordKey.class)); + // When no Data is present in RetryEventDAO for the corresponding msgId + // retry cannot be attempted. Here EventDAO has key with msgId "msg323" + // and retry is attempted for "msg123". + Assert.assertEquals(0, handler.getEventList().size()); + + } + + /** + * Test setup when retry interval less than retry threshold. + */ + @Test(expected = IllegalArgumentException.class) + public void testSetupWhenRetryIntervalLessThanRetryThreshold() { + retryHandler.setRetryInterval(TestConstants.THREAD_SLEEP_TIME_100); + retryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_200); + retryHandler.setup(taskId); + } + + /** + * Test setup when retry interval less than zero. + */ + @Test(expected = IllegalArgumentException.class) + public void testSetupWhenRetryIntervalLessThanZero() { + retryHandler.setRetryInterval(-TestConstants.THREAD_SLEEP_TIME_100); + retryHandler.setup(taskId); + } + + /** + * Test setup when retry threshold less than zero. + */ + @Test(expected = IllegalArgumentException.class) + public void testSetupWhenRetryThresholdLessThanZero() { + retryHandler.setRetryMinThreshold(-Constants.THREAD_SLEEP_TIME_200); + retryHandler.setup(taskId); + } + + /** + * Test scheduled thread delay. + */ + @Test + public void testScheduledThreadDelay() { + retryHandler.setRetryIntervalDivisor(Constants.TEN); + retryHandler.setRetryInterval(TestConstants.THREAD_SLEEP_TIME_40000); + retryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + Assert.assertEquals(TestConstants.THREAD_SLEEP_TIME_4000, retryHandler.getScheduledThreadDelay()); + retryHandler.close(); + retryHandler.setRetryInterval(TestConstants.THREAD_SLEEP_TIME_900); + retryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + Assert.assertEquals(TestConstants.THREAD_SLEEP_TIME_500, retryHandler.getScheduledThreadDelay()); + } + + /** + * Test delivery cut off exceeded. + */ + @Test + public void testDeliveryCutOffExceeded() { + String msgId = "msg123"; + RetryTestEvent event = new RetryTestEvent(); + event.setResponseExpected(true); + event.setMessageId(msgId); + event.setDeviceDeliveryCutoff(System.currentTimeMillis() - TestConstants.THREAD_SLEEP_TIME_10000); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), + Version.V1_0, event, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + RetryRecord rr = new RetryRecord(); + rr.setAttempts(Constants.TWO); + Mockito.when(retryEventDAO.get(Mockito.any(RetryRecordKey.class))).thenReturn(rr); + retryHandler.handle(retryTestKey, entity); + Mockito.verify(retryEventDAO, Mockito.times(1)) + .deleteFromMap(Mockito.anyString(), Mockito.any(RetryRecordKey.class), + Mockito.any(Optional.class), Mockito.anyString()); + + } + + /** + * Test device inactive. + */ + @Test + public void testDeviceInactive() { + String msgId = "msg123"; + RetryTestEvent event = new RetryTestEvent(); + event.setResponseExpected(true); + event.setMessageId(msgId); + + String vehicleId = event.getVehicleId(); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), + Version.V1_0, event, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + Mockito.when(connectionStatusHandler.getDeviceIdIfActive(retryTestKey, entity.getDeviceMessageHeader(), + vehicleId)).thenReturn(Optional.empty()); + retryHandler.handle(retryTestKey, entity); + Mockito.verify(connectionStatusHandler, Mockito.times(1)).handleDeviceInactiveState(retryTestKey, entity); + Mockito.verify(retryEventDAO, Mockito.times(1)) + .deleteFromMap(Mockito.anyString(), Mockito.any(RetryRecordKey.class), + Mockito.any(Optional.class), Mockito.anyString()); + + } + + /** + * inner class TestHandler implements DeviceMessageHandler. + */ + public static final class TestHandler implements DeviceMessageHandler { + + /** The event list. */ + List eventList; + + /** + * Instantiates a new test handler. + */ + public TestHandler() { + eventList = new ArrayList(); + } + + /** + * Handle. + * + * @param key the key + * @param value the value + */ + @Override + public void handle(IgniteKey key, DeviceMessage value) { + eventList.add(value); + + } + + /** + * Sets the next handler. + * + * @param handler the new next handler + */ + @Override + public void setNextHandler(DeviceMessageHandler handler) { + + } + + /** + * Gets the event list. + * + * @return the event list + */ + public List getEventList() { + return eventList; + } + + /** + * Close. + */ + @Override + public void close() { + + } + + } + + /** + * The Class TestKVIterator. + * + * @param the key type + * @param the value type + */ + private class TestKVIterator implements KeyValueIterator { + + /** The sorted map. */ + private ConcurrentSkipListMap sortedMap; + + /** The key iter. */ + private Iterator keyIter; + + /** + * Since we have to iterate over the original map, make a deep copy + * of this map. + * + * @param map the map + */ + public TestKVIterator(Map map) { + + this.sortedMap = new ConcurrentSkipListMap(); + this.sortedMap.putAll(map); + keyIter = map.keySet().iterator(); + + } + + /** + * Checks for next. + * + * @return true, if successful + */ + @Override + public boolean hasNext() { + return keyIter.hasNext(); + } + + /** + * Next. + * + * @return the key value + */ + @Override + public KeyValue next() { + if (hasNext()) { + K key = this.keyIter.next(); + V val = this.sortedMap.get(key); + return new KeyValue(key, val); + } + return null; + } + + /** + * Close. + */ + @Override + public void close() { + if (sortedMap != null) { + sortedMap.clear(); + } + + } + + /** + * Peek next key. + * + * @return the k + */ + @Override + public K peekNextKey() { + throw new UnsupportedOperationException("Method peekNextKey not supported in KeyValueMapIterator"); + } + + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryTestEvent.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryTestEvent.java new file mode 100644 index 0000000..497eece --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryTestEvent.java @@ -0,0 +1,82 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.entities.IgniteEventImpl; + +import java.util.Optional; + + +/** + * class RetryTestEvent extends IgniteEventImpl. + */ +public class RetryTestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new retry test event. + */ + public RetryTestEvent() { + + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Speed"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("Device12345"); + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryTestKey.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryTestKey.java new file mode 100644 index 0000000..42690bb --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/RetryTestKey.java @@ -0,0 +1,71 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.key.IgniteKey; + + +/** + * class RetryTestKey implements IgniteKey. + */ +public class RetryTestKey implements IgniteKey { + + /** The vehicle id. */ + private String vehicleId; + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + return vehicleId; + } + + /** + * Sets the key. + * + * @param vehicleId the new key + */ + public void setKey(String vehicleId) { + this.vehicleId = vehicleId; + } +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/TestFilterDMOfflineBufferEntryImpl.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/TestFilterDMOfflineBufferEntryImpl.java new file mode 100644 index 0000000..7670117 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/TestFilterDMOfflineBufferEntryImpl.java @@ -0,0 +1,73 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.eclipse.ecsp.stream.dma.dao.DMOfflineBufferEntry; +import org.springframework.stereotype.Component; + +import java.util.List; + + +/** + * This is just Dummy implementation of FilterDMOfflineBufferEntry. + * + * @author JDEHURY + */ + +@Component +public class TestFilterDMOfflineBufferEntryImpl implements FilterDMOfflineBufferEntry { + + /** + * Filter and update dm offline buffer entries. + * + * @param bufferedEntries the buffered entries + * @return the list + */ + @Override + public List filterAndUpdateDmOfflineBufferEntries(List + bufferedEntries) { + // This is dummy implementation to filter the list. So removing an entry + // from the list for test. + if (bufferedEntries.size() > 1) { + bufferedEntries.remove(0); + } + return bufferedEntries; + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/handler/TestKVIterator.java b/src/test/java/org/eclipse/ecsp/stream/dma/handler/TestKVIterator.java new file mode 100644 index 0000000..b11ffc9 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/handler/TestKVIterator.java @@ -0,0 +1,124 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.handler; + +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.state.KeyValueIterator; + +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentSkipListMap; + + +/** + * class {@link TestKVIterator}implements {@link KeyValueIterator}. + * + * @param k + * @param v + */ +public class TestKVIterator implements KeyValueIterator { + + /** The sorted map. */ + private ConcurrentSkipListMap sortedMap; + + /** The key iter. */ + private Iterator keyIter; + + /** + * Since we have to iterate over the original map, make a deep copy of + * this map. + * + * @param map the map + */ + public TestKVIterator(Map map) { + + this.sortedMap = new ConcurrentSkipListMap(); + this.sortedMap.putAll(map); + keyIter = map.keySet().iterator(); + + } + + /** + * Checks for next. + * + * @return true, if successful + */ + @Override + public boolean hasNext() { + return keyIter.hasNext(); + } + + /** + * Next. + * + * @return the key value + */ + @Override + public KeyValue next() { + if (hasNext()) { + K key = this.keyIter.next(); + V val = this.sortedMap.get(key); + return new KeyValue(key, val); + } + return null; + } + + /** + * Close. + */ + @Override + public void close() { + if (sortedMap != null) { + sortedMap.clear(); + } + + } + + /** + * Peek next key. + * + * @return the k + */ + @Override + public K peekNextKey() { + throw new UnsupportedOperationException("Method peekNextKey not supported in KeyValueMapIterator"); + } + +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapRetryHandlerIntegrationTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapRetryHandlerIntegrationTest.java new file mode 100644 index 0000000..bdcab6c --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapRetryHandlerIntegrationTest.java @@ -0,0 +1,533 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.shouldertap; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.JsonMappingException; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.ByteArrayDeserializer; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.PropertyNames; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaTestUtils; +import org.eclipse.ecsp.domain.DeviceMessageFailureEventDataV1_0; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.entities.AbstractIgniteEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.dma.DeviceMessageErrorCode; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.DeviceStatusService; +import org.eclipse.ecsp.stream.dma.handler.DeviceConnectionStatusHandler; +import org.eclipse.ecsp.stream.dma.handler.DeviceStatusBackDoorKafkaConsumer; +import org.eclipse.ecsp.transform.GenericIgniteEventTransformer; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.jupiter.migrationsupport.rules.EnableRuleMigrationSupport; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.Properties; + +import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + + +/** + * class DeviceShoulderTapRetryHandlerIntegrationTest extends KafkaStreamsApplicationTestBase. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = { Launcher.class }) +@EnableRuleMigrationSupport +@TestPropertySource("/dma-shouldertap-test.properties") +public class DeviceShoulderTapRetryHandlerIntegrationTest extends KafkaStreamsApplicationTestBase { + + /** The Constant LOGGER. */ + private static final Logger LOGGER = LoggerFactory.getLogger(DeviceShoulderTapRetryHandlerIntegrationTest.class); + + /** The conn status topic. */ + private static String connStatusTopic; + + /** The source topic name. */ + private static String sourceTopicName; + + /** The i. */ + private static int i = 0; + + /** The service name. */ + @Value("${" + PropertyNames.SERVICE_NAME + ":}") + private String serviceName; + + /** The max retry. */ + @Value("${" + PropertyNames.SHOULDER_TAP_MAX_RETRY + ":3}") + private int maxRetry; + + /** The retry interval. */ + @Value("${" + PropertyNames.SHOULDER_TAP_RETRY_INTERVAL_MILLIS + ":60000}") + private long retryInterval; + + /** The retry min threshold. */ + @Value("${" + PropertyNames.SHOULDER_TAP_RETRY_MIN_THRESHOLD_MILLIS + ":60000}") + private long retryMinThreshold; + + /** The vehicle id. */ + private String vehicleId = "Vehicle12345"; + + /** The device service. */ + @Autowired + private DeviceStatusService deviceService; + + /** The shoulder tap invoker WAM impl. */ + @Autowired + private ShoulderTapInvokerWAMImpl shoulderTapInvokerWAMImpl; + + /** The device status back door kafka consumer. */ + @Autowired + DeviceStatusBackDoorKafkaConsumer deviceStatusBackDoorKafkaConsumer; + + /** The device connection status handler. */ + @Autowired + DeviceConnectionStatusHandler deviceConnectionStatusHandler; + + /** The web server. */ + @Rule + public MockWebServer webServer = new MockWebServer(); + + /** The thread delay. */ + long threadDelay; + + /** + * setup(). + * + * @throws Exception Exception + */ + @Before + public void setup() throws Exception { + super.setup(); + i++; + sourceTopicName = "sourceTopic" + i; + connStatusTopic = DMAConstants.DEVICE_STATUS_TOPIC_PREFIX + serviceName.toLowerCase(); + createTopics(connStatusTopic, sourceTopicName); + ksProps.put(PropertyNames.SOURCE_TOPIC_NAME, sourceTopicName); + + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + Serdes.ByteArray().serializer().getClass().getName()); + + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class); + consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "test-sp-consumer-group"); + Properties kafkaConsumerProps = deviceStatusBackDoorKafkaConsumer.getKafkaConsumerProps(); + kafkaConsumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_CLUSTER.bootstrapServers()); + deviceStatusBackDoorKafkaConsumer.addCallback(deviceConnectionStatusHandler.new DeviceStatusCallBack(), 0); + deviceStatusBackDoorKafkaConsumer.startBackDoorKafkaConsumer(); + Thread.sleep(TestConstants.THREAD_SLEEP_TIME_5000); + + ksProps.put(PropertyNames.SERVICE_STREAM_PROCESSORS, DMAShoulderTapServiceProcessor.class.getName()); + ksProps.put(PropertyNames.APPLICATION_ID, "test-sp" + System.currentTimeMillis()); + launchApplication(); + Thread.sleep(Constants.THREAD_SLEEP_TIME_10000); + threadDelay = getScheduledThreadDelay(); + } + + /** + * Tear down. + */ + @After + public void tearDown() { + deviceStatusBackDoorKafkaConsumer.shutdown(); + } + + /** + * Test if shoulder tap msg delivered and device comes active then retry stops. + * + * @throws Exception the exception + */ + @Test + public void testIfShoulderTapMsgDeliveredAndDeviceComesActiveThenRetryStops() throws Exception { + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamSendSMSUrl", "http://localhost:" + webServer.getPort() + "/"); + String transactionId = "f71e2395-eda2-4de9-ad0a-72e930111736"; + String shoulderTapSendSMSJsonResponse = "{\"message\": \"SUCCESS\",\"failureReasonCode\": null," + + "\"failureReason\": null,\"data\": {\"transactionId\": \"" + + transactionId + "\"}}"; + MockResponse mockShoulderTapResponse = getMockResponse(shoulderTapSendSMSJsonResponse); + + for (int index = 0; index < maxRetry + 1; index++) { + webServer.enqueue(mockShoulderTapResponse); + } + + String deviceConnInActiveStatusEvent = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\",\"Data\": " + + "{\"connStatus\":\"INACTIVE\",\"serviceName\":\"ECall\"},\"MessageId\": \"1234\",\"VehicleId\": \"" + + vehicleId + "\",\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, + vehicleId.getBytes(), deviceConnInActiveStatusEvent.getBytes()); + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + assertNull(deviceService.get(vehicleId, Optional.empty())); + + String messageId = "Message12345"; + String value = "20.0"; + String speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":" + value + + "},\"RequestId\":\"Request123\", \"MessageId\":\"" + messageId + + "\",\"BizTransactionId\": \"Biz1237\",\"VehicleId\": \"" + + vehicleId + "\",\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, vehicleId.getBytes(), speedEvent.getBytes()); + + Thread.sleep(Constants.INT_45000); + + String deviceConnActiveStatusEvent = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\",\"Data\": " + + "{\"connStatus\":\"ACTIVE\",\"serviceName\":\"ECall\"},\"MessageId\": \"1234\",\"VehicleId\": \"" + + vehicleId + "\",\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, + vehicleId.getBytes(), deviceConnActiveStatusEvent.getBytes()); + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + assertNotNull(deviceService.get(vehicleId, Optional.empty())); + + // additional buffer of 120000 delay to cover up any time lag due to + // kafka/retry thread. + Thread.sleep(Constants.INT_120000 + (maxRetry * threadDelay)); + // Receive DeviceMessageFailureEventData + List> receivedRecords = + getKeyValueRecords(sourceTopicName, consumerProps, Constants.FOUR, Constants.THREAD_SLEEP_TIME_60000); + + // Assert no. of DeviceMessageFailureEventData: + // 1) 1 speed event + // 2) 1 failure event for device status inactive + // 3) 1 failure event for first shoulder tap attempt + // 4) 1 (1 retry attempt) failure event for shoulder tap retry + assertEquals(Constants.FOUR, receivedRecords.size()); + + // Assert speedEvent that was first sent on sourceTopic + IgniteEvent speedIgniteEvent = getIgniteEvent(receivedRecords.get(0).value); + assertEquals(messageId, speedIgniteEvent.getMessageId()); + assertEquals(vehicleId, speedIgniteEvent.getVehicleId()); + assertEquals(EventID.SPEED, speedIgniteEvent.getEventId()); + + // Assert 1st DeviceMessageFailureEventData + IgniteEvent firstDeviceMessageFailureEvent = getIgniteEvent(receivedRecords.get(1).value); + assertEquals(EventID.DEVICEMESSAGEFAILURE, firstDeviceMessageFailureEvent.getEventId()); + + DeviceMessageFailureEventDataV1_0 firstFailEventData = + (DeviceMessageFailureEventDataV1_0) firstDeviceMessageFailureEvent.getEventData(); + assertEquals(DeviceMessageErrorCode.DEVICE_STATUS_INACTIVE, firstFailEventData.getErrorCode()); + assertEquals(0, firstFailEventData.getShoudlerTapRetryAttempts()); + assertEquals(true, firstFailEventData.isDeviceStatusInactive()); + + // Assert failed DeviceMessage + IgniteEvent failedIgniteEvent = firstFailEventData.getFailedIgniteEvent(); + assertEquals(messageId, failedIgniteEvent.getMessageId()); + assertEquals(vehicleId, failedIgniteEvent.getVehicleId()); + assertEquals(EventID.SPEED, failedIgniteEvent.getEventId()); + + // Assert 4th DeviceMessageFailureEventData + IgniteEvent fourthDeviceMessageFailureEvent = getIgniteEvent(receivedRecords.get(Constants.THREE).value); + assertEquals(EventID.DEVICEMESSAGEFAILURE, fourthDeviceMessageFailureEvent.getEventId()); + + DeviceMessageFailureEventDataV1_0 fourthFailEventData = + (DeviceMessageFailureEventDataV1_0) fourthDeviceMessageFailureEvent.getEventData(); + assertEquals(DeviceMessageErrorCode.RETRYING_SHOULDER_TAP, fourthFailEventData.getErrorCode()); + assertEquals(1, fourthFailEventData.getShoudlerTapRetryAttempts()); + assertEquals(true, fourthFailEventData.isDeviceStatusInactive()); + } + + /** + * Test if shoulder tap msg delivered and device remains in active then max retry is attempted. + * + * @throws Exception the exception + */ + @Test + public void testIfShoulderTapMsgDeliveredAndDeviceRemainsInActiveThenMaxRetryIsAttempted() throws Exception { + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamSendSMSUrl", "http://localhost:" + webServer.getPort() + "/"); + String transactionId = "f71e2395-eda2-4de9-ad0a-72e930111736"; + String shoulderTapSendSMSJsonResponse = "{\"message\": \"SUCCESS\",\"failureReasonCode\": null," + + "\"failureReason\": null,\"data\": {\"transactionId\": \"" + + transactionId + "\"}}"; + MockResponse mockShoulderTapResponse = getMockResponse(shoulderTapSendSMSJsonResponse); + + for (int index = 0; index < maxRetry + 1; index++) { + + webServer.enqueue(mockShoulderTapResponse); + } + + String deviceConnStatusEvent = "{\"EventID\": \"DeviceConnStatus\",\"Version\": \"1.0\",\"Data\": " + + "{\"connStatus\":\"INACTIVE\",\"serviceName\":\"ECall\"},\"MessageId\": \"1234\",\"VehicleId\": \"" + + vehicleId + "\",\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(connStatusTopic, producerProps, + vehicleId.getBytes(), deviceConnStatusEvent.getBytes()); + Thread.sleep(Constants.THREAD_SLEEP_TIME_5000); + assertNull(deviceService.get(DMAConstants.VEHICLE_DEVICE_MAPPING + serviceName + vehicleId, Optional.empty())); + + String messageId = "Message12345"; + String value = "20.0"; + String speedEvent = "{\"EventID\": \"Speed\",\"Version\": \"1.0\",\"Data\": {\"value\":" + value + + "},\"RequestId\":\"Request123\", \"MessageId\":\"" + + messageId + "\",\"BizTransactionId\": \"Biz1237\",\"VehicleId\": \"" + + vehicleId + "\",\"SourceDeviceId\": \"Device12345\"}"; + KafkaTestUtils.sendMessages(sourceTopicName, producerProps, vehicleId.getBytes(), speedEvent.getBytes()); + + Thread.sleep(Constants.INT_20000); + + // additional buffer of 120000 delay to cover up any time lag due to + // kafka/retry thread. + Thread.sleep(Constants.INT_120000 + (maxRetry * threadDelay)); + // Receive DeviceMessageFailureEventData + List> receivedRecords = + getKeyValueRecords(sourceTopicName, consumerProps, Constants.SEVEN, Constants.THREAD_SLEEP_TIME_60000); + + // Assert no. of DeviceMessageFailureEventData: + // 1) 1 speed event + // 2) 1 failure event for device status inactive + // 3) 1 failure event for first shoulder tap attempt + // 4) 3 (maxRetry count) failure event for shoulder tap retry + // 5) 1 failure event for shoulder tap retry attempt exceeded + assertEquals(Constants.SEVEN, receivedRecords.size()); + + // Assert speedEvent that was first sent on sourceTopic + IgniteEvent speedIgniteEvent = getIgniteEvent(receivedRecords.get(0).value); + assertEquals(messageId, speedIgniteEvent.getMessageId()); + assertEquals(vehicleId, speedIgniteEvent.getVehicleId()); + assertEquals(EventID.SPEED, speedIgniteEvent.getEventId()); + + // Assert 1st DeviceMessageFailureEventData + IgniteEvent firstDeviceMessageFailureEvent = getIgniteEvent(receivedRecords.get(1).value); + assertEquals(EventID.DEVICEMESSAGEFAILURE, firstDeviceMessageFailureEvent.getEventId()); + + DeviceMessageFailureEventDataV1_0 firstFailEventData = + (DeviceMessageFailureEventDataV1_0) firstDeviceMessageFailureEvent.getEventData(); + assertEquals(DeviceMessageErrorCode.DEVICE_STATUS_INACTIVE, firstFailEventData.getErrorCode()); + assertEquals(0, firstFailEventData.getShoudlerTapRetryAttempts()); + assertEquals(true, firstFailEventData.isDeviceStatusInactive()); + + // Assert failed DeviceMessage + IgniteEvent failedIgniteEvent = firstFailEventData.getFailedIgniteEvent(); + assertEquals(messageId, failedIgniteEvent.getMessageId()); + assertEquals(vehicleId, failedIgniteEvent.getVehicleId()); + assertEquals(EventID.SPEED, failedIgniteEvent.getEventId()); + + // Assert 3rd DeviceMessageFailureEventData + IgniteEvent thirdDeviceMessageFailureEvent = getIgniteEvent(receivedRecords.get(Constants.THREE).value); + assertEquals(EventID.DEVICEMESSAGEFAILURE, thirdDeviceMessageFailureEvent.getEventId()); + + DeviceMessageFailureEventDataV1_0 thirdFailEventData = + (DeviceMessageFailureEventDataV1_0) thirdDeviceMessageFailureEvent.getEventData(); + assertEquals(DeviceMessageErrorCode.RETRYING_SHOULDER_TAP, thirdFailEventData.getErrorCode()); + assertEquals(1, thirdFailEventData.getShoudlerTapRetryAttempts()); + assertEquals(true, thirdFailEventData.isDeviceStatusInactive()); + + // Assert 6th DeviceMessageFailureEventData + IgniteEvent sixthDeviceMessageFailureEvent = getIgniteEvent(receivedRecords.get(Constants.SIX).value); + assertEquals(EventID.DEVICEMESSAGEFAILURE, sixthDeviceMessageFailureEvent.getEventId()); + + DeviceMessageFailureEventDataV1_0 sixthFailEventData = + (DeviceMessageFailureEventDataV1_0) sixthDeviceMessageFailureEvent.getEventData(); + sixthFailEventData = (DeviceMessageFailureEventDataV1_0) sixthDeviceMessageFailureEvent.getEventData(); + assertEquals(DeviceMessageErrorCode.SHOULDER_TAP_RETRY_ATTEMPTS_EXCEEDED, sixthFailEventData.getErrorCode()); + assertEquals(maxRetry, sixthFailEventData.getShoudlerTapRetryAttempts()); + assertEquals(true, sixthFailEventData.isDeviceStatusInactive()); + } + + /** + * Gets the mock response. + * + * @param shoulderTapSendSMSJsonResponse the shoulder tap send SMS json response + * @return the mock response + */ + private static MockResponse getMockResponse(String shoulderTapSendSMSJsonResponse) { + MockResponse mockShoulderTapResponse = new MockResponse(); + mockShoulderTapResponse.setResponseCode(Constants.INT_202); + mockShoulderTapResponse.setBody(shoulderTapSendSMSJsonResponse); + return mockShoulderTapResponse; + } + + /** + * inner class DMAShoulderTapServiceProcessor implements IgniteEventStreamProcessor. + */ + public static final class DMAShoulderTapServiceProcessor implements IgniteEventStreamProcessor { + + /** The spc. */ + private StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "DMAShoulderTapServiceProcessor"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + IgniteEvent value = kafkaRecord.value(); + AbstractIgniteEvent event = (AbstractIgniteEvent) value; + if (EventID.DEVICEMESSAGEFAILURE.equals(event.getEventId())) { + LOGGER.debug("Received feedBackEvent: " + value); + } else { + event.setDeviceRoutable(true); + event.setShoulderTapEnabled(true); + spc.forward(kafkaRecord.withValue(value)); + } + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + } + + /** + * Close. + */ + @Override + public void close() { + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + /** + * Sources. + * + * @return the string[] + */ + @Override + public String[] sources() { + return new String[] { sourceTopicName }; + } + + /** + * Sinks. + * + * @return the string[] + */ + @Override + public String[] sinks() { + return new String[] { sourceTopicName }; + } + } + + /** + * Gets the ignite event. + * + * @param eventData the event data + * @return the ignite event + * @throws JsonParseException the json parse exception + * @throws JsonMappingException the json mapping exception + * @throws IOException Signals that an I/O exception has occurred. + */ + private IgniteEvent getIgniteEvent(byte[] eventData) throws JsonParseException, JsonMappingException, IOException { + GenericIgniteEventTransformer eventTransformer = new GenericIgniteEventTransformer(); + IgniteEvent event = eventTransformer.fromBlob(eventData, Optional.empty()); + + return event; + } + + /** + * Gets the scheduled thread delay. + * + * @return the scheduled thread delay + */ + long getScheduledThreadDelay() { + long freq = retryInterval / Constants.TWO; + long delay = freq > retryMinThreshold ? freq : retryMinThreshold; + return delay; + } +} \ No newline at end of file diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapRetryHandlerTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapRetryHandlerTest.java new file mode 100644 index 0000000..056e909 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapRetryHandlerTest.java @@ -0,0 +1,590 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.shouldertap; + +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.idgen.MessageIdGenerator; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.domain.DeviceMessageFailureEventDataV1_0; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.entities.dma.DeviceMessageErrorCode; +import org.eclipse.ecsp.entities.dma.RetryRecord; +import org.eclipse.ecsp.entities.dma.RetryRecordIds; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.eclipse.ecsp.stream.dma.dao.ShoulderTapRetryBucketDAO; +import org.eclipse.ecsp.stream.dma.dao.ShoulderTapRetryRecordDAOCacheImpl; +import org.eclipse.ecsp.stream.dma.dao.key.RetryVehicleIdKey; +import org.eclipse.ecsp.stream.dma.dao.key.ShoulderTapRetryBucketKey; +import org.eclipse.ecsp.stream.dma.handler.DeviceMessageUtils; +import org.eclipse.ecsp.stream.dma.handler.RetryTestEvent; +import org.eclipse.ecsp.stream.dma.handler.RetryTestKey; +import org.eclipse.ecsp.stream.dma.handler.TestKVIterator; +import org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer; +import org.eclipse.ecsp.utils.ConcurrentHashSet; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentSkipListMap; + +import static java.util.concurrent.CompletableFuture.delayedExecutor; +import static java.util.concurrent.CompletableFuture.runAsync; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.Assert.assertTrue; + + +/** + * {@link DeviceShoulderTapRetryHandler} UT class {@link DeviceShoulderTapRetryHandlerTest}. + */ +public class DeviceShoulderTapRetryHandlerTest { + + /** The vehicle id. */ + private final String vehicleId = "Vehicle12345"; + + /** The mockito rule. */ + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + /** The shoulder tap retry handler. */ + @InjectMocks + private DeviceShoulderTapRetryHandler shoulderTapRetryHandler; + + /** The shoulder tap retry bucket DAO. */ + @Mock + private ShoulderTapRetryBucketDAO shoulderTapRetryBucketDAO; + + /** The msg id generator. */ + @Mock + private MessageIdGenerator msgIdGenerator; + + /** The device message utils. */ + @Mock + private DeviceMessageUtils deviceMessageUtils; + + /** The shoulder tap retry record DAO. */ + @Mock + private ShoulderTapRetryRecordDAOCacheImpl shoulderTapRetryRecordDAO; + + /** The device shoulder tap invoker. */ + @Mock + private DeviceShoulderTapInvoker deviceShoulderTapInvoker; + + /** The spc. */ + @Mock + private StreamProcessingContext spc; + + /** The event. */ + private RetryTestEvent event; + + /** The retry test key. */ + private RetryTestKey retryTestKey = new RetryTestKey(); + + /** The task id. */ + private String taskId = "taskId"; + + /** The extra parameters. */ + private Map extraParameters = new HashMap(); + + /** The transformer. */ + private DeviceMessageIgniteEventTransformer transformer = new DeviceMessageIgniteEventTransformer(); + + /** The source topic. */ + private String sourceTopic = "testTopic"; + + /** + * setup(): to set up retry event. + */ + @Before + public void setUp() { + shoulderTapRetryHandler.setServiceName("service"); + retryTestKey.setKey(vehicleId); + MockitoAnnotations.initMocks(this); + event = new RetryTestEvent(); + event.setVehicleId(vehicleId); + event.setRequestId("Req123"); + event.setMessageId("Msg123"); + event.setBizTransactionId("Biz123"); + // Class classObject = + // getClass().getClassLoader() + // .loadClass("org.eclipse.ecsp.stream.dma.shouldertap.DummyShoulderTapInvokerImpl"); + // Mockito.when(ctx.getBean(classObject)).thenReturn((DeviceShoulderTapInvoker) + // deviceShoulderTapInvoker); + Mockito.when(deviceShoulderTapInvoker.sendWakeUpMessage("Req123", vehicleId, extraParameters, spc)) + .thenReturn(true); + } + + /** + * Test ST retry handle when max retry less than zero. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testSTRetryHandleWhenMaxRetryLessThanZero() throws InterruptedException { + shoulderTapRetryHandler.close(); + shoulderTapRetryHandler.setRetryIntervalDivisor(Constants.TEN); + shoulderTapRetryHandler.setMaxRetry(Constants.NEGATIVE_ONE); + shoulderTapRetryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + shoulderTapRetryHandler.setRetryInterval(Constants.THREAD_SLEEP_TIME_1000); + shoulderTapRetryHandler.setup(taskId); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), + Version.V1_0, event, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + + shoulderTapRetryHandler.registerDevice(retryTestKey, entity, extraParameters); + Mockito.verify(deviceMessageUtils, Mockito.times(1)) + .postFailureEvent(Mockito.any(DeviceMessageFailureEventDataV1_0.class), + Mockito.any(IgniteKey.class), Mockito.any(StreamProcessingContext.class), Mockito.anyString()); + + ArgumentCaptor failDataArg = ArgumentCaptor + .forClass(DeviceMessageFailureEventDataV1_0.class); + Mockito.verify(deviceMessageUtils).postFailureEvent(failDataArg.capture(), Mockito.any(IgniteKey.class), + Mockito.any(StreamProcessingContext.class), Mockito.anyString()); + + DeviceMessageFailureEventDataV1_0 actual = failDataArg.getValue(); + Assert.assertEquals(DeviceMessageErrorCode.RETRYING_SHOULDER_TAP, actual.getErrorCode()); + Assert.assertEquals(0, actual.getShoudlerTapRetryAttempts()); + Assert.assertEquals(0, actual.getRetryAttempts()); + Assert.assertEquals(true, actual.isDeviceStatusInactive()); + Assert.assertEquals(false, actual.isDeviceDeliveryCutoffExceeded()); + + Mockito.verify(shoulderTapRetryBucketDAO, Mockito.times(0)) + .update(Mockito.anyString(), Mockito.any(ShoulderTapRetryBucketKey.class), + Mockito.anyString()); + Mockito.verify(deviceShoulderTapInvoker, Mockito.times(1)) + .sendWakeUpMessage("Req123", vehicleId, extraParameters, spc); + + } + + /** + * Test setup when retry interval less than retry threshold. + */ + @Test(expected = IllegalArgumentException.class) + public void testSetupWhenRetryIntervalLessThanRetryThreshold() { + shoulderTapRetryHandler.setRetryIntervalDivisor(Constants.TEN); + shoulderTapRetryHandler.setRetryInterval(TestConstants.THREAD_SLEEP_TIME_100); + shoulderTapRetryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_200); + shoulderTapRetryHandler.setup(taskId); + } + + /** + * Test setup when retry interval less than zero. + */ + @Test(expected = IllegalArgumentException.class) + public void testSetupWhenRetryIntervalLessThanZero() { + shoulderTapRetryHandler.setRetryIntervalDivisor(Constants.TEN); + shoulderTapRetryHandler.setRetryInterval(-TestConstants.THREAD_SLEEP_TIME_100); + shoulderTapRetryHandler.setup(taskId); + } + + /** + * Test setup when retry threshold less than zero. + */ + @Test(expected = IllegalArgumentException.class) + public void testSetupWhenRetryThresholdLessThanZero() { + shoulderTapRetryHandler.setRetryIntervalDivisor(Constants.TEN); + shoulderTapRetryHandler.setRetryMinThreshold(-Constants.THREAD_SLEEP_TIME_200); + shoulderTapRetryHandler.setup(taskId); + } + + /** + * Test scheduled thread delay. + */ + @Test + public void testScheduledThreadDelay() { + shoulderTapRetryHandler.setRetryIntervalDivisor(Constants.TEN); + shoulderTapRetryHandler.setRetryInterval(TestConstants.THREAD_SLEEP_TIME_40000); + shoulderTapRetryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + Assert.assertEquals(TestConstants.THREAD_SLEEP_TIME_4000, shoulderTapRetryHandler.getScheduledThreadDelay()); + shoulderTapRetryHandler.close(); + shoulderTapRetryHandler.setRetryInterval(TestConstants.THREAD_SLEEP_TIME_900); + shoulderTapRetryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + Assert.assertEquals(TestConstants.THREAD_SLEEP_TIME_500, shoulderTapRetryHandler.getScheduledThreadDelay()); + } + + /** + * Test retry handle when max retry threshold has not been reached. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testRetryHandleWhenMaxRetryThresholdHasNotBeenReached() throws InterruptedException { + ConcurrentHashSet vehicleIds = new ConcurrentHashSet(); + vehicleIds.add(vehicleId); + + // It should be able to retry past keys, hence we are subtracting Constants.TEN + // seconds + long currentTime = System.currentTimeMillis() - TestConstants.THREAD_SLEEP_TIME_10000; + ConcurrentSkipListMap map = + new ConcurrentSkipListMap(); + map.put((new ShoulderTapRetryBucketKey(currentTime)), new RetryRecordIds(Version.V1_0, vehicleIds)); + TestKVIterator itr = + new TestKVIterator(map); + Mockito.when(shoulderTapRetryBucketDAO.getHead(Mockito.any(ShoulderTapRetryBucketKey.class))) + .thenReturn(itr); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), + Version.V1_0, event, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + + RetryRecord record = new RetryRecord(retryTestKey, entity, currentTime); + record.addAttempt(currentTime + Constants.TEN); + record.setExtraParameters(extraParameters); + Mockito.when(spc.streamName()).thenReturn("topic"); + Mockito.when(shoulderTapRetryRecordDAO.get(Mockito.any(RetryVehicleIdKey.class))).thenReturn(record); + + // Max Retry is set to Constants.TWO here. + shoulderTapRetryHandler.setRetryIntervalDivisor(Constants.TEN); + shoulderTapRetryHandler.setMaxRetry(Constants.TWO); + shoulderTapRetryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + shoulderTapRetryHandler.setRetryInterval(Constants.THREAD_SLEEP_TIME_1000); + shoulderTapRetryHandler.setup(taskId); + runAsync(() -> {}, delayedExecutor(Constants.THREAD_SLEEP_TIME_200, MILLISECONDS)).join(); + shoulderTapRetryHandler.close(); + + Mockito.verify(shoulderTapRetryRecordDAO, Mockito.times(1)) + .putToMap(Mockito.anyString(), Mockito.any(RetryVehicleIdKey.class), + Mockito.any(RetryRecord.class), Mockito.any(Optional.class), Mockito.anyString()); + ArgumentCaptor recordKeyArgument = ArgumentCaptor.forClass(RetryVehicleIdKey.class); + ArgumentCaptor recordArgument = ArgumentCaptor.forClass(RetryRecord.class); + ArgumentCaptor parentKeyArgument = ArgumentCaptor.forClass(String.class); + Mockito.verify(shoulderTapRetryRecordDAO).putToMap(parentKeyArgument.capture(), recordKeyArgument.capture(), + recordArgument.capture(), Mockito.any(Optional.class), Mockito.anyString()); + StringBuilder key = new StringBuilder(Constants.OFFSET_VALUE); + key.append(DMAConstants.SHOULDER_TAP_RETRY_VEHICLEID).append(DMAConstants.COLON) + .append("service").append(DMAConstants.COLON) + .append(taskId); + RetryVehicleIdKey actualKey = recordKeyArgument.getValue(); + Assert.assertEquals(key.toString(), parentKeyArgument.getValue()); + Assert.assertEquals(vehicleId, actualKey.convertToString()); + RetryRecord actualValue = recordArgument.getValue(); + Assert.assertEquals(event, actualValue.getDeviceMessage().getEvent()); + Assert.assertEquals(Constants.TWO, actualValue.getAttempts()); + Mockito.verify(deviceShoulderTapInvoker, Mockito.times(1)) + .sendWakeUpMessage("Req123", vehicleId, extraParameters, spc); + + ArgumentCaptor failDataArg = ArgumentCaptor + .forClass(DeviceMessageFailureEventDataV1_0.class); + Mockito.verify(deviceMessageUtils).postFailureEvent(failDataArg.capture(), Mockito.any(IgniteKey.class), + Mockito.any(StreamProcessingContext.class), Mockito.anyString()); + + DeviceMessageFailureEventDataV1_0 actual = failDataArg.getValue(); + Assert.assertEquals(DeviceMessageErrorCode.RETRYING_SHOULDER_TAP, actual.getErrorCode()); + Assert.assertEquals(Constants.TWO, actual.getShoudlerTapRetryAttempts()); + Assert.assertEquals(0, actual.getRetryAttempts()); + Assert.assertEquals(true, actual.isDeviceStatusInactive()); + Assert.assertEquals(false, actual.isDeviceDeliveryCutoffExceeded()); + } + + /** + * Test retry handle when max retry threshold has reached. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testRetryHandleWhenMaxRetryThresholdHasReached() throws InterruptedException { + ConcurrentHashSet vehicleIds = new ConcurrentHashSet(); + vehicleIds.add(vehicleId); + + // It should be able to retry past keys, hence we are subtracting Constants.TEN + // seconds + long currentTime = System.currentTimeMillis() - TestConstants.THREAD_SLEEP_TIME_10000; + ConcurrentSkipListMap map = + new ConcurrentSkipListMap(); + map.put((new ShoulderTapRetryBucketKey(currentTime)), new RetryRecordIds(Version.V1_0, vehicleIds)); + TestKVIterator itr = + new TestKVIterator(map); + Mockito.when(shoulderTapRetryBucketDAO.getHead(Mockito.any(ShoulderTapRetryBucketKey.class))) + .thenReturn(itr); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), + Version.V1_0, event, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + + RetryRecord record = new RetryRecord(retryTestKey, entity, currentTime); + record.addAttempt(currentTime + Constants.TEN); + record.addAttempt(currentTime + Constants.TWENTY); + record.setExtraParameters(extraParameters); + Mockito.when(spc.streamName()).thenReturn("topic"); + Mockito.when(shoulderTapRetryRecordDAO.get(Mockito.any(RetryVehicleIdKey.class))).thenReturn(record); + + // Max Retry is set to Constants.TWO here. + int maxRetry = Constants.TWO; + shoulderTapRetryHandler.setRetryIntervalDivisor(Constants.TEN); + shoulderTapRetryHandler.setMaxRetry(maxRetry); + shoulderTapRetryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + shoulderTapRetryHandler.setRetryInterval(Constants.THREAD_SLEEP_TIME_1000); + shoulderTapRetryHandler.setup(taskId); + runAsync(() -> {}, delayedExecutor(Constants.THREAD_SLEEP_TIME_200, MILLISECONDS)).join(); + shoulderTapRetryHandler.close(); + + Mockito.verify(shoulderTapRetryRecordDAO, Mockito.times(1)) + .deleteFromMap(Mockito.anyString(), Mockito.any(RetryVehicleIdKey.class), + Mockito.any(Optional.class), Mockito.anyString()); + ArgumentCaptor recordKeyArgument = ArgumentCaptor + .forClass(RetryVehicleIdKey.class); + ArgumentCaptor parentKeyArgument = ArgumentCaptor.forClass(String.class); + Mockito.verify(shoulderTapRetryRecordDAO).deleteFromMap(parentKeyArgument.capture(), + recordKeyArgument.capture(), + Mockito.any(Optional.class), Mockito.anyString()); + RetryVehicleIdKey actualKey = recordKeyArgument.getValue(); + StringBuilder key = new StringBuilder(Constants.OFFSET_VALUE); + key.append(DMAConstants.SHOULDER_TAP_RETRY_VEHICLEID).append(DMAConstants.COLON) + .append("service").append(DMAConstants.COLON) + .append(taskId); + Assert.assertEquals(key.toString(), parentKeyArgument.getValue()); + Assert.assertEquals(vehicleId, actualKey.convertToString()); + Mockito.verify(deviceShoulderTapInvoker, Mockito.times(0)) + .sendWakeUpMessage("Req123", vehicleId, extraParameters, spc); + Mockito.verify(deviceMessageUtils, Mockito.times(1)) + .postFailureEvent(Mockito.any(DeviceMessageFailureEventDataV1_0.class), + Mockito.any(IgniteKey.class), Mockito.any(StreamProcessingContext.class), + Mockito.anyString()); + ArgumentCaptor failDataArg = ArgumentCaptor + .forClass(DeviceMessageFailureEventDataV1_0.class); + Mockito.verify(deviceMessageUtils).postFailureEvent(failDataArg.capture(), Mockito.any(IgniteKey.class), + Mockito.any(StreamProcessingContext.class), Mockito.anyString()); + + DeviceMessageFailureEventDataV1_0 actual = failDataArg.getValue(); + Assert.assertEquals(DeviceMessageErrorCode.SHOULDER_TAP_RETRY_ATTEMPTS_EXCEEDED, actual.getErrorCode()); + Assert.assertEquals(maxRetry, actual.getShoudlerTapRetryAttempts()); + Assert.assertEquals(0, actual.getRetryAttempts()); + Assert.assertEquals(true, actual.isDeviceStatusInactive()); + Assert.assertEquals(false, actual.isDeviceDeliveryCutoffExceeded()); + } + + /** + * Test deregister device. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testDeregisterDevice() throws InterruptedException { + shoulderTapRetryHandler.close(); + shoulderTapRetryHandler.setRetryIntervalDivisor(Constants.TEN); + shoulderTapRetryHandler.setMaxRetry(1); + shoulderTapRetryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + shoulderTapRetryHandler.setRetryInterval(Constants.THREAD_SLEEP_TIME_1000); + shoulderTapRetryHandler.setup(taskId); + shoulderTapRetryHandler.deregisterDevice(vehicleId); + + Mockito.verify(shoulderTapRetryRecordDAO, Mockito.times(1)) + .deleteFromMap(Mockito.anyString(), Mockito.any(RetryVehicleIdKey.class), + Mockito.any(Optional.class), Mockito.anyString()); + + ArgumentCaptor recordKeyArgument = ArgumentCaptor.forClass(RetryVehicleIdKey.class); + ArgumentCaptor parentKeyArgument = ArgumentCaptor.forClass(String.class); + Mockito.verify(shoulderTapRetryRecordDAO).deleteFromMap(parentKeyArgument.capture(), + recordKeyArgument.capture(), + Mockito.any(Optional.class), Mockito.anyString()); + RetryVehicleIdKey actualKey = recordKeyArgument.getValue(); + + StringBuilder key = new StringBuilder(Constants.OFFSET_VALUE); + key.append(DMAConstants.SHOULDER_TAP_RETRY_VEHICLEID).append(DMAConstants.COLON) + .append("service").append(DMAConstants.COLON) + .append(taskId); + Assert.assertEquals(key.toString(), parentKeyArgument.getValue()); + Assert.assertEquals(vehicleId, actualKey.convertToString()); + + } + + /** + * Test set stream processing context. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testSetStreamProcessingContext() throws InterruptedException { + StreamProcessingContext ctx = Mockito.mock(StreamProcessingContext.class); + shoulderTapRetryHandler.setStreamProcessingContext(ctx); + + StreamProcessingContext ctxRetrieved = (StreamProcessingContext) + ReflectionTestUtils.getField(shoulderTapRetryHandler, "spc"); + Assert.assertNotNull(ctxRetrieved); + } + + /** + * Test init invalid device shoulder tap invoker impl config. + * + * @throws InterruptedException the interrupted exception + */ + @Test(expected = IllegalArgumentException.class) + public void testInitInvalidDeviceShoulderTapInvokerImplConfig() throws InterruptedException { + String deviceShoulderTapInvoker = + "org.eclipse.ecsp.stream.dma.shouldertap.ShoulderTapInvokerWAMImp"; + ReflectionTestUtils.setField(shoulderTapRetryHandler, + "deviceShoulderTapInvokerImplClass", deviceShoulderTapInvoker); + + shoulderTapRetryHandler.init(); + } + + /** + * Test setup invalid retry interal equal to zero. + * + * @throws InterruptedException the interrupted exception + */ + @Test(expected = IllegalArgumentException.class) + public void testSetupInvalidRetryInteralEqualToZero() throws InterruptedException { + ReflectionTestUtils.setField(shoulderTapRetryHandler, "retryIntervalDividend", Constants.TEN); + ReflectionTestUtils.setField(shoulderTapRetryHandler, "retryMinThreshold", Constants.THREAD_SLEEP_TIME_500); + ReflectionTestUtils.setField(shoulderTapRetryHandler, "retryInterval", 0); + shoulderTapRetryHandler.setup(taskId); + } + + /** + * Test retry handle when device message vehicle id registered is first attempt. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testRetryHandleWhenDeviceMessageVehicleIdRegisteredIsFirstAttempt() throws InterruptedException { + shoulderTapRetryHandler.close(); + shoulderTapRetryHandler.setRetryIntervalDivisor(Constants.TEN); + shoulderTapRetryHandler.setMaxRetry(Constants.THREE); + shoulderTapRetryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + shoulderTapRetryHandler.setRetryInterval(Constants.THREAD_SLEEP_TIME_1000); + shoulderTapRetryHandler.setup(taskId); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), Version.V1_0, + event, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + Mockito.when(shoulderTapRetryRecordDAO.get(Mockito.any(RetryVehicleIdKey.class))).thenReturn(null); + + boolean registered = shoulderTapRetryHandler.registerDevice(retryTestKey, entity, extraParameters); + assertTrue(registered); + + Mockito.verify(shoulderTapRetryRecordDAO, Mockito.times(1)) + .putToMap(Mockito.anyString(), Mockito.any(RetryVehicleIdKey.class), + Mockito.any(RetryRecord.class), Mockito.any(Optional.class), Mockito.anyString()); + ArgumentCaptor recordKeyArgument = ArgumentCaptor.forClass(RetryVehicleIdKey.class); + ArgumentCaptor recordArgument = ArgumentCaptor.forClass(RetryRecord.class); + ArgumentCaptor parentKeyArgument = ArgumentCaptor.forClass(String.class); + Mockito.verify(shoulderTapRetryRecordDAO).putToMap(parentKeyArgument.capture(), recordKeyArgument.capture(), + recordArgument.capture(), Mockito.any(Optional.class), Mockito.anyString()); + + RetryVehicleIdKey actualKey = recordKeyArgument.getValue(); + StringBuilder retryRecordKey = new StringBuilder(Constants.OFFSET_VALUE); + retryRecordKey.append(DMAConstants.SHOULDER_TAP_RETRY_VEHICLEID).append(DMAConstants.COLON).append("service") + .append(DMAConstants.COLON).append(taskId); + Assert.assertEquals(retryRecordKey.toString(), parentKeyArgument.getValue()); + RetryRecord actualValue = recordArgument.getValue(); + Assert.assertEquals(vehicleId, actualKey.convertToString()); + Assert.assertEquals(event, actualValue.getDeviceMessage().getEvent()); + Assert.assertEquals(0, actualValue.getAttempts()); + + Mockito.verify(shoulderTapRetryBucketDAO, Mockito.times(1)).update(Mockito.anyString(), + Mockito.any(ShoulderTapRetryBucketKey.class), + Mockito.any(String.class)); + + Mockito.verify(deviceShoulderTapInvoker, Mockito.times(1)) + .sendWakeUpMessage("Req123", vehicleId, extraParameters, spc); + + ArgumentCaptor failDataArg = ArgumentCaptor + .forClass(DeviceMessageFailureEventDataV1_0.class); + Mockito.verify(deviceMessageUtils).postFailureEvent(failDataArg.capture(), Mockito.any(IgniteKey.class), + Mockito.any(StreamProcessingContext.class), Mockito.anyString()); + + DeviceMessageFailureEventDataV1_0 actual = failDataArg.getValue(); + Assert.assertEquals(DeviceMessageErrorCode.RETRYING_SHOULDER_TAP, actual.getErrorCode()); + Assert.assertEquals(0, actual.getShoudlerTapRetryAttempts()); + Assert.assertEquals(0, actual.getRetryAttempts()); + Assert.assertEquals(true, actual.isDeviceStatusInactive()); + Assert.assertEquals(false, actual.isDeviceDeliveryCutoffExceeded()); + } + + /** + * Test retry handle when device message vehicle id is already registered. + * + * @throws InterruptedException the interrupted exception + */ + @Test + public void testRetryHandleWhenDeviceMessageVehicleIdIsAlreadyRegistered() throws InterruptedException { + shoulderTapRetryHandler.setRetryIntervalDivisor(Constants.TEN); + shoulderTapRetryHandler.setMaxRetry(Constants.TWO); + shoulderTapRetryHandler.setRetryMinThreshold(Constants.THREAD_SLEEP_TIME_500); + shoulderTapRetryHandler.setRetryInterval(Constants.THREAD_SLEEP_TIME_1000); + shoulderTapRetryHandler.setup(taskId); + + ConcurrentHashSet vehicleIds = new ConcurrentHashSet(); + vehicleIds.add(vehicleId); + + long currentTime = System.currentTimeMillis(); + ConcurrentSkipListMap map = + new ConcurrentSkipListMap(); + map.put((new ShoulderTapRetryBucketKey(currentTime)), + new RetryRecordIds(Version.V1_0, vehicleIds)); + TestKVIterator itr = + new TestKVIterator(map); + Mockito.when(shoulderTapRetryBucketDAO.getHead(Mockito.any(ShoulderTapRetryBucketKey.class))) + .thenReturn(itr); + + DeviceMessage entity = new DeviceMessage(transformer.toBlob(event), + Version.V1_0, event, sourceTopic, Constants.THREAD_SLEEP_TIME_60000); + + RetryRecord record = new RetryRecord(retryTestKey, entity, currentTime); + record.addAttempt(currentTime + Constants.TEN); + record.setExtraParameters(extraParameters); + Mockito.when(spc.streamName()).thenReturn("topic"); + Mockito.when(shoulderTapRetryRecordDAO.get(Mockito.any(RetryVehicleIdKey.class))).thenReturn(record); + + boolean registered = shoulderTapRetryHandler.registerDevice(retryTestKey, entity, extraParameters); + assertTrue(registered); + } + + /** + * Close. + */ + @After + public void close() { + shoulderTapRetryHandler.close(); + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapServiceTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapServiceTest.java new file mode 100644 index 0000000..4ba46c1 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DeviceShoulderTapServiceTest.java @@ -0,0 +1,295 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.shouldertap; + +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.entities.dma.DeviceMessage; +import org.eclipse.ecsp.key.IgniteKey; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; + + +/** + * {@link DeviceShoulderTapServiceTest} UT class for {@link DeviceShoulderTapService}. + */ +public class DeviceShoulderTapServiceTest { + + /** The device shoulder tap service. */ + @InjectMocks + private DeviceShoulderTapService deviceShoulderTapService; + + /** The shoulder tap retry handler. */ + @Mock + private DeviceShoulderTapRetryHandler shoulderTapRetryHandler; + + /** + * Sets the up. + * + * @throws Exception the exception + */ + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + deviceShoulderTapService.setShoulderTapEnabled(true); + } + + /** + * Test wake up device wake up status true. + */ + @Test + public void testWakeUpDeviceWakeUpStatusTrue() { + + TestKey igniteKey = new TestKey(); + DeviceMessage igniteEvent = new DeviceMessage(); + TestEvent event = new TestEvent(); + igniteEvent.setEvent(event); + + Map extraParameters = new HashMap<>(); + extraParameters.put("bizTransactionId", "bizTransactionId123"); + + Mockito.when(shoulderTapRetryHandler.registerDevice(igniteKey, igniteEvent, extraParameters)) + .thenReturn(true); + String requestId = "Request123"; + String vehicleId = "Vehicle123"; + String serviceName = "ECall"; + boolean wakeUpStatus = deviceShoulderTapService + .wakeUpDevice(requestId, vehicleId, serviceName, igniteKey, igniteEvent, + extraParameters); + + assertEquals("Expected wakeUpStatus as true", true, wakeUpStatus); + + Mockito.verify(shoulderTapRetryHandler, Mockito.times(1)).registerDevice(Mockito.any(IgniteKey.class), + Mockito.any(DeviceMessage.class), Mockito.any(Map.class)); + } + + /** + * Test wake up device wake up status false. + */ + @Test + public void testWakeUpDeviceWakeUpStatusFalse() { + TestKey igniteKey = new TestKey(); + TestEvent event = new TestEvent(); + DeviceMessage igniteEvent = new DeviceMessage(); + igniteEvent.setEvent(event); + + Map extraParameters = new HashMap<>(); + extraParameters.put("bizTransactionId", "bizTransactionId123"); + + Mockito.when(shoulderTapRetryHandler.registerDevice(igniteKey, igniteEvent, extraParameters)) + .thenReturn(false); + + String requestId = "Req123"; + String vehicleId = "Vehicle123"; + String serviceName = "ECall"; + boolean wakeUpStatus = deviceShoulderTapService + .wakeUpDevice(requestId, vehicleId, serviceName, igniteKey, igniteEvent, + extraParameters); + + assertEquals("Expected wakeUpStatus as false", false, wakeUpStatus); + Mockito.verify(shoulderTapRetryHandler, Mockito.times(1)).registerDevice(Mockito.any(IgniteKey.class), + Mockito.any(DeviceMessage.class), Mockito.any(Map.class)); + } + + /** + * Test wake up device wake up status with shoulder tap disabled. + */ + @Test + public void testWakeUpDeviceWakeUpStatusWithShoulderTapDisabled() { + DeviceMessage igniteEvent = new DeviceMessage(); + TestEvent event = new TestEvent(); + igniteEvent.setEvent(event); + + Map extraParameters = new HashMap<>(); + extraParameters.put("bizTransactionId", "bizTransactionId123"); + + deviceShoulderTapService.setShoulderTapEnabled(false); + TestKey igniteKey = new TestKey(); + String serviceName = "ECall"; + String requestId = "Req123"; + String vehicleId = "Vehicle123"; + boolean wakeUpStatus = deviceShoulderTapService.wakeUpDevice(requestId, + vehicleId, serviceName, igniteKey, igniteEvent, + extraParameters); + assertEquals("Expected wakeUpStatus as false", false, wakeUpStatus); + + Mockito.verify(shoulderTapRetryHandler, Mockito.times(0)) + .registerDevice(igniteKey, igniteEvent, extraParameters); + } + + /** + * Testexecute on device active status. + */ + @Test + public void testexecuteOnDeviceActiveStatus() { + String requestId = "Req123"; + String vehicleId = "Vehicle123"; + String serviceName = "ECall"; + + deviceShoulderTapService.executeOnDeviceActiveStatus(requestId, vehicleId, serviceName); + Mockito.verify(shoulderTapRetryHandler, Mockito.times(1)) + .deregisterDevice(Mockito.any(String.class)); + } + + /** + * Test execute on device active status with shoulder tap disabled. + */ + @Test + public void testExecuteOnDeviceActiveStatusWithShoulderTapDisabled() { + deviceShoulderTapService.setShoulderTapEnabled(false); + String requestId = "Req123"; + String vehicleId = "Vehicle123"; + String serviceName = "ECall"; + deviceShoulderTapService.executeOnDeviceActiveStatus(requestId, vehicleId, serviceName); + Mockito.verify(shoulderTapRetryHandler, Mockito.times(0)) + .deregisterDevice(Mockito.any(String.class)); + } + + /** + * Test set stream processing context. + */ + @Test + public void testSetStreamProcessingContext() { + StreamProcessingContext streamProcessingContext = Mockito.mock(StreamProcessingContext.class); + deviceShoulderTapService.setStreamProcessingContext(streamProcessingContext); + + Mockito.verify(shoulderTapRetryHandler, + Mockito.times(1)).setStreamProcessingContext(Mockito.any(StreamProcessingContext.class)); + } + + /** + * Test setup. + */ + @Test + public void testSetup() { + String taskId = "0_0"; + deviceShoulderTapService.setup(taskId); + + Mockito.verify(shoulderTapRetryHandler, + Mockito.times(1)).setup(Mockito.any(String.class)); + } + + /** + * Test setup with shoulder tap disabled. + */ + @Test + public void testSetupWithShoulderTapDisabled() { + String taskId = "0_0"; + deviceShoulderTapService.setShoulderTapEnabled(false); + deviceShoulderTapService.setup(taskId); + Mockito.verify(shoulderTapRetryHandler, + Mockito.times(0)).setup(Mockito.any(String.class)); + } + + /** + * The Class TestKey. + */ + class TestKey implements IgniteKey { + + /** The vehicle id. */ + private String vehicleId; + + /** + * Gets the key. + * + * @return the key + */ + @Override + public String getKey() { + return vehicleId; + } + + /** + * Sets the key. + * + * @param vehicleId the new key + */ + public void setKey(String vehicleId) { + this.vehicleId = vehicleId; + } + } + + /** + * The Class TestEvent. + */ + class TestEvent extends IgniteEventImpl { + + /** The Constant serialVersionUID. */ + private static final long serialVersionUID = 1L; + + /** + * Instantiates a new test event. + */ + public TestEvent() { + + } + + /** + * Gets the event id. + * + * @return the event id + */ + @Override + public String getEventId() { + return "Speed"; + } + + /** + * Gets the target device id. + * + * @return the target device id + */ + @Override + public Optional getTargetDeviceId() { + return Optional.of("Device12345"); + } + + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DummyShoulderTapInvokerImplTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DummyShoulderTapInvokerImplTest.java new file mode 100644 index 0000000..3601e6f --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/DummyShoulderTapInvokerImplTest.java @@ -0,0 +1,90 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.shouldertap; + +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + + +/** + * Test class for {@link DummyShoulderTapInvokerImpl}. + */ +public class DummyShoulderTapInvokerImplTest { + + /** The spc. */ + @Mock + private StreamProcessingContext spc; + + /** The dummy shoulder tap invoker impl. */ + @InjectMocks + private DummyShoulderTapInvokerImpl dummyShoulderTapInvokerImpl; + + /** + * Sets the up. + * + * @throws Exception the exception + */ + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + } + + /** + * Test send wake up message. + */ + @Test + public void testSendWakeUpMessage() { + String requestId = "Request123"; + String vehicleId = "Vehicle123"; + Map extraParameters = new HashMap<>(); + boolean wakeUpStatus = dummyShoulderTapInvokerImpl.sendWakeUpMessage(requestId, vehicleId, + extraParameters, spc); + assertEquals(false, wakeUpStatus); + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerVehicleNotificationImplTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerVehicleNotificationImplTest.java new file mode 100644 index 0000000..1a890fa --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerVehicleNotificationImplTest.java @@ -0,0 +1,95 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.shouldertap; + +import org.eclipse.ecsp.stream.dma.shouldertap.ShoulderTapInvokerVehicleNotificationImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + + +/** + * The Class ShoulderTapInvokerVehicleNotificationImplTest. + */ +class ShoulderTapInvokerVehicleNotificationImplTest { + + /** The shoulder tap invoker vehicle notification impl. */ + private ShoulderTapInvokerVehicleNotificationImpl shoulderTapInvokerVehicleNotificationImpl; + + /** + * Sets the up. + * + * @throws Exception the exception + */ + @BeforeEach + void setUp() throws Exception { + shoulderTapInvokerVehicleNotificationImpl = new ShoulderTapInvokerVehicleNotificationImpl(); + } + + /** + * ShoulderTapInvokerVehicleNotificationImpl.sendWakeUpMessage() always should return false. + */ + @Test + void testSendWakeUpMessage() { + assertNotNull(shoulderTapInvokerVehicleNotificationImpl); + assertFalse(shoulderTapInvokerVehicleNotificationImpl.sendWakeUpMessage("requestId", "vehicleId", + new HashMap<>(), null)); + assertNotEquals(true, shoulderTapInvokerVehicleNotificationImpl.sendWakeUpMessage("", "", null, null)); + + } + + /** + * Tear down. + * + * @throws Exception the exception + */ + @AfterEach + void tearDown() throws Exception { + shoulderTapInvokerVehicleNotificationImpl = null; + } + +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerWAMImplTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerWAMImplTest.java new file mode 100644 index 0000000..7b1b56e --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapInvokerWAMImplTest.java @@ -0,0 +1,418 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.shouldertap; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.http.HttpClient; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.stream.dma.dao.DMAConstants; +import org.jetbrains.annotations.NotNull; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + + +/** + * test class for {@link ShoulderTapInvokerWAMImpl}. + */ +public class ShoulderTapInvokerWAMImplTest { + + /** The wam send sms url. */ + private static String WAM_SEND_SMS_URL = "https://wam.endpoint.com/v1.0/m2m/sms/send"; + + /** The wam transaction status url. */ + private static String WAM_TRANSACTION_STATUS_URL = "https://wam.endpoint.com/v1.0/m2m/sim/transaction"; + + /** The shoulder tap invoker WAM impl. */ + @InjectMocks + public ShoulderTapInvokerWAMImpl shoulderTapInvokerWAMImpl; + + /** The spc. */ + @Mock + private StreamProcessingContext spc; + + /** The http client. */ + @Mock + public HttpClient httpClient; + + /** + * Sets the up. + */ + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + } + + /** + * Test init when WAM send SMS url null. + */ + @Test(expected = IllegalArgumentException.class) + public void testInitWhenWAMSendSMSUrlNull() { + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamSendSMSUrl", null); + shoulderTapInvokerWAMImpl.init(); + } + + /** + * Test init when WAM transaction status url null. + */ + @Test(expected = IllegalArgumentException.class) + public void testInitWhenWAMTransactionStatusUrlNull() { + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamTransactionStatusUrl", null); + + shoulderTapInvokerWAMImpl.init(); + } + + /** + * Test init when WAM urls not null. + */ + @Test + public void testInitWhenWAMUrlsNotNull() { + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamSendSMSUrl", WAM_SEND_SMS_URL); + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamTransactionStatusUrl", WAM_TRANSACTION_STATUS_URL); + Assertions.assertDoesNotThrow(() -> shoulderTapInvokerWAMImpl.init()); + } + + /** + * Test send wake up message with skip status check. + */ + @Test + @SuppressWarnings("unchecked") + public void testSendWakeUpMessageWithSkipStatusCheck() { + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamSendSMSUrl", WAM_SEND_SMS_URL); + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamSendSMSSkipStatusCheck", true); + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamAPIMaxRetryCount", Constants.THREE); + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, + "wamAPIMaxRetryIntervalMs", TestConstants.THREAD_SLEEP_TIME_5000); + + Map additionalParameters = new HashMap<>(); + + Map priorityParam = new HashMap<>(); + priorityParam.put("key", "PRIORITY"); + priorityParam.put("value", "HIGH"); + additionalParameters.put("additionalParameters", priorityParam); + + Map responseData = new HashMap(); + responseData.put(HttpClient.RESPONSE_CODE, "202"); + + try { + ObjectMapper responseMapper = new ObjectMapper(); + JsonNode jsonNode = responseMapper.readTree( + "{\"message\": \"SUCCESS\",\"failureReasonCode\": null,\"failureReason\": null," + + "\"data\": {\"transactionId\": \"f71e2395-eda2-4de9-ad0a-72e930111736\"}}"); + responseData.put(HttpClient.RESPONSE_JSON, jsonNode); + } catch (IOException e) { + e.printStackTrace(); + } + + Mockito.when(httpClient.invokeJsonResource(Mockito.any(HttpClient.HttpReqMethod.class), + Mockito.any(String.class), Mockito.any(Map.class), Mockito.any(Map.class), + Mockito.any(Integer.class), Mockito.any(Long.class))).thenReturn(responseData); + + Map extraParameters = new HashMap<>(); + extraParameters.put(DMAConstants.BIZ_TRANSACTION_ID, "bizTransactionId12345"); + String requestId = "Request12345"; + String vehicleId = "Vehicle12345"; + boolean wakeUpStatus = shoulderTapInvokerWAMImpl.sendWakeUpMessage(requestId, vehicleId, extraParameters, spc); + assertEquals(true, wakeUpStatus); + + Mockito.verify(httpClient, Mockito.times(1)).invokeJsonResource(Mockito.any(HttpClient.HttpReqMethod.class), + Mockito.any(String.class), + Mockito.any(Map.class), Mockito.any(Map.class), Mockito.any(Integer.class), Mockito.any(Long.class)); + } + + /** + * Test send wake up message with skip status check invalid send SMS call. + */ + @Test + @SuppressWarnings("unchecked") + public void testSendWakeUpMessageWithSkipStatusCheckInvalidSendSMSCall() { + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamSendSMSUrl", WAM_SEND_SMS_URL); + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamSendSMSSkipStatusCheck", true); + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamAPIMaxRetryCount", TestConstants.THREE); + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, + "wamAPIMaxRetryIntervalMs", TestConstants.THREAD_SLEEP_TIME_5000); + Map additionalParameters = new HashMap<>(); + + Map priorityParam = new HashMap<>(); + priorityParam.put("key", "PRIORITY"); + priorityParam.put("value", "HIGH"); + additionalParameters.put("additionalParameters", priorityParam); + + Map responseData = new HashMap(); + responseData.put(HttpClient.RESPONSE_CODE, "400"); + + try { + ObjectMapper responseMapper = new ObjectMapper(); + JsonNode jsonNode = responseMapper.readTree( + "{\"message\": \"FAILURE\",\"Bad Request\": null,\"failureReason\": " + + "\"Missing vehicleId parameter\",\"data\": null}"); + responseData.put(HttpClient.RESPONSE_JSON, jsonNode); + } catch (IOException e) { + e.printStackTrace(); + } + + Mockito.when(httpClient.invokeJsonResource(Mockito.any(HttpClient.HttpReqMethod.class), + Mockito.any(String.class), Mockito.any(Map.class), Mockito.any(Map.class), + Mockito.any(Integer.class), Mockito.any(Long.class))) + .thenReturn(responseData); + + Map extraParameters = new HashMap<>(); + extraParameters.put(DMAConstants.BIZ_TRANSACTION_ID, "bizTransactionId12345"); + String requestId = "Request12345"; + String vehicleId = ""; + boolean wakeUpStatus = shoulderTapInvokerWAMImpl.sendWakeUpMessage(requestId, vehicleId, extraParameters, spc); + assertEquals(false, wakeUpStatus); + + Mockito.verify(httpClient, Mockito.times(1)).invokeJsonResource(Mockito.any(HttpClient.HttpReqMethod.class), + Mockito.any(String.class), + Mockito.any(Map.class), Mockito.any(Map.class), Mockito.any(Integer.class), Mockito.any(Long.class)); + } + + /** + * Test send wake up message with status as pending. + */ + @SuppressWarnings("unchecked") + @Test + public void testSendWakeUpMessageWithStatusAsPending() { + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamSendSMSUrl", WAM_SEND_SMS_URL); + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamTransactionStatusUrl", WAM_TRANSACTION_STATUS_URL); + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamSendSMSSkipStatusCheck", false); + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, "wamAPIMaxRetryCount", Constants.THREE); + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, + "wamAPIMaxRetryIntervalMs", TestConstants.THREAD_SLEEP_TIME_5000); + + Map additionalParameters = new HashMap<>(); + + Map priorityParam = new HashMap<>(); + priorityParam.put("key", "PRIORITY"); + priorityParam.put("value", "HIGH"); + additionalParameters.put("additionalParameters", priorityParam); + + Map transactionStatusResponseData = getObjectMap(); + + Map sendSMSResponseData = new HashMap(); + sendSMSResponseData.put(HttpClient.RESPONSE_CODE, "202"); + + try { + ObjectMapper responseMapper = new ObjectMapper(); + JsonNode sendSMSJsonNode = responseMapper.readTree( + "{\"message\": \"SUCCESS\",\"failureReasonCode\": null,\"failureReason\": null," + + "\"data\": {\"transactionId\": \"f71e2395-eda2-4de9-ad0a-72e930111736\"}}"); + sendSMSResponseData.put(HttpClient.RESPONSE_JSON, sendSMSJsonNode); + } catch (IOException e) { + e.printStackTrace(); + } + + Mockito.when(httpClient.invokeJsonResource( + Mockito.any(HttpClient.HttpReqMethod.class), Mockito.any(String.class), + Mockito.any(Map.class), Mockito.any(Map.class), + Mockito.any(Integer.class), Mockito.any(Long.class))) + .then(new Answer() { + private int count = 0; + + public Object answer(InvocationOnMock invocation) { + if (++count == 1) { + return transactionStatusResponseData; + } + return sendSMSResponseData; + } + }); + + String bizTransactionId = "bizTransactionId12345"; + Map extraParameters = new HashMap<>(); + extraParameters.put(DMAConstants.BIZ_TRANSACTION_ID, bizTransactionId); + String requestId = "Request12345"; + String vehicleId = "Vehicle12345"; + extraParameters.put(ShoulderTapInvokerWAMImpl.shoulderTapSmsTransactionId, + "f71e2395-eda2-4de9-ad0a-72e930111736"); + boolean wakeUpStatus = shoulderTapInvokerWAMImpl.sendWakeUpMessage(requestId, vehicleId, extraParameters, spc); + assertEquals(false, wakeUpStatus); + + Mockito.verify(httpClient, Mockito.times(1)).invokeJsonResource(Mockito.any(HttpClient.HttpReqMethod.class), + Mockito.any(String.class), + Mockito.any(Map.class), Mockito.any(Map.class), Mockito.any(Integer.class), Mockito.any(Long.class)); + } + + /** + * Gets the object map. + * + * @return the object map + */ + @NotNull + private static Map getObjectMap() { + Map transactionStatusResponseData = new HashMap(); + transactionStatusResponseData.put(HttpClient.RESPONSE_CODE, "200"); + + try { + ObjectMapper responseMapper = new ObjectMapper(); + JsonNode transactionStatusJsonNode = responseMapper.readTree( + "{ \"message\": \"SUCCESS\", \"failureReasonCode\": null, \"failureReason\": null, " + + "\"data\": { \"transactionId\": \"bde7fcda-3b62-46f5-8a24-1bee2e482546\", \"status\": " + + "\"PENDING\", \"transitionDate\": \"2018-10-22 10:17:46.758\", \"originalStatus\": null," + + " \"destinationStatus\": null, \"error_code\": null, \"error_msg\": null, \"smsData\": " + + "{ \"firstShippingDate\": 1540203339389, \"iccid\": \"8932999901000000003\", " + + "\"lastShippingDate\": null, \"text\": " + + "\"MEUCIQCr52F+t/rp6OWzwBw+ZBU/dK/u6h/HhV4hGMkRBkOiCQIgS2zdcsy" + + "/ZYMhxVXtJj9eLF+vP75aN7vQhd31xtCnRKE=\", " + + "\"transactionId\": \"bde7fcda-3b62-46f5-8a24-1bee2e482546\", " + + "\"validityHours\": \"36\", \"priority\": " + + "\"LOW\", \"schemaVersion\": null } } } "); + transactionStatusResponseData.put(HttpClient.RESPONSE_JSON, transactionStatusJsonNode); + } catch (IOException e) { + e.printStackTrace(); + } + return transactionStatusResponseData; + } + + /** + * Test send wake up message with status as error. + */ + @Test + public void testSendWakeUpMessageWithStatusAsError() { + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, + "wamSendSMSUrl", WAM_SEND_SMS_URL); + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, + "wamTransactionStatusUrl", WAM_TRANSACTION_STATUS_URL); + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, + "wamSendSMSSkipStatusCheck", false); + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, + "wamAPIMaxRetryCount", Constants.THREE); + ReflectionTestUtils.setField(shoulderTapInvokerWAMImpl, + "wamAPIMaxRetryIntervalMs", TestConstants.THREAD_SLEEP_TIME_5000); + + + Map additionalParameters = new HashMap<>(); + + Map priorityParam = new HashMap<>(); + priorityParam.put("key", "PRIORITY"); + priorityParam.put("value", "HIGH"); + additionalParameters.put("additionalParameters", priorityParam); + + Map transactionStatusResponseData = getStringObjectMap(); + + Map sendSMSResponseData = new HashMap(); + sendSMSResponseData.put(HttpClient.RESPONSE_CODE, "202"); + + try { + ObjectMapper responseMapper = new ObjectMapper(); + JsonNode sendSMSJsonNode = responseMapper.readTree( + "{\"message\": \"SUCCESS\",\"failureReasonCode\": null,\"failureReason\": null,\"data\": " + + "{\"transactionId\": \"f71e2395-eda2-4de9-ad0a-72e930111736\"}}"); + sendSMSResponseData.put(HttpClient.RESPONSE_JSON, sendSMSJsonNode); + } catch (IOException e) { + e.printStackTrace(); + } + + Mockito.when(httpClient.invokeJsonResource(Mockito.any(HttpClient.HttpReqMethod.class), + Mockito.any(String.class), Mockito.any(Map.class), Mockito.any(Map.class), + Mockito.any(Integer.class), Mockito.any(Long.class))) + .then(new Answer() { + private int count = 0; + + public Object answer(InvocationOnMock invocation) { + if (++count == 1) { + return transactionStatusResponseData; + } + return sendSMSResponseData; + } + }); + + Map extraParameters = new HashMap<>(); + extraParameters.put(DMAConstants.BIZ_TRANSACTION_ID, "bizTransactionId12345"); + String requestId = "Request12345"; + String vehicleId = "Vehicle12345"; + extraParameters.put(ShoulderTapInvokerWAMImpl.shoulderTapSmsTransactionId, + "f71e2395-eda2-4de9-ad0a-72e930111736"); + boolean wakeUpStatus = shoulderTapInvokerWAMImpl.sendWakeUpMessage(requestId, vehicleId, extraParameters, spc); + assertEquals(true, wakeUpStatus); + + Mockito.verify(httpClient, Mockito.times(Constants.TWO)) + .invokeJsonResource(Mockito.any(HttpClient.HttpReqMethod.class), Mockito.any(String.class), + Mockito.any(Map.class), Mockito.any(Map.class), Mockito.any(Integer.class), Mockito.any(Long.class)); + } + + /** + * Gets the string object map. + * + * @return the string object map + */ + @NotNull + private static Map getStringObjectMap() { + Map transactionStatusResponseData = new HashMap(); + transactionStatusResponseData.put(HttpClient.RESPONSE_CODE, "200"); + + try { + ObjectMapper responseMapper = new ObjectMapper(); + JsonNode transactionStatusJsonNode = responseMapper.readTree( + "{ \"message\": \"SUCCESS\", \"failureReasonCode\": null, \"failureReason\": null, \"data\": " + + "{ \"transactionId\": \"bde7fcda-3b62-46f5-8a24-1bee2e482546\", \"status\": \"ERROR\", " + + "\"transitionDate\": \"2018-10-22 10:17:46.758\", " + + "\"originalStatus\": null, \"destinationStatus\": null, " + + "\"error_code\": null, \"error_msg\": null, \"smsData\": " + + "{ \"firstShippingDate\": 1540203339389, " + + "\"iccid\": \"8932999901000000003\", \"lastShippingDate\": null, \"text\": " + + "\"MEUCIQCr52F+t/rp6OWzwBw+ZBU/dK/u6h/HhV4hGMkRBkOiCQIgS2zdcsy" + + "/ZYMhxVXtJj9eLF+vP75aN7vQhd31xtCnRKE=\", " + + "\"transactionId\": \"bde7fcda-3b62-46f5-8a24-1bee2e482546\", " + + "\"validityHours\": \"36\", \"priority\": " + + "\"LOW\", \"schemaVersion\": null } } } "); + transactionStatusResponseData.put(HttpClient.RESPONSE_JSON, transactionStatusJsonNode); + } catch (IOException e) { + e.printStackTrace(); + } + return transactionStatusResponseData; + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapRetryRecordDAOCacheImplTest.java b/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapRetryRecordDAOCacheImplTest.java new file mode 100644 index 0000000..332ff88 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/dma/shouldertap/ShoulderTapRetryRecordDAOCacheImplTest.java @@ -0,0 +1,85 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.dma.shouldertap; + +import org.eclipse.ecsp.analytics.stream.base.stores.CacheBypass; +import org.eclipse.ecsp.cache.IgniteCache; +import org.eclipse.ecsp.stream.dma.dao.ShoulderTapRetryRecordDAOCacheImpl; +import org.eclipse.ecsp.utils.metrics.InternalCacheGuage; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + + +/** + * {@link ShoulderTapRetryRecordDAOCacheImplTest} test class for {@link ShoulderTapRetryRecordDAOCacheImpl}. + */ +public class ShoulderTapRetryRecordDAOCacheImplTest { + + /** The cache. */ + @InjectMocks + private ShoulderTapRetryRecordDAOCacheImpl cache = new ShoulderTapRetryRecordDAOCacheImpl(); + + /** The bypass. */ + @Mock + private CacheBypass bypass; + + /** The ignite cache. */ + @Mock + private IgniteCache igniteCache; + + /** The guage. */ + @Mock + private InternalCacheGuage guage; + + /** + * Test initialize. + */ + @Test + public void testInitialize() { + MockitoAnnotations.openMocks(this); + cache.setServiceName("ecall"); + cache.initialize("task_Id"); + Assert.assertEquals("ecall", (String) ReflectionTestUtils.getField(cache, "serviceName")); + } +} diff --git a/src/test/java/org/eclipse/ecsp/stream/scheduler/SchedulerAgentIntegrationTest.java b/src/test/java/org/eclipse/ecsp/stream/scheduler/SchedulerAgentIntegrationTest.java new file mode 100644 index 0000000..956e5be --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/stream/scheduler/SchedulerAgentIntegrationTest.java @@ -0,0 +1,504 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package org.eclipse.ecsp.stream.scheduler; + + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.ByteArrayDeserializer; +import org.apache.kafka.common.serialization.ByteArraySerializer; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.StreamsConfig; +import org.apache.kafka.streams.processor.api.Record; +import org.eclipse.ecsp.analytics.stream.base.IgniteEventStreamProcessor; +import org.eclipse.ecsp.analytics.stream.base.Launcher; +import org.eclipse.ecsp.analytics.stream.base.StreamProcessingContext; +import org.eclipse.ecsp.analytics.stream.base.constants.TestConstants; +import org.eclipse.ecsp.analytics.stream.base.stores.HarmanPersistentKVStore; +import org.eclipse.ecsp.analytics.stream.base.utils.Constants; +import org.eclipse.ecsp.analytics.stream.base.utils.KafkaStreamsApplicationTestBase; +import org.eclipse.ecsp.domain.EventID; +import org.eclipse.ecsp.domain.SpeedV1_0; +import org.eclipse.ecsp.domain.Version; +import org.eclipse.ecsp.entities.AbstractIgniteEvent; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.eclipse.ecsp.entities.IgniteEventImpl; +import org.eclipse.ecsp.events.scheduler.CreateScheduleEventData; +import org.eclipse.ecsp.events.scheduler.DeleteScheduleEventData; +import org.eclipse.ecsp.key.IgniteKey; +import org.eclipse.ecsp.key.IgniteStringKey; +import org.eclipse.ecsp.transform.GenericIgniteEventTransformer; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.TimeUnit; + +import static org.eclipse.ecsp.analytics.stream.base.PropertyNames.SCHEDULER_AGENT_TOPIC_NAME; +import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + + +/** + * class {@link SchedulerAgentIntegrationTest} extends {@link KafkaStreamsApplicationTestBase}. + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = Launcher.class) +@TestPropertySource("/scheduler-agent-test.properties") +public class SchedulerAgentIntegrationTest extends KafkaStreamsApplicationTestBase { + + /** The test event type. */ + private static String testEventType; + + /** The service name. */ + @Value("${service.name}") + private String serviceName; + + /** The source topic. */ + @Value("${source.topic.name}") + private String sourceTopic; + + /** The scheduler agent topic. */ + @Value("${" + SCHEDULER_AGENT_TOPIC_NAME + "}") + private String schedulerAgentTopic; + + /** + * setUp(). + * + * @throws Exception Exception + * @throws MqttException MqttException + */ + @Before + public void setUp() throws Exception, MqttException { + createTopics(sourceTopic, schedulerAgentTopic); + super.setup(); + ksProps.remove(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG); + ksProps.remove(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG); + ksProps.remove(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG); + ksProps.remove(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG); + launchApplication(); + await().atMost(TestConstants.LONG_30000, TimeUnit.MILLISECONDS); + } + + /** + * Test if create schedule event is forwarded to scheduler topic. + */ + @Test + public void testCreateScheduleEvent() { + try { + Properties producerProps = new Properties(); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); + + CreateScheduleEventData createScheduleEventData = new CreateScheduleEventData(); + long recurrenceDelayMs = Constants.THREAD_SLEEP_TIME_60000; + long initialDelayMs = Constants.THREAD_SLEEP_TIME_60000; + createScheduleEventData.setRecurrenceDelayMs(recurrenceDelayMs); + createScheduleEventData.setInitialDelayMs(initialDelayMs); + int times = 1; + createScheduleEventData.setFiringCount(times); + String service = "ECall"; + createScheduleEventData.setServiceName(service); + String notificationPayload = "executeMonthlyReportUpload"; + createScheduleEventData.setNotificationPayload(notificationPayload.getBytes()); + String notificationTopic = "scheduleNotificationTopic"; + createScheduleEventData.setNotificationTopic(notificationTopic); + + IgniteStringKey key = new IgniteStringKey(); + key.setKey("111"); + createScheduleEventData.setNotificationKey(key); + IgniteEventImpl igniteEvent = new IgniteEventImpl(); + igniteEvent.setEventData(createScheduleEventData); + + byte[] eventData = getEventBlob(igniteEvent); + + testEventType = EventID.CREATE_SCHEDULE_EVENT; + String vehicleId = getIgniteEvent(igniteEvent); + sendMessages(sourceTopic, producerProps, + Arrays.asList(vehicleId.getBytes(), eventData)); + createConsumerProps(); + await().atMost(TestConstants.LONG_30000, TimeUnit.MILLISECONDS); + List> scheduleNotificationAckRecords = getKeyValueRecords(schedulerAgentTopic, + consumerProps, 1, Constants.THREAD_SLEEP_TIME_10000); + assertEquals(1, scheduleNotificationAckRecords.size()); + KeyValue value = scheduleNotificationAckRecords.get(0); + byte[] eventValue = value.value; + IgniteEvent receivedEvent = getIgniteEvent(eventValue); + + assertEquals(EventID.CREATE_SCHEDULE_EVENT, + receivedEvent.getEventId()); + + CreateScheduleEventData receivedEventData = (CreateScheduleEventData) receivedEvent.getEventData(); + assertEquals(EventID.CREATE_SCHEDULE_EVENT, + receivedEvent.getEventId()); + assertEquals(initialDelayMs, + receivedEventData.getInitialDelayMs()); + assertEquals(recurrenceDelayMs, receivedEventData.getRecurrenceDelayMs()); + assertTrue(Arrays.equals(notificationPayload.getBytes(), + receivedEventData.getNotificationPayload())); + assertEquals(notificationTopic, + receivedEventData.getNotificationTopic()); + } catch (Exception e) { + e.printStackTrace(); + } + shutDownApplication(); + } + + /** + * Creates the consumer props. + */ + private void createConsumerProps() { + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + ByteArrayDeserializer.class); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + ByteArrayDeserializer.class); + consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, + "localhost:9092"); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, + "schedule-agent-consumer-group"); + } + + /** + * Test if delete schedule event is forwarded to scheduler topic. + */ + @Test + public void testDeleteScheduleEvent() { + try { + Properties producerProps = new Properties(); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); + + IgniteEventImpl igniteEvent = new IgniteEventImpl(); + igniteEvent.setEventId(EventID.DELETE_SCHEDULE_EVENT); + igniteEvent.setTimestamp(System.currentTimeMillis()); + igniteEvent.setRequestId("Request123"); + igniteEvent.setCorrelationId("1234"); + igniteEvent.setBizTransactionId("Biz1234"); + String messageId = "Message1222"; + igniteEvent.setMessageId(messageId); + igniteEvent.setSourceDeviceId("12345"); + String vehicleId = "Vehicle12345"; + igniteEvent.setVehicleId(vehicleId); + igniteEvent.setVersion(Version.V1_0); + + String scheduleId = "scheduleId123"; + DeleteScheduleEventData deleteScheduleEventData = new DeleteScheduleEventData(); + deleteScheduleEventData.setScheduleId(scheduleId); + + igniteEvent.setEventData(deleteScheduleEventData); + + byte[] eventData = getEventBlob(igniteEvent); + + testEventType = EventID.DELETE_SCHEDULE_EVENT; + sendMessages(sourceTopic, producerProps, + Arrays.asList(vehicleId.getBytes(), eventData)); + await().atMost(TestConstants.LONG_30000, TimeUnit.MILLISECONDS); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + ByteArrayDeserializer.class); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + ByteArrayDeserializer.class); + consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, + "localhost:9092"); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, + "schedule-agent-consumer-group"); + + List> scheduleNotificationAckRecords = + getKeyValueRecords(schedulerAgentTopic, consumerProps, 1, Constants.THREAD_SLEEP_TIME_5000); + + assertEquals(1, scheduleNotificationAckRecords.size()); + + KeyValue value = scheduleNotificationAckRecords.get(0); + byte[] eventValue = value.value; + IgniteEvent receivedEvent = getIgniteEvent(eventValue); + + DeleteScheduleEventData receivedEventData = (DeleteScheduleEventData) receivedEvent.getEventData(); + + assertEquals(EventID.DELETE_SCHEDULE_EVENT, + receivedEvent.getEventId()); + assertEquals(scheduleId, + receivedEventData.getScheduleId()); + } catch (Exception e) { + e.printStackTrace(); + } + shutDownApplication(); + } + + /** + * Test if non schedule event is not forwarded to scheduler topic. + */ + @Test + public void testNonScheduleEvent() { + try { + Properties producerProps = new Properties(); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class); + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); + + IgniteEventImpl igniteEvent = new IgniteEventImpl(); + igniteEvent.setEventId(EventID.SPEED); + igniteEvent.setTimestamp(System.currentTimeMillis()); + igniteEvent.setRequestId("Request123"); + igniteEvent.setCorrelationId("1234"); + igniteEvent.setBizTransactionId("Biz1234"); + String messageId = "Message1222"; + igniteEvent.setMessageId(messageId); + igniteEvent.setSourceDeviceId("12345"); + String vehicleId = "Vehicle12345"; + igniteEvent.setVehicleId(vehicleId); + igniteEvent.setVersion(Version.V1_0); + SpeedV1_0 speed = new SpeedV1_0(); + speed.setValue(TestConstants.DOUBLE_TWENTY); + igniteEvent.setEventData(speed); + byte[] eventData = getEventBlob(igniteEvent); + testEventType = EventID.SPEED; + sendMessages(sourceTopic, producerProps, + Arrays.asList(vehicleId.getBytes(), eventData)); + await().atMost(TestConstants.LONG_30000, TimeUnit.MILLISECONDS); + + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, + ByteArrayDeserializer.class); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, + ByteArrayDeserializer.class); + consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, + "localhost:9092"); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, + "schedule-agent-consumer-group"); + + List> scheduleNotificationAckRecords = + getKeyValueRecords(schedulerAgentTopic, consumerProps, 1, + Constants.THREAD_SLEEP_TIME_5000); + + assertEquals(0, scheduleNotificationAckRecords.size()); + } catch (Exception e) { + e.printStackTrace(); + } + shutDownApplication(); + } + + /** + * Gets the event blob. + * + * @param event the event + * @return the event blob + * @throws JsonProcessingException the json processing exception + */ + private byte[] getEventBlob(IgniteEventImpl event) throws JsonProcessingException { + GenericIgniteEventTransformer eventTransformer = new GenericIgniteEventTransformer(); + byte[] eventData = eventTransformer.toBlob(event); + + return eventData; + } + + /** + * Gets the ignite event. + * + * @param eventData the event data + * @return the ignite event + * @throws JsonParseException the json parse exception + * @throws JsonMappingException the json mapping exception + * @throws IOException Signals that an I/O exception has occurred. + */ + private IgniteEvent getIgniteEvent(byte[] eventData) throws JsonParseException, JsonMappingException, IOException { + GenericIgniteEventTransformer eventTransformer = new GenericIgniteEventTransformer(); + return eventTransformer.fromBlob(eventData, Optional.empty()); + } + + /** + * Gets the ignite event. + * + * @param igniteEvent the ignite event + * @return the ignite event + */ + private static String getIgniteEvent(IgniteEventImpl igniteEvent) { + igniteEvent.setEventId(EventID.CREATE_SCHEDULE_EVENT); + igniteEvent.setTimestamp(System.currentTimeMillis()); + igniteEvent.setRequestId("Request123"); + igniteEvent.setCorrelationId("1234"); + igniteEvent.setBizTransactionId("Biz1234"); + String messageId = "Message1222"; + igniteEvent.setMessageId(messageId); + igniteEvent.setSourceDeviceId("12345"); + String vehicleId = "Vehicle12345"; + igniteEvent.setVehicleId(vehicleId); + igniteEvent.setVersion(Version.V1_0); + return vehicleId; + } + + /** + * inner class {@link SchedulerAgentTestStreamProcessor} implements {@link IgniteEventStreamProcessor}. + */ + public static class SchedulerAgentTestStreamProcessor implements IgniteEventStreamProcessor { + + /** The spc. */ + protected StreamProcessingContext, IgniteEvent> spc; + + /** + * Inits the. + * + * @param spc the spc + */ + @Override + public void init(StreamProcessingContext, IgniteEvent> spc) { + this.spc = spc; + + } + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "scheduler-agent-test-sp"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + public void process(Record, IgniteEvent> kafkaRecord) { + IgniteEvent value = kafkaRecord.value(); + if (!value.getEventId().equals(EventID.DEVICEMESSAGEFAILURE)) { + ((AbstractIgniteEvent) value).setDeviceRoutable(true); + kafkaRecord.withValue(value); + spc.forward(kafkaRecord); + } + } + + /** + * Punctuate. + * + * @param timestamp the timestamp + */ + @Override + public void punctuate(long timestamp) { + } + + /** + * Close. + */ + @Override + public void close() { + } + + /** + * Config changed. + * + * @param props the props + */ + @Override + public void configChanged(Properties props) { + } + + /** + * Creates the state store. + * + * @return the harman persistent KV store + */ + @SuppressWarnings("rawtypes") + @Override + public HarmanPersistentKVStore createStateStore() { + return null; + } + + } + + /** + * inner class {@link TestStreamPostProcessor} extends {@link SchedulerAgentTestStreamProcessor}. + */ + public static class TestStreamPostProcessor extends SchedulerAgentTestStreamProcessor { + + /** + * Name. + * + * @return the string + */ + @Override + public String name() { + return "test-post-sp"; + } + + /** + * Process. + * + * @param kafkaRecord the kafka record + */ + @Override + @Test + public void process(Record, IgniteEvent> kafkaRecord) { + IgniteEvent value = kafkaRecord.value(); + if (!value.getEventId().equals(EventID.DEVICEMESSAGEFAILURE)) { + ((AbstractIgniteEvent) value).setDeviceRoutable(true); + + if (EventID.CREATE_SCHEDULE_EVENT.equals(testEventType)) { + assertEquals(EventID.CREATE_SCHEDULE_EVENT, + value.getEventId()); + } else if (EventID.DELETE_SCHEDULE_EVENT.equals(testEventType)) { + assertEquals(EventID.DELETE_SCHEDULE_EVENT, + value.getEventId()); + } else { + assertEquals(EventID.SPEED, + value.getEventId()); + } + kafkaRecord.withValue(value); + spc.forward(kafkaRecord); + } + } + } +} diff --git a/src/test/java/redis/embedded/RedisCluster408.java b/src/test/java/redis/embedded/RedisCluster408.java new file mode 100644 index 0000000..2a9625f --- /dev/null +++ b/src/test/java/redis/embedded/RedisCluster408.java @@ -0,0 +1,104 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package redis.embedded; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + + +/** + * class RedisCluster408 extends RedisCluster. + */ +public class RedisCluster408 extends RedisCluster { + + /** + * Instantiates a new redis cluster 408. + * + * @param rc the rc + */ + public RedisCluster408(RedisCluster rc) { + this(rc.sentinels(), rc.servers()); + } + + /** + * Instantiates a new redis cluster 408. + * + * @param sentinels the sentinels + * @param servers the servers + */ + public RedisCluster408(List sentinels, List servers) { + super(transformSentinels(sentinels), transformServers(servers)); + } + + /** + * Transform servers. + * + * @param servers the servers + * @return the list + */ + private static List transformServers(List servers) { + return servers.stream().map(s -> createNewRedisServer408(s)).collect(Collectors.toList()); + } + + /** + * Creates the new redis server 408. + * + * @param s the s + * @return the redis server 408 + */ + private static RedisServer408 createNewRedisServer408(Redis s) { + try { + return new RedisServer408((RedisServer) s); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Transform sentinels. + * + * @param sentinels the sentinels + * @return the list + */ + private static List transformSentinels(List sentinels) { + return sentinels.stream().map(s -> new RedisSentinel408((RedisSentinel) s)).collect(Collectors.toList()); + } +} diff --git a/src/test/java/redis/embedded/RedisSentinel408.java b/src/test/java/redis/embedded/RedisSentinel408.java new file mode 100644 index 0000000..31077b6 --- /dev/null +++ b/src/test/java/redis/embedded/RedisSentinel408.java @@ -0,0 +1,67 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package redis.embedded; + + +/** + * class RedisSentinel408 extends AbstractRedisInstance. + */ +public class RedisSentinel408 extends AbstractRedisInstance { + + /** + * Instantiates a new redis sentinel 408. + * + * @param actual the actual + */ + public RedisSentinel408(RedisSentinel actual) { + super(actual.ports().get(0)); + this.args = actual.args; + } + + /** + * Redis ready pattern. + * + * @return the string + */ + @Override + protected String redisReadyPattern() { + return ".*Sentinel ID is.*"; + } +} diff --git a/src/test/java/redis/embedded/RedisServer408.java b/src/test/java/redis/embedded/RedisServer408.java new file mode 100644 index 0000000..1d4fe81 --- /dev/null +++ b/src/test/java/redis/embedded/RedisServer408.java @@ -0,0 +1,84 @@ +/* + * + * + * ****************************************************************************** + * + * Copyright (c) 2023-24 Harman International + * + * + * + * 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. + * + * + * + * SPDX-License-Identifier: Apache-2.0 + * + * ******************************************************************************* + * + * + */ + +package redis.embedded; + +import java.io.IOException; + + +/** + * Supporting class for Redis version 4.0.8. + * + * @author ssasidharan + */ +public class RedisServer408 extends RedisServer { + + /** + * Instantiates a new redis server 408. + * + * @param redisExecProvider the redis exec provider + * @param port the port + * @throws IOException Signals that an I/O exception has occurred. + */ + public RedisServer408(RedisExecProvider redisExecProvider, Integer port) throws IOException { + super(redisExecProvider, port); + } + + /** + * Instantiates a new redis server 408. + * + * @param r the r + * @throws IOException Signals that an I/O exception has occurred. + */ + public RedisServer408(RedisServer r) throws IOException { + super(r.ports().get(0)); + this.args = r.args; + } + + /** + * Redis ready pattern. + * + * @return the string + */ + @Override + protected String redisReadyPattern() { + return ".*Ready to accept connections.*"; + } + +} diff --git a/src/test/resources/application-base-test.properties b/src/test/resources/application-base-test.properties new file mode 100644 index 0000000..9599c2e --- /dev/null +++ b/src/test/resources/application-base-test.properties @@ -0,0 +1,172 @@ +pre.processors=org.eclipse.ecsp.analytics.stream.base.processors.TaskContextInitializer,org.eclipse.ecsp.analytics.stream.base.processors.ProtocolTranslatorPreProcessor,org.eclipse.ecsp.analytics.stream.base.processors.DeviceMessagingAgentPreProcessor +#the sinker node will always be the last processor in the chain +#Forexample: post.processors=org.eclipse.ecsp.analytics.stream.base.processors.DeviceMessagingAgent,org.eclipse.ecsp.analytics.stream.base.processors.ProtocolTranslatorPostProcessor +#In the above the last processor is ProtocolTranslatorPostProcessor, process will be the sinker for all of the sink topics +post.processors=org.eclipse.ecsp.analytics.stream.base.processors.SchedulerAgentPostProcessor,org.eclipse.ecsp.analytics.stream.base.processors.DeviceMessagingAgentPostProcessor,org.eclipse.ecsp.analytics.stream.base.processors.ProtocolTranslatorPostProcessor +launcher.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.KafkaStreamsLauncher +#How the processors will be discovered. (SPIDiscovery, Property based discovery etc) +discovery.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl +source.topic.name=raw-events +launcher.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.KafkaStreamsLauncher +shutdown.hook.wait.ms=180000 +log.counts=false +application.id=sample +kafka.ssl.enable=false +kafka.rebalance.time.mins=10 +kafka.close.timeout.secs=10 +kafka.client.keystore=keystore.jks +kafka.client.keystore.password=**** +kafka.client.key.password=*** +kafka.client.truststore=truststore.jks +kafka.client.truststore.password=**** +kafka.ssl.client.auth=true +kafka.consumer.topic=testtopic +kafka.consumer.poll=10 +kafka.partitioner=org.eclipse.ecsp.analytics.stream.base.dao.impl.MockKafkaPartitioner +kafka.device.events.sync.puts=true +exec.shutdown.hook=false +#Replication factor for change log +replication.factor=2 +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=admin +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=30000 +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +mqtt.broker.url=tcp://127.0.0.1:1883 +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.topic.to.device.infix=/2d +mqtt.service.topic.name=test +mqtt.conn.retry.count=3 +mqtt.conn.retry.interval=1000 +messageid.generator.type=org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator +event.transformer.classes=genericIgniteEventTransformer +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +dma.event.header.updation.type=messageId +dma.service.max.retry=3 +dma.service.retry.interval.millis=60000 +dma.service.retry.min.threshold.millis=10000 +shoulder.tap.max.retry=3 +shoulder.tap.retry.interval.millis=60000 +shoulder.tap.retry.min.threshold.millis=10000 +dma.auto.offset.reset=latest +#Shoulder tap invoker implementation class. +#Possible values: +#1) Default, Dummy (no invocation) Impl - org.eclipse.ecsp.stream.dma.shouldertap.DummyShoulderTapInvokerImpl +#2) WAM API endpoint - org.eclipse.ecsp.stream.dma.shouldertap.ShoulderTapInvokerWAMImpl +#3) Vehicle Notification service - org.eclipse.ecsp.stream.dma.shouldertap.ShoulderTapInvokerVehicleNotificationImpl +dma.shoulder.tap.invoker.impl.class=org.eclipse.ecsp.stream.dma.shouldertap.DummyShoulderTapInvokerImpl +dma.shoulder.tap.invoker.wam.url=http://wamserver/api +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=10000 +redis.netty.threads=0 +redis.decode.in.executor=true +#IgniteCache Lua script scan limit +redis.scan.limit=1000 +#IgniteCache Lua script for Regex scan +redis.regex.scan.filename=scanregex.txt +#IgniteCache Async operation batch pipeline size +redis.pipeline.size=2 +#VehicleId to DeviceId mapping impl class +device.to.vehicle.mapper.impl=org.eclipse.ecsp.analytics.stream.d2v.VehicleToDeviceSingleIdentityMapper +#Vehicle Profile Service URL +http.vp.url=http://internal-andromeda-vehicle-profile-1184319113.us-east-1.elb.amazonaws.com/v1.0/vehicleProfiles/ +// Http client properties +http.connection.timeout.in.sec=120 +http.read.timeout.in.sec=10 +http.write.timeout.in.sec=10 +http.keep.alive.duration.in.sec=120 +http.max.idle.connections=20 +http.vp.auth.header=Authentication +http.vp.service.user=test +http.vp.service.password=pass +// Vehicle profile service URL +http.vp.retry.interval.in.millis=5000 +http.vp.max.retry.count=3 +#Scheduler Agent Stream Processor +scheduler.agent.topic.name=scheduler +start.device.status.consumer=true +prometheus.agent.port=9100 +metrics.prometheus.enabled=false +prometheus.histogram.buckets=0.005, 0.010, 0.015, 0.020, 0.025, 0.030, 0.080, 0.1, 0.2, 0.3 +messageid.generator.type=org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator +#Health framework properties +health.mqtt.monitor.enabled=false +health.mqtt.monitor.restart.on.failure=true +health.mongo.monitor.enabled=false +health.mongo.needs.restart.on.failure=true +health.kafka.consumer.group.monitor.enabled=false +health.kafka.consumer.group.needs.restart.on.failure=false +health.device.status.backdoor.monitor.enabled=false +health.device.status.backdoor.monitor.restart.on.failure=false +health.kafka.topics.monitor.enabled=false +health.kafka.topics.monitor.needs.restart.on.failure=true +health.redis.monitor.enabled=false +health.redis.needs.restart.on.failure=true +ignore.bootstrap.failure.monitors=KAFKA_CONSUMER_GROUP_HEALTH_MONITOR,DEVICE_STATUS_BACKDOOR_HEALTH_MONITOR +sp.restart.on.failure=false +sp.restart.wait.time.in.millis=10000 +kafka.topics.file.path=/data/topics.txt +expected.min.isr=1 +health.service.failure.retry.thrshold=20 +health.service.failure.retry.interval.millis=500 +health.service.retry.interval.millis=100 +health.service.executor.shutdown.millis=2000 +health.service.executor.initial.delay=120000 +#CacheBypass queue's capacity +cache.bypass.queue.capacity=100000 +cache.bypass.thread.initial.delay=0 +cache.bypass.thread.delay=60000 +cache.bypass.thread.shutdown.wait.time=2000 +print.threads.metadata.enabled=false +print.threads.metadata.interval.ms=30000 +stream.threads.active.states=CREATED,STARTING,PARTITIONS_REVOKED,PARTITIONS_ASSIGNED,RUNNING,PENDING_SHUTDOWN +stream.threads.dead.states=DEAD +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=false +scheduler.enabled=false +#Name of the class which is implementing IgnitePlatform interface to provide platformID +ignite.platform.service.impl.class.name= + +mqtt.topic.name.generator.impl.class.name=org.eclipse.ecsp.analytics.stream.base.utils.DefaultMqttTopicNameGeneratorImpl +' diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..966d5d0 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,2 @@ +#Dummy file to pass test cases. Required for satisfying the VautlPropertySourceFactory +k=v diff --git a/src/test/resources/aws-props.properties b/src/test/resources/aws-props.properties new file mode 100644 index 0000000..5958cac --- /dev/null +++ b/src/test/resources/aws-props.properties @@ -0,0 +1,12 @@ +#DynamoDB properties +dynamodb.service.endpoint=http://localhost:8000 +dynamodb.region=us-east-1 +dynamodb.alert.msgs.table=alerts +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region + + + + + + diff --git a/src/test/resources/backdoor-dao-test.properties b/src/test/resources/backdoor-dao-test.properties new file mode 100644 index 0000000..793bc27 --- /dev/null +++ b/src/test/resources/backdoor-dao-test.properties @@ -0,0 +1,81 @@ +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +#redis.read.mode=SLAVEdff +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=1000 +redis.scan.limit=10 +redis.regex.scan.filename=scanregex.txt +redis.pipeline.size=2 +redis.netty.threads=0 +redis.decode.in.executor=true +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=admin +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=300000 +service.name=Ecall +messageid.generator.type=org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +mqtt.broker.url=tcp://127.0.0.1:1883 +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.service.topic.name=test +bootstrap.servers=localhost:9092 +event.transformer.classes=genericIgniteEventTransformer +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +#Serialization class +ingestion.serializer.class=org.eclipse.ecsp.serializer.IngestionSerializerFstImpl +kafka.ssl.enable=false +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +dma.auto.offset.reset=earliest +dma.service.max.retry=3 +dma.service.retry.interval.millis=5000 +dma.service.retry.min.threshold.millis=1000 +prometheus.agent.port=9100 +metrics.prometheus.enabled=false +prometheus.histogram.buckets=0.005, 0.010, 0.015, 0.020, 0.025, 0.030, 0.080, 0.1, 0.2, 0.3 +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=false +scheduler.enabled=false \ No newline at end of file diff --git a/src/test/resources/cache-bypass-test.properties b/src/test/resources/cache-bypass-test.properties new file mode 100644 index 0000000..ca604bf --- /dev/null +++ b/src/test/resources/cache-bypass-test.properties @@ -0,0 +1,86 @@ +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=1 +redis.retry.interval=10 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=1000 +redis.scan.limit=10 +redis.regex.scan.filename=scanregex.txt +redis.pipeline.size=2 +redis.netty.threads=0 +redis.decode.in.executor=true +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=admin +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=300000 +service.name=Ecall +messageid.generator.type=org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +mqtt.broker.url=tcp://127.0.0.1:1883 +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.service.topic.name=test +bootstrap.servers=localhost:9092 +event.transformer.classes=genericIgniteEventTransformer +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +#Serialization class +ingestion.serializer.class=org.eclipse.ecsp.serializer.IngestionSerializerFstImpl +kafka.ssl.enable=false +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +dma.auto.offset.reset=earliest +dma.service.max.retry=3 +dma.service.retry.interval.millis=5000 +dma.service.retry.min.threshold.millis=1000 +prometheus.agent.port=9100 +metrics.prometheus.enabled=false +prometheus.histogram.buckets=0.005, 0.010, 0.015, 0.020, 0.025, 0.030, 0.080, 0.1, 0.2, 0.3 +kafka.streams.offset.persistence.enabled=false +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +#CacheBypass's properties +cache.bypass.queue.initial.capacity=10000 +cache.bypass.threads.shutdown.wait.time=2000 +dma.num.cache.bypass.threads=2 +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=false +scheduler.enabled=false \ No newline at end of file diff --git a/src/test/resources/client-truststore.jks b/src/test/resources/client-truststore.jks new file mode 100644 index 0000000000000000000000000000000000000000..fd0013fee7d0b7512307a799286d870468b790c6 GIT binary patch literal 4061 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 literal 0 HcmV?d00001 diff --git a/src/test/resources/client.jks b/src/test/resources/client.jks new file mode 100644 index 0000000000000000000000000000000000000000..2d7366dd7f6c3420a00fe2b4b50ac257142f40c7 GIT binary patch literal 1483 zcmezO_TO6u1_mY|W(3om#i>PQsYThTl|YfWB^FK!46G4)rUsS_46L07O{}d3P0YCq zn3))vm{^SX%HA08vT4`a|MWu%PK%;=_xP;j}5{q&Z^I#geg*hGb5_2j`GLwr9 zMGS;MikOADeFH;66oSEyHINhMH8eM{G%_?aG&VIbjuPiJF*PtUFoSXj#!*d7O2~o3 z$jZRn#Kg~F(8R>W)WpQda5Urjh8C^wFMl0AuV&!Elkg@a#a-Mb-gft7X*<)>69os$ zuaqC`)T?)?lzV#a=x?KvuIZsZoDJD)8(4xnZQnax&ux2Kp0(@Rvo?c>-WQAklGO$)GuTZ7&NqunPY_|& zkm)~C`6}{*t@9W6L%vfLCft_inALKwAkn1N(sWM9+kS<`Z547V?;@Ta_&g=FW~xii zrCG+iIP4t+`oFlz9f8xn3S;mg-FS<-*_drSpq?Zrsw&pHl*~w?5XXs#7jb|NOJ|#dXeZ zjk~k<9s001M(q!!((sh1=%;?TtGH7x`@StZoBRDP{(G0cI4ra` zx|CDjclBrA)gLW+Ym!XE_sE=i>(i;dO5cCC{BtjJYgLDxU;8It@z(io&9J=UQ9|!CL%_LgTc@I_U>N1Z>szB#xKXG>~#A) ziG5-6%UMyqJP63(n`Q{IiK|?!6tqO|Qi*-tsdlT(D&0 zNukezCTc;F&tojE%7i)@dur8 z()f8uT|xASm#)gi_gQ>Q>V-XvKk0w3YA!u|#VVHL;+=W2$J&y0&SeOtYzo@1em~CF z{j$WS1D@AD$z6%7ip{c@Yu|K+^6sX{$1I~XT1uKTJavG(-TNs~%) z${jAdGaTQQ5)w1_-_P(ZE4aUy>K^0qj*~Fh=N$I*@KnWuLpmFcuh-OFTRl~7{$JgV z_nVIkCnUUdO^b8ayWPy)e3fs)to@a$VKqB`<;)Um2s!V+?T6K&&PVqyPi}4ax@70u zZ^xU?{<~q&GVAlZK8}y?_FUcj(=y}wHHA@xixQj literal 0 HcmV?d00001 diff --git a/src/test/resources/dlq-reprocessing-test.properties b/src/test/resources/dlq-reprocessing-test.properties new file mode 100644 index 0000000..876d268 --- /dev/null +++ b/src/test/resources/dlq-reprocessing-test.properties @@ -0,0 +1,63 @@ +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.mode=SINGLE +redis.single.endpoint=127.0.0.1:6379 +redis.replica.endpoints=127.0.0.1:6379,127.0.0.1:6380 +redis.cluster.endpoints=127.0.0.1:6379,127.0.0.1:6380 +redis.sentinel.endpoints=127.0.0.1:26379,127.0.0.1:26380,127.0.0.1:26381 +redis.master.name=mymaster +redis.master.pool.max.size=5 +redis.master.idle.min=1 +redis.slave.pool.max.size=5 +redis.slave.idle.min=1 +redis.scan.interval=2000 +redis.database=0 +redis.max.pool.size=5 +redis.min.idle=1 +redis.netty.threads=0 +redis.decode.in.executor=true +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=admin +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=300000 +service.name=Ecall +messageid.generator.type=org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator +sequence.block.config.maxvalue=500 +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +mqtt.broker.url=tcp://127.0.0.1:1883 +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.service.topic.name=test +bootstrap.servers=localhost:9092 +kafka.ssl.enable=false +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +start.device.status.consumer=false +prometheus.agent.port=9100 +metrics.prometheus.enabled=false +prometheus.histogram.buckets=0.005, 0.010, 0.015, 0.020, 0.025, 0.030, 0.080, 0.1, 0.2, 0.3 +sequence.block.config.maxvalue=500 +## Reprocessing in DLQ test +dlq.max.retry.count=5 +dlq.reprocessing.enabled=true +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +#Enabling DMA/DFF/SCHEDULER Module Configurations For StreamBase +dma.enabled=false +scheduler.enabled=false \ No newline at end of file diff --git a/src/test/resources/dma-backdoor-consumer-test.properties b/src/test/resources/dma-backdoor-consumer-test.properties new file mode 100644 index 0000000..bbd5fb8 --- /dev/null +++ b/src/test/resources/dma-backdoor-consumer-test.properties @@ -0,0 +1,103 @@ +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=1000 +redis.scan.limit=10 +redis.regex.scan.filename=scanregex.txt +redis.pipeline.size=2 +redis.netty.threads=0 +redis.decode.in.executor=true + +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=admin +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=300000 +service.name=Ecall +messageid.generator.type=org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator +mongodb.vault.enabled=false +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region + +mqtt.broker.url=tcp://127.0.0.1:1883 +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name = 8146ccc47e84ac1e43de623403133d55 +mqtt.user.password = simulator16 +mqtt.service.topic.name=test +mqtt.client= paho +bootstrap.servers = localhost:9092 + +event.transformer.classes=genericIgniteEventTransformer +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +#Serialization class +ingestion.serializer.class=org.eclipse.ecsp.serializer.IngestionSerializerFstImpl + +#SSL Configuration +kafka.ssl.enable=true +kafka.ssl.client.auth=required +kafka.client.keystore=src/test/resources/kafka.client.keystore.jks +kafka.client.keystore.password=password +kafka.client.key.password=password +kafka.client.truststore=src/test/resources/kafka.client.truststore.jks +kafka.client.truststore.password=password + +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +dma.auto.offset.reset=earliest + +dma.service.max.retry=-1 +dma.service.retry.interval.millis=5000 +dma.service.retry.min.threshold.millis=1000 +vault.enabled=false +metrics.prometheus.enabled=false +health.vault.monitor.enabled=false +health.vault.needs.restart.on.failure=false +#FilterDMOfflineBufferEntry Impl class +filter.dmoffline.buffer.entry.impl=org.eclipse.ecsp.stream.dma.handler.NoFilterDMOfflineBufferEntryImpl +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +enable.input.validation=false + +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=true +dff.enabled=false +scheduler.enabled=false +device.message.feedback.topic=test-feedback \ No newline at end of file diff --git a/src/test/resources/dma-connection-status-handler-test2.properties b/src/test/resources/dma-connection-status-handler-test2.properties new file mode 100644 index 0000000..9f1b5d2 --- /dev/null +++ b/src/test/resources/dma-connection-status-handler-test2.properties @@ -0,0 +1,87 @@ +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=1000 +redis.scan.limit=10 +redis.regex.scan.filename=scanregex.txt +redis.pipeline.size=2 +redis.netty.threads=0 +redis.decode.in.executor=true +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=admin +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=300000 +service.name=Ecall +messageid.generator.type=org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +mqtt.broker.url=tcp://127.0.0.1:1883 +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.service.topic.name=test +bootstrap.servers=localhost:9092 +event.transformer.classes=genericIgniteEventTransformer +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +#Serialization class +ingestion.serializer.class=org.eclipse.ecsp.serializer.IngestionSerializerFstImpl +kafka.ssl.enable=false +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +dma.auto.offset.reset=earliest +dma.service.max.retry=3 +dma.service.retry.interval.millis=2000 +dma.service.retry.min.threshold.millis=1000 +metrics.prometheus.enabled=false +filter.dmoffline.buffer.entry.impl=org.eclipse.ecsp.stream.dma.handler.NoFilterDMOfflineBufferEntryImpl +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +enable.input.validation=false +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=true +scheduler.enabled=false +device.message.feedback.topic=test-feedback +dma.dispatcher.ecu.types=kafka:testEcu#test-topic,testEcu2#test-topic +dma.connection.status.retriever.api.url=https://test-url/api/devices/ +dma.connection.status.api.retry.interval.ms=2000 +dma.connection.status.api.max.retry.count=2 +dma.connection.status.parser.impl=org.eclipse.ecsp.stream.dma.ConnectionStatusParserTestImpl \ No newline at end of file diff --git a/src/test/resources/dma-connectionstatus-handler-test.properties b/src/test/resources/dma-connectionstatus-handler-test.properties new file mode 100644 index 0000000..3e20cb0 --- /dev/null +++ b/src/test/resources/dma-connectionstatus-handler-test.properties @@ -0,0 +1,85 @@ +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=1000 +redis.scan.limit=10 +redis.regex.scan.filename=scanregex.txt +redis.pipeline.size=2 +redis.netty.threads=0 +redis.decode.in.executor=true +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=admin +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=300000 +service.name=Ecall +messageid.generator.type=org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +mqtt.broker.url=tcp://127.0.0.1:1883 +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.service.topic.name=test +mqtt.client=paho +bootstrap.servers=localhost:9092 +event.transformer.classes=genericIgniteEventTransformer +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +#Serialization class +ingestion.serializer.class=org.eclipse.ecsp.serializer.IngestionSerializerFstImpl +kafka.ssl.enable=false +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +dma.auto.offset.reset=earliest +dma.service.max.retry=-1 +dma.service.retry.interval.millis=5000 +dma.service.retry.min.threshold.millis=1000 +metrics.prometheus.enabled=false +#FilterDMOfflineBufferEntry Impl class +filter.dmoffline.buffer.entry.impl=org.eclipse.ecsp.stream.dma.handler.NoFilterDMOfflineBufferEntryImpl +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +enable.input.validation=false +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=true +dff.enabled=false +scheduler.enabled=false +device.message.feedback.topic=test-feedback diff --git a/src/test/resources/dma-handler-fetch-conn-status-test.properties b/src/test/resources/dma-handler-fetch-conn-status-test.properties new file mode 100644 index 0000000..0fcfb51 --- /dev/null +++ b/src/test/resources/dma-handler-fetch-conn-status-test.properties @@ -0,0 +1,209 @@ +################################################################################################################# + +#Stream processor properties +#Below are the required properties for the stream processors to run + +################################################################################################################# +launcher.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.KafkaStreamsLauncher + +#Below property specify the Fully qualified name of your processor(s) classes. Currently single processor is supported +service.stream.processors=org.eclipse.ecsp.stream.dma.DeviceFetchConnStatusIntegrationTest$FetchConnStatusTestStreamProcessor + +#The input source topic your stream processor listens to +source.topic.name=testSource + +#The sink topic where the stream processor will push the dta to +sink.topic.name=testSink + +#application id / consumer group of your stream processor +application.id=dma-sp + +#Service name +service.name=DMOfflineService + +#Comma separated list of kafka brokers +bootstrap.servers=localhost:9092 + +#Comma separated list of zookeepers +zookeeper.connect=localhost:2181 + +#Number of parallelism +num.stream.threads=1 + + +#State store directory +state.dir=/tmp/kafka-streams + +#Number of records that will be polled from kafka topic +max.poll.records=1000 + +session.timeout.ms=30000 + +request.timeout.ms=40000 + +kafka.rebalance.time.mins=10 + +kafka.close.timeout.secs=30 + +#setting this to nowhere means reset will not happen (earliest,latest,nowhere) +application.offset.reset=nowhere + + +#Set the below property as true, if changeLog topic is to be created for the state store +state.store.changelog.enabled=false + +################################################################################################################# + +#Transformer properties +#Developer should provide the logic to convert byte[] to Igniteevent + +################################################################################################################# + +#Provide the custom implementation of how the byte[] (JSON) should be converted to Ignite event +event.transformer.classes=genericIgniteEventTransformer +#Provide the custom implementation of how the byte[](JSON) should be converted to Ignite key +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +################################################################################################################# + +#Metric Properties +#In case you want to capture metrics for your stream processor + +################################################################################################################# + +#metric.reporters=org.eclipse.ecsp.analytics.stream.base.metrics.reporter.ConsoleMetricReporter +metrics.sample.window.ms=60000 +metrics.num.samples=15000 +#Kafka-redis-connector metrics + +#define the interval of logging +metric.logging.interval=2 +#define the time units. Possible values supported are minutes,seconds, hours, milliseconds +metric.logging.unit=minutes + + +#metrics for reporting the number of events getting processed per second +metrics.event.rate.enable=true + +#metrics for reporting the number of events processed by redis per second +metrics.event.rate.redis.enable=true + +#metrics for reporting the average latency in events processing for redis +metrics.avg.latency.redis.enable=true + +################################################################################################################# + +#Mqtt Properties +#You should configure the mqtt properties in case you wanted to send some data to Device (via mqtt) +# Used by DeviceMessaging agent + +################################################################################################################# + +mqtt.short.circuit=true +mqtt.broker.url=tcp://127.0.0.1:1883 +# separator is defaulted to / +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.service.topic.name.prefix=prefix/ +mqtt.service.topic.name=testSource +################################################################################################################# + +#Cumulative logging Properties +#You should configure the below properties in case you want cumlative logging (CLOGGER) + +################################################################################################################# + +#Cumulative logging configuration +log.counts=true +log.counts.minutes=1 +log.per.pdid=false + +discovery.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl + + +#Serialization class +ingestion.serializer.class=org.eclipse.ecsp.serializer.IngestionSerializerFstImpl + +#SSL Configuration +kafka.ssl.enable=false +kafka.ssl.client.auth=required +kafka.client.keystore=/kafka/ssl/kafka.client.keystore.jks +kafka.client.keystore.password=shcuwNHARcNuag8SgYdsG8cWuPExY3Tx +kafka.client.key.password=pUBPHXM9mP5PrRBrTEpF5cV2TpjvWtb5 +kafka.client.truststore=/kafka/ssl/kafka.client.truststore.jks +kafka.client.truststore.password=9vq9ghbSFd7JMFSgGMSCEuAzE3q27Xd3 + +#Mongo Configuration +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=test +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=30000 +dma.service.max.retry=0 +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region + +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=1000 +redis.scan.limit=10 +redis.regex.scan.filename=scanregex.txt +redis.pipeline.size=2 +replication.factor=1 +redis.netty.threads=0 +redis.decode.in.executor=true + +prometheus.agent.port=9100 +metrics.prometheus.enabled=false +prometheus.histogram.buckets=0.005, 0.010, 0.015, 0.020, 0.025, 0.030, 0.080, 0.1, 0.2, 0.3 +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +enable.input.validation=false + +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=true +scheduler.enabled=false + +#Name for fetch connection status kafka topic for presence manager +fetch.connection.status.topic.name=fetchConnectionStatus \ No newline at end of file diff --git a/src/test/resources/dma-handler-sub-services-test.properties b/src/test/resources/dma-handler-sub-services-test.properties new file mode 100644 index 0000000..bb68fe6 --- /dev/null +++ b/src/test/resources/dma-handler-sub-services-test.properties @@ -0,0 +1,92 @@ +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=1000 +redis.scan.limit=10 +redis.regex.scan.filename=scanregex.txt +redis.pipeline.size=2 +redis.netty.threads=0 +redis.decode.in.executor=true +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=admin +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=300000 +service.name=Ecall +messageid.generator.type=org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +mqtt.broker.url=tcp://127.0.0.1:1883 +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.service.topic.name=test +bootstrap.servers=localhost:9092 +event.transformer.classes=genericIgniteEventTransformer +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +#Serialization class +ingestion.serializer.class=org.eclipse.ecsp.serializer.IngestionSerializerFstImpl +kafka.ssl.enable=false +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +dma.auto.offset.reset=earliest +dma.service.max.retry=3 +dma.service.retry.interval.millis=5000 +dma.service.retry.min.threshold.millis=1000 +dma.event.config.provider.class=org.eclipse.ecsp.stream.dma.handler.EventConfigProviderTestImpl +prometheus.agent.port=9100 +metrics.prometheus.enabled=false +prometheus.histogram.buckets=0.005, 0.010, 0.015, 0.020, 0.025, 0.030, 0.080, 0.1, 0.2, 0.3 +kafka.streams.offset.persistence.enabled=false +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +enable.input.validation=false +#CacheBypass's properties +cache.bypass.queue.capacity=100000 +cache.bypass.thread.initial.delay=0 +cache.bypass.thread.delay=10 +cache.bypass.thread.shutdown.wait.time=2000 +cache.bypass.max.retry.attempts=10 +cache.bypass.retry.interval.milli.secs=1000 +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=true +scheduler.enabled=false +sub.services=ecall/test/ftd,ecall/test/ubi \ No newline at end of file diff --git a/src/test/resources/dma-handler-test.properties b/src/test/resources/dma-handler-test.properties new file mode 100644 index 0000000..7296da1 --- /dev/null +++ b/src/test/resources/dma-handler-test.properties @@ -0,0 +1,93 @@ +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=1000 +redis.scan.limit=10 +redis.regex.scan.filename=scanregex.txt +redis.pipeline.size=2 +redis.netty.threads=0 +redis.decode.in.executor=true +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=admin +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=300000 +service.name=Ecall +messageid.generator.type=org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +mqtt.broker.url=tcp://127.0.0.1:1883 +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.service.topic.name=test +mqtt.client=paho +bootstrap.servers=localhost:9092 +event.transformer.classes=genericIgniteEventTransformer +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +#Serialization class +ingestion.serializer.class=org.eclipse.ecsp.serializer.IngestionSerializerFstImpl +kafka.ssl.enable=false +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +dma.auto.offset.reset=earliest +dma.service.max.retry=3 +dma.service.retry.interval.millis=5000 +dma.service.retry.min.threshold.millis=1000 +dma.event.config.provider.class=org.eclipse.ecsp.stream.dma.handler.EventConfigProviderTestImpl +dma.config.resolver.class=org.eclipse.ecsp.stream.dma.handler.DMAConfigResolverTestImpl +prometheus.agent.port=9100 +metrics.prometheus.enabled=false +prometheus.histogram.buckets=0.005, 0.010, 0.015, 0.020, 0.025, 0.030, 0.080, 0.1, 0.2, 0.3 +kafka.streams.offset.persistence.enabled=false +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +enable.input.validation=false +#CacheBypass's properties +cache.bypass.queue.capacity=100000 +cache.bypass.thread.initial.delay=0 +cache.bypass.thread.delay=10 +cache.bypass.thread.shutdown.wait.time=2000 +cache.bypass.max.retry.attempts=10 +cache.bypass.retry.interval.milli.secs=1000 +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=true +scheduler.enabled=false \ No newline at end of file diff --git a/src/test/resources/dma-offline-multiple-device-test.properties b/src/test/resources/dma-offline-multiple-device-test.properties new file mode 100644 index 0000000..865bbf5 --- /dev/null +++ b/src/test/resources/dma-offline-multiple-device-test.properties @@ -0,0 +1,164 @@ +################################################################################################################# +#Stream processor properties +#Below are the required properties for the stream processors to run +################################################################################################################# +launcher.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.KafkaStreamsLauncher +#Below property specify the Fully qualified name of your processor(s) classes. Currently single processor is supported +service.stream.processors=org.eclipse.ecsp.stream.dma.DMOfflineBufferIntegrationTest$DMOfflineBufferTestStreamProcessor +#The input source topic your stream processor listens to +source.topic.name=testSource +#The sink topic where the stream processor will push the dta to +sink.topic.name=testSink +#application id / consumer group of your stream processor +application.id=dma-sp +#Service name +service.name=DMOfflineService +#Comma separated list of kafka brokers +bootstrap.servers=localhost:9092 +#Comma separated list of zookeepers +zookeeper.connect=localhost:2181 +#Number of parallelism +num.stream.threads=1 +#State store directory +state.dir=/tmp/kafka-streams +#Number of records that will be polled from kafka topic +max.poll.records=1000 +session.timeout.ms=30000 +request.timeout.ms=40000 +kafka.rebalance.time.mins=10 +kafka.close.timeout.secs=30 +#setting this to nowhere means reset will not happen (earliest,latest,nowhere) +application.offset.reset=nowhere +#Set the below property as true, if changeLog topic is to be created for the state store +state.store.changelog.enabled=false +################################################################################################################# +#Transformer properties +#Developer should provide the logic to convert byte[] to Igniteevent +################################################################################################################# +#Provide the custom implementation of how the byte[] (JSON) should be converted to Ignite event +event.transformer.classes=genericIgniteEventTransformer +#Provide the custom implementation of how the byte[](JSON) should be converted to Ignite key +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +################################################################################################################# +#Metric Properties +#In case you want to capture metrics for your stream processor +################################################################################################################# +metric.reporters=org.eclipse.ecsp.analytics.stream.base.metrics.reporter.ConsoleMetricReporter +metrics.sample.window.ms=60000 +metrics.num.samples=15000 +#Kafka-redis-connector metrics +#define the interval of logging +metric.logging.interval=2 +#define the time units. Possible values supported are minutes,seconds, hours, milliseconds +metric.logging.unit=minutes +#metrics for reporting the number of events getting processed per second +metrics.event.rate.enable=true +#metrics for reporting the number of events processed by redis per second +metrics.event.rate.redis.enable=true +#metrics for reporting the average latency in events processing for redis +metrics.avg.latency.redis.enable=true +################################################################################################################# +#Mqtt Properties +#You should configure the mqtt properties in case you wanted to send some data to Device (via mqtt) +# Used by DeviceMessaging agent +################################################################################################################# +mqtt.short.circuit=true +mqtt.broker.url=tcp://127.0.0.1:1883 +# separator is defaulted to / +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.service.topic.name.prefix=prefix/ +mqtt.service.topic.name=testSource +################################################################################################################# +#Cumulative logging Properties +#You should configure the below properties in case you want cumlative logging (CLOGGER) +################################################################################################################# +#Cumulative logging configuration +log.counts=true +log.counts.minutes=1 +log.per.pdid=false +discovery.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl +#Serialization class +ingestion.serializer.class=org.eclipse.ecsp.serializer.IngestionSerializerFstImpl +#SSL Configuration +kafka.ssl.enable=false +kafka.ssl.client.auth=required +kafka.client.keystore=/kafka/ssl/kafka.client.keystore.jks +kafka.client.keystore.password=shcuwNHARcNuag8SgYdsG8cWuPExY3Tx +kafka.client.key.password=pUBPHXM9mP5PrRBrTEpF5cV2TpjvWtb5 +kafka.client.truststore=/kafka/ssl/kafka.client.truststore.jks +kafka.client.truststore.password=9vq9ghbSFd7JMFSgGMSCEuAzE3q27Xd3 +#Mongo Configuration +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=test +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=30000 +dma.service.max.retry=0 +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=1000 +redis.scan.limit=10 +redis.regex.scan.filename=scanregex.txt +redis.pipeline.size=2 +replication.factor=1 +redis.netty.threads=0 +redis.decode.in.executor=true +offline.buffer.per.device=true +prometheus.agent.port=9100 +metrics.prometheus.enabled=false +prometheus.histogram.buckets=0.005, 0.010, 0.015, 0.020, 0.025, 0.030, 0.080, 0.1, 0.2, 0.3 +backdoor.callback.thread.pool.size=5 +backdoor.kafka.max.poll.interval.ms=600000 +backdoor.kafka.request.timeout.ms=605000 +backdoor.kafka.session.timeout.ms=250000 +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +enable.input.validation=false +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=true +scheduler.enabled=false \ No newline at end of file diff --git a/src/test/resources/dma-offline-test.properties b/src/test/resources/dma-offline-test.properties new file mode 100644 index 0000000..a242c94 --- /dev/null +++ b/src/test/resources/dma-offline-test.properties @@ -0,0 +1,159 @@ +################################################################################################################# +#Stream processor properties +#Below are the required properties for the stream processors to run +################################################################################################################# +launcher.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.KafkaStreamsLauncher +#Below property specify the Fully qualified name of your processor(s) classes. Currently single processor is supported +service.stream.processors=org.eclipse.ecsp.stream.dma.DMOfflineBufferIntegrationTest$DMOfflineBufferTestStreamProcessor +#The input source topic your stream processor listens to +source.topic.name=testSource +#The sink topic where the stream processor will push the dta to +sink.topic.name=testSink +#application id / consumer group of your stream processor +application.id=dma-sp +#Service name +service.name=DMOfflineService +#Comma separated list of kafka brokers +bootstrap.servers=localhost:9092 +#Comma separated list of zookeepers +zookeeper.connect=localhost:2181 +#Number of parallelism +num.stream.threads=1 +#State store directory +state.dir=/tmp/kafka-streams +#Number of records that will be polled from kafka topic +max.poll.records=1000 +session.timeout.ms=30000 +request.timeout.ms=40000 +kafka.rebalance.time.mins=10 +kafka.close.timeout.secs=30 +#setting this to nowhere means reset will not happen (earliest,latest,nowhere) +application.offset.reset=nowhere +#Set the below property as true, if changeLog topic is to be created for the state store +state.store.changelog.enabled=false +################################################################################################################# +#Transformer properties +#Developer should provide the logic to convert byte[] to Igniteevent +################################################################################################################# +#Provide the custom implementation of how the byte[] (JSON) should be converted to Ignite event +event.transformer.classes=genericIgniteEventTransformer +#Provide the custom implementation of how the byte[](JSON) should be converted to Ignite key +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +################################################################################################################# +#Metric Properties +#In case you want to capture metrics for your stream processor +################################################################################################################# +#metric.reporters=org.eclipse.ecsp.analytics.stream.base.metrics.reporter.ConsoleMetricReporter +metrics.sample.window.ms=60000 +metrics.num.samples=15000 +#Kafka-redis-connector metrics +#define the interval of logging +metric.logging.interval=2 +#define the time units. Possible values supported are minutes,seconds, hours, milliseconds +metric.logging.unit=minutes +#metrics for reporting the number of events getting processed per second +metrics.event.rate.enable=true +#metrics for reporting the number of events processed by redis per second +metrics.event.rate.redis.enable=true +#metrics for reporting the average latency in events processing for redis +metrics.avg.latency.redis.enable=true +################################################################################################################# +#Mqtt Properties +#You should configure the mqtt properties in case you wanted to send some data to Device (via mqtt) +# Used by DeviceMessaging agent +################################################################################################################# +mqtt.short.circuit=true +mqtt.broker.url=tcp://127.0.0.1:1883 +# separator is defaulted to / +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.service.topic.name.prefix=prefix/ +mqtt.service.topic.name=testSource +################################################################################################################# +#Cumulative logging Properties +#You should configure the below properties in case you want cumlative logging (CLOGGER) +################################################################################################################# +#Cumulative logging configuration +log.counts=true +log.counts.minutes=1 +log.per.pdid=false +discovery.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl +#Serialization class +ingestion.serializer.class=org.eclipse.ecsp.serializer.IngestionSerializerFstImpl +#SSL Configuration +kafka.ssl.enable=false +kafka.ssl.client.auth=required +kafka.client.keystore=/kafka/ssl/kafka.client.keystore.jks +kafka.client.keystore.password=shcuwNHARcNuag8SgYdsG8cWuPExY3Tx +kafka.client.key.password=pUBPHXM9mP5PrRBrTEpF5cV2TpjvWtb5 +kafka.client.truststore=/kafka/ssl/kafka.client.truststore.jks +kafka.client.truststore.password=9vq9ghbSFd7JMFSgGMSCEuAzE3q27Xd3 +#Mongo Configuration +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=test +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=30000 +dma.service.max.retry=0 +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=1000 +redis.scan.limit=10 +redis.regex.scan.filename=scanregex.txt +redis.pipeline.size=2 +replication.factor=1 +redis.netty.threads=0 +redis.decode.in.executor=true +prometheus.agent.port=9100 +metrics.prometheus.enabled=false +prometheus.histogram.buckets=0.005, 0.010, 0.015, 0.020, 0.025, 0.030, 0.080, 0.1, 0.2, 0.3 +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +enable.input.validation=false +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=true +scheduler.enabled=false \ No newline at end of file diff --git a/src/test/resources/dma-shouldertap-test.properties b/src/test/resources/dma-shouldertap-test.properties new file mode 100644 index 0000000..72f343c --- /dev/null +++ b/src/test/resources/dma-shouldertap-test.properties @@ -0,0 +1,106 @@ +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=1000 +redis.scan.limit=10 +redis.regex.scan.filename=scanregex.txt +redis.pipeline.size=2 +redis.netty.threads=0 +redis.decode.in.executor=true +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=admin +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=300000 +service.name=ECall +messageid.generator.type=org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +mqtt.broker.url=tcp://127.0.0.1:1883 +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.service.topic.name=test +bootstrap.servers=localhost:9092 +event.transformer.classes=genericIgniteEventTransformer +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +#Serialization class +ingestion.serializer.class=org.eclipse.ecsp.serializer.IngestionSerializerFstImpl +kafka.ssl.enable=false +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +dma.auto.offset.reset=latest +dma.service.max.retry=-1 +dma.service.retry.interval.millis=5000 +dma.service.retry.min.threshold.millis=1000 +shoulder.tap.max.retry=3 +shoulder.tap.retry.interval.millis=30000 +shoulder.tap.retry.min.threshold.millis=100 +#Shoulder tap invoker implementation class. +#Possible values: +#1) Default, Dummy (no invocation) Impl - org.eclipse.ecsp.stream.dma.shouldertap.DummyShoulderTapInvokerImpl +#2) WAM API implementation - org.eclipse.ecsp.stream.dma.shouldertap.ShoulderTapInvokerWAMImpl +#3) Vehicle Notification service - org.eclipse.ecsp.stream.dma.shouldertap.ShoulderTapInvokerVehicleNotificationImpl +dma.shoulder.tap.invoker.impl.class=org.eclipse.ecsp.stream.dma.shouldertap.ShoulderTapInvokerWAMImpl +# Shoulder tap WAM API Send SMS endpoint +dma.shoulder.tap.invoker.wam.send.sms.url=https://localhost:8080/v1.0/m2m/sms/send/ +# Shoulder tap WAM API SMS Transaction Status endpoint +dma.shoulder.tap.invoker.wam.sms.transaction.status.url=https://localhost:8080/v1.0/m2m/sim/transaction/ +# Shoulder tap WAM API SMS priority. Applicable values: HIGH, LOW. Default is HIGH. +dma.shoulder.tap.wam.sms.priority=HIGH +# Shoulder tap WAM API SMS validity hour. Value in hours: default is 72 hours. +dma.shoulder.tap.wam.sms.validity.hours=72 +# Shoulder tap WAM API SMS call: flag to skip the status check of any previous send SMS call before invoking again. +dma.shoulder.tap.wam.send.sms.skip.status.check=true +# Shoulder tap WAM API: max. retry count and interval to invoke send SMS/transaction status until a response. +dma.shoulder.tap.wam.api.max.retry.count=3 +dma.shoulder.tap.wam.api.max.retry.interval.ms=30000 +metrics.prometheus.enabled=false +dma.shoulder.tap.enabled=true +prometheus.agent.port=9100 +metrics.prometheus.enabled=false +prometheus.histogram.buckets=0.005, 0.010, 0.015, 0.020, 0.025, 0.030, 0.080, 0.1, 0.2, 0.3 +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +enable.input.validation=false +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=true +scheduler.enabled=false \ No newline at end of file diff --git a/src/test/resources/dma-test-kafka-dispatch.properties b/src/test/resources/dma-test-kafka-dispatch.properties new file mode 100644 index 0000000..7322e77 --- /dev/null +++ b/src/test/resources/dma-test-kafka-dispatch.properties @@ -0,0 +1,164 @@ +################################################################################################################# +#Stream processor properties +#Below are the required properties for the stream processors to run +################################################################################################################# +launcher.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.KafkaStreamsLauncher +#Below property specify the Fully qualified name of your processor(s) classes. Currently single processor is supported +service.stream.processors=org.eclipse.ecsp.stream.dma.KafkaDispatcherIntegrationTest$KafkaDispatcherTestStreamProcessor +#The input source topic your stream processor listens to +source.topic.name=testSource +#The sink topic where the stream processor will push the dta to +sink.topic.name=testSink +#application id / consumer group of your stream processor +application.id=dma-sp +#Service name +service.name=DMOfflineService +#Comma separated list of kafka brokers +bootstrap.servers=localhost:9092 +#Comma separated list of zookeepers +zookeeper.connect=localhost:2181 +#Number of parallelism +num.stream.threads=1 +#State store directory +state.dir=/tmp/kafka-streams +#Number of records that will be polled from kafka topic +max.poll.records=1000 +session.timeout.ms=30000 +request.timeout.ms=40000 +kafka.rebalance.time.mins=10 +kafka.close.timeout.secs=30 +#setting this to nowhere means reset will not happen (earliest,latest,nowhere) +application.offset.reset=nowhere +#Set the below property as true, if changeLog topic is to be created for the state store +state.store.changelog.enabled=false +################################################################################################################# +#Transformer properties +#Developer should provide the logic to convert byte[] to Igniteevent +################################################################################################################# +#Provide the custom implementation of how the byte[] (JSON) should be converted to Ignite event +event.transformer.classes=genericIgniteEventTransformer +#Provide the custom implementation of how the byte[](JSON) should be converted to Ignite key +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +################################################################################################################# +#Metric Properties +#In case you want to capture metrics for your stream processor +################################################################################################################# +metrics.sample.window.ms=60000 +metrics.num.samples=15000 +#Kafka-redis-connector metrics +#define the interval of logging +metric.logging.interval=2 +#define the time units. Possible values supported are minutes,seconds, hours, milliseconds +metric.logging.unit=minutes +#metrics for reporting the number of events getting processed per second +metrics.event.rate.enable=true +#metrics for reporting the number of events processed by redis per second +metrics.event.rate.redis.enable=true +#metrics for reporting the average latency in events processing for redis +metrics.avg.latency.redis.enable=true +################################################################################################################# +#Mqtt Properties +#You should configure the mqtt properties in case you wanted to send some data to Device (via mqtt) +# Used by DeviceMessaging agent +################################################################################################################# +mqtt.short.circuit=true +mqtt.broker.url=tcp://127.0.0.1:1883 +# separator is defaulted to / +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.service.topic.name.prefix=prefix/ +mqtt.service.topic.name=testSource +################################################################################################################# +#Cumulative logging Properties +#You should configure the below properties in case you want cumlative logging (CLOGGER) +################################################################################################################# +#Cumulative logging configuration +log.counts=true +log.counts.minutes=1 +log.per.pdid=false +discovery.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl +#Serialization class +ingestion.serializer.class=org.eclipse.ecsp.serializer.IngestionSerializerFstImpl +#SSL Configuration +kafka.ssl.enable=false +kafka.ssl.client.auth=required +kafka.client.keystore=/kafka/ssl/kafka.client.keystore.jks +kafka.client.keystore.password=shcuwNHARcNuag8SgYdsG8cWuPExY3Tx +kafka.client.key.password=pUBPHXM9mP5PrRBrTEpF5cV2TpjvWtb5 +kafka.client.truststore=/kafka/ssl/kafka.client.truststore.jks +kafka.client.truststore.password=9vq9ghbSFd7JMFSgGMSCEuAzE3q27Xd3 +#Mongo Configuration +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=test +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=30000 +dma.service.max.retry=0 +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=1000 +redis.scan.limit=10 +redis.regex.scan.filename=scanregex.txt +redis.pipeline.size=2 +replication.factor=1 +redis.netty.threads=0 +redis.decode.in.executor=true +prometheus.agent.port=9100 +metrics.prometheus.enabled=false +prometheus.histogram.buckets=0.005, 0.010, 0.015, 0.020, 0.025, 0.030, 0.080, 0.1, 0.2, 0.3 +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +enable.input.validation=false +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=true +scheduler.enabled=false +dma.dispatcher.ecu.types=kafka:testEcu#kafka-dispatch-topic,testEcu2#test-topic +dma.connection.status.retriever.api.url=https://test-url/api/devices/ +dma.connection.status.api.retry.interval.ms=2000 +dma.connection.status.api.max.retry.count=2 +dma.connection.status.parser.impl=org.eclipse.ecsp.stream.dma.ConnectionStatusParserTestImpl +kafka.headers.enabled=true \ No newline at end of file diff --git a/src/test/resources/filter-dma-offline-test.properties b/src/test/resources/filter-dma-offline-test.properties new file mode 100644 index 0000000..78ec26b --- /dev/null +++ b/src/test/resources/filter-dma-offline-test.properties @@ -0,0 +1,161 @@ +################################################################################################################# +#Stream processor properties +#Below are the required properties for the stream processors to run +################################################################################################################# +launcher.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.KafkaStreamsLauncher +#Below property specify the Fully qualified name of your processor(s) classes. Currently single processor is supported +service.stream.processors=org.eclipse.ecsp.stream.dma.DMOfflineBufferIntegrationTest$DMOfflineBufferTestStreamProcessor +#The input source topic your stream processor listens to +source.topic.name=testSource +#The sink topic where the stream processor will push the dta to +sink.topic.name=testSink +#application id / consumer group of your stream processor +application.id=dma-sp +#Service name +service.name=DMOfflineService +#Comma separated list of kafka brokers +bootstrap.servers=localhost:9092 +#Comma separated list of zookeepers +zookeeper.connect=localhost:2181 +#Number of parallelism +num.stream.threads=1 +#State store directory +state.dir=/tmp/kafka-streams +#Number of records that will be polled from kafka topic +max.poll.records=1000 +session.timeout.ms=30000 +request.timeout.ms=40000 +kafka.rebalance.time.mins=10 +kafka.close.timeout.secs=30 +#setting this to nowhere means reset will not happen (earliest,latest,nowhere) +application.offset.reset=nowhere +#Set the below property as true, if changeLog topic is to be created for the state store +state.store.changelog.enabled=false +################################################################################################################# +#Transformer properties +#Developer should provide the logic to convert byte[] to Igniteevent +################################################################################################################# +#Provide the custom implementation of how the byte[] (JSON) should be converted to Ignite event +event.transformer.classes=genericIgniteEventTransformer +#Provide the custom implementation of how the byte[](JSON) should be converted to Ignite key +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +################################################################################################################# +#Metric Properties +#In case you want to capture metrics for your stream processor +################################################################################################################# +metric.reporters=org.eclipse.ecsp.analytics.stream.base.metrics.reporter.ConsoleMetricReporter +metrics.sample.window.ms=60000 +metrics.num.samples=15000 +#Kafka-redis-connector metrics +#define the interval of logging +metric.logging.interval=2 +#define the time units. Possible values supported are minutes,seconds, hours, milliseconds +metric.logging.unit=minutes +#metrics for reporting the number of events getting processed per second +metrics.event.rate.enable=true +#metrics for reporting the number of events processed by redis per second +metrics.event.rate.redis.enable=true +#metrics for reporting the average latency in events processing for redis +metrics.avg.latency.redis.enable=true +################################################################################################################# +#Mqtt Properties +#You should configure the mqtt properties in case you wanted to send some data to Device (via mqtt) +# Used by DeviceMessaging agent +################################################################################################################# +mqtt.short.circuit=true +mqtt.broker.url=tcp://127.0.0.1:1883 +# separator is defaulted to / +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.service.topic.name.prefix=prefix/ +mqtt.service.topic.name=testSource +################################################################################################################# +#Cumulative logging Properties +#You should configure the below properties in case you want cumlative logging (CLOGGER) +################################################################################################################# +#Cumulative logging configuration +log.counts=true +log.counts.minutes=1 +log.per.pdid=false +discovery.impl.class.fqn=org.eclipse.ecsp.analytics.stream.base.discovery.PropBasedDiscoveryServiceImpl +#Serialization class +ingestion.serializer.class=org.eclipse.ecsp.serializer.IngestionSerializerFstImpl +#SSL Configuration +kafka.ssl.enable=false +kafka.ssl.client.auth=required +kafka.client.keystore=/kafka/ssl/kafka.client.keystore.jks +kafka.client.keystore.password=shcuwNHARcNuag8SgYdsG8cWuPExY3Tx +kafka.client.key.password=pUBPHXM9mP5PrRBrTEpF5cV2TpjvWtb5 +kafka.client.truststore=/kafka/ssl/kafka.client.truststore.jks +kafka.client.truststore.password=9vq9ghbSFd7JMFSgGMSCEuAzE3q27Xd3 +#Mongo Configuration +mongodb.host=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=test +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=30000 +dma.service.max.retry=0 +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=1000 +redis.scan.limit=10 +redis.regex.scan.filename=scanregex.txt +redis.pipeline.size=2 +replication.factor=1 +redis.netty.threads=0 +redis.decode.in.executor=true +prometheus.agent.port=9100 +metrics.prometheus.enabled=false +prometheus.histogram.buckets=0.005, 0.010, 0.015, 0.020, 0.025, 0.030, 0.080, 0.1, 0.2, 0.3 +#FilterDMOfflineBufferEntry Impl class +filter.dmoffline.buffer.entry.impl=org.eclipse.ecsp.stream.dma.handler.TestFilterDMOfflineBufferEntryImpl +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +enable.input.validation=false +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=true +scheduler.enabled=false \ No newline at end of file diff --git a/src/test/resources/hivemq-keystore.jks b/src/test/resources/hivemq-keystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..700adb922630d920a20302d84b698220a10be63c GIT binary patch literal 7086 zcma)hMN}Ms6664by95tT2oe}9xVyVU(7}Cx;0#W1cL)$H5Zr^q;BEuK9fDhM$Y$mJ z|Ms+d=-02RyQ^QP5c;GJO4!+3g-Wr!oom6EC@js|3?agtp78Cj14jX@&6ZMfmne=jyyoptk!KE z8M_qAMu&}SF8>x~?u`mRD=3i4;UQ+}nUwr+DBo}AspMeZeaI&XG9(@aYC ztNL}{<)=XupWFhb6+za=+R&K2zdDHl#KtIVbP2?7uacu{XO09HJR&+`OS2x8Hs4>N zl*;S953#(JgJ6UMwobLj(oMeaW)nlLS**2wAd(wf%cthEx(Jz0~xZIktvE7&c~y zBpERU;0;`sP2%9}*o6MRU4K?&f%`OZv94L-9SKXt3`Hit;~~E7Wypb$nr*IhLh>$s z2s&BjSp!kD787tEQc%qK{ycnHkdHCkTKFs2s4|wXB)YQ03408pcM!vAh!n$W6{_D? zV>kzV!(SWvwNzET*p`g_O+UCI_*2R-SdKupDKB=MUHlZS##9#yTs}durD`~%$>@5r zV2L%XE3A`bTT=bCR~PA>U;>K*J(fXB)vW za>O`Xe{gzL+94eq!Vtg!jy9ZJiBE*EL7h&ufe_$M>M5)QHnG`jTa6KHkcBiV;x<+BuX% zWr5lw{dCgf17glLKT*5++ppC-A54o2;0dUpjI2{TPOwdYrB?*v==)Y3rBR`220of{ zji$E5+#h3v%yqaP*f-0`7c>Xu63s7}D$bK`B8uha*3$?tZBzqI8~di$x%ai`ZM}Hk zvV}6^RU^x%+=(D9EZ<+pVA+3GHQK*0+|QMOdU@KazBojnjWW=pN7O?s=ycRu3-r0a zx+AM!5xzi~Ndtdgl{r{v^7%W(VLLbe`lWtW(-arEdx6Gy@Bj@E5$TL03NY>%cw72P z4Ry_)y~hcnlpa2a{5o(UYUo8@7po{2kfg~ldEx9r;E1f5ioc+BJgE2-bc1GwP<5wl zkBWwI_X%e?qbb8&@aco;DN=Ta`lY&y zm!tGP2q8`kheW8*NtRhCuS05kv#fIGki;P4N-|3jAlD|`Iwu>Kd80CRu`01Tr2 zuf!WpA|QdbofDXblaEt?TY&eY01wwkeo)98^ZzuVq7;O@(fmiLA|e3(CAt4r5dL3g zzJ-KQh9L6w!@(%ycgKizH^bU$oc|v)KVoOx^!JmGCnzL{7lNseLqmwBjggVWAv)uK zO8Us=1(urGts#djxUH3tqF6^bn6t0QPX)AdPe#LmX&fHJIP9USkN@|r+?zw^lt zYCMpCTw+g<5Y6SfG{X#N+D`CSK+UT{>3rI^-#TKDimX$mLz47Qi+0<4?<_}mV{lsN z9@sAHS$UNBc~a!7lYntOU+*mYKwo#f$r#od9u^D zmF<*%HN8hrWwi0k!PPHb;nc$4RXCCD5yTGrgR-Tn$o&vlggmU-lY@>|C*WHZUwY{t zW`r+3qz=6-qLkwU9rpvdWwF&~yjl-I^=gZ5_8_M7>HbSR?*3-Q_@R`*7{9jxnaTJN^VM)JtpX|(a7C)IoA!EIkNfQ_{FNO zRMfk&H70G?+DUCxrejdT3V*9zX%^%9q<*fG3w_yh1j-b`T*)hIHI1d1>CiXOW7BIg zV+Hr^)P#2Ub+g7*E761Y5>G`v#fekOrpojD9L>roIa6hxI#XxbFSU0!Th@b(t>2UW z>rEl9k!6&7l1 z(AiaL<#rED+Kv1nGa{*KMt1rg8b>O)_{pvdi9YRSJcAAB``sHkU9i=oj9cRB}Zmrs!H`^4z)n(hZc}A{lwH zFM3IFv95+rW+fCp&#hb9gFMRs$e>u4mr%H$pRRIA zE9)^A{SEfcTJ(ZL?Y(Dz}X|T&xg}N;Q75v&AH9V`WHYLj#Gt+pMDEF_&)~f99 zXk*}!O0U?#oS#y>#^rH~?4zcDA-g?sc`;4N0 zZS7Xj)xs;;#sbie%&z9~jn6F}Mv1M>l{1OzmWP>~Y!E-6*54e@`9>>Uv zNfI<6wtL4^Xf=N^z+f95+=WN}hN?c-xk96ZgNQ6KO0cQf$>II+#qGuE=c7#vD?^uP zi!a2@y%+)e9z+mur`-I;!Y;zbM`@xQGgkZ`x>B+EYjb@Rxm5j zl`SS?I7x~hyCYc8n{3|L4zNbz9ZJp^stb)DRnT&?mijZ^0K}B^1u=+iApLXFU z9(Jz;!xNd;cUS;RH|GQ5-IL9qT}rFEzHTQJP0dK3LaN7^YjU(W&2 z-L`up)%Bc|@e?B)X)K`f<9J>~ZFucKZKt+~9!N5hT>B(A)cgGqpz}Qk*X%;=_9O=^=1fO{6s=R?I5mn-KlD6jD#@Tm{QUFM^e3Z2{@gFV8NUc3 zeg(@f*lmz*LIj-n?Yw)R-l_%sr8!u^#(GT@hWzw@N5X?kHB zA*&U%7OWvq+LPDp6++^`vzTLAmr2+e@h5Y^5SbmPJ!G#cLIZb|>O3_&0CBbz=fUAQ z2R{w&%uHs(`Yz-+P3< zL}2_+7Cu+b9)#dFT=Lvi50MPCzzirSuiAtTX9w1il+>WB=RzRt8dnso0yqgmEr$ zm>`cpt3ux=sL@P|*%-9n4~gj{R3TNE2t|$L+j|O5KT5bua7_~}102$9441}ci1~W( zl43I6;re6C5#RLxz^o^mXB+%&rGeVK-GU!~;0$9sf$h0KZJ&h}0x*5YXC5SXYw&hi zFwHLNhKv$}tOa5PA2IjKo+dkT{w~gTKXB4r5mPZ^>e9&MzC6JEL$ThzT|5ghK` zmCccbtZ_$%>?h=sgkO1d1?oeh>rgCCITxrP?@O*VZ_TVtX}PRLTcpB_kjN=pxYXdK zMY`pQN9qoHeyCEHS*pP~AAe&mB*uKR`Bh>`$)Vg=sO>ba(l=c5g< zoe{QQxS6&83sXFS7?JBe>b`Q&BHEQ@k$1!4STIVD6ZNSjK1*gvwsU7zMq-1tDVW03 z-7*SHb#Gp#=2*z>Ev^$cj6o^$L0R7*uvW7SHGoZ3QH_3mJ&gq(KeIORsKlN9ZWB8? z7N#vN%`z62Du;c4e+4_mh8QcuAbfNu0s(8(K|{G;TQz+8H3uq|y#l9o?}3G^*@nwx z`UW*5#e9J^e{n|Sw?h@gTgZKvJh(Z=Sy$n}A@E;v!SaDJ54@sPGg7JSE4C+8$!mkh zLssd!)m?K@x?qXSj*_9xZvBYHh{z)zRtHspd2FuFhCgE*eV$22=$8au0vXqXyx?75 zP@{V@X-zN7h{@5T{TTvHSJ*)A*ILO@-Ob8G_<%uBo1RjHmuOG1*99CUqk15cqb+63 znU^Y$1jc6B#mFlOQp@8d>0wE5T`Y=vjh+t^+m7655*)YdR{Q19^Jn+h5&x1ll3W!n zDw+B_x8&nvuWm~=TyD}YuN#G|wh`HAEJ#YkquC!cKQ2oLiG`~gr!jx^mr`h7Iej%V zl)+&p&lmZSnn{+1>i>Y>j=dq>--ts`sul3Xe; z|Aa3ckJxHzZ-kS4i_ypDN#&t@! z>+hWZL$l?6_VY&xFTWk7h#F&p;>rmotZ#1*7&Sz#&aZt;bj6y9@GE#6zk%vpWEm1n zE!K%g2kWbA;kb;`sQ3Il9vKr)a-OMOh>J6NhNPI$Yr$4!J&y^(yJE=MSR*ep2ZH=j zG+8g4rrREl^z?-M95`NlYKvj{b0qnijqeoCDT2}p8|9xkAw?P2 z`!ku|h_+lkwAfF{AgP0hgEu-2`;>hL$RTS2OcspGmL;E?HCg&z^k3cJv-+?0bAFt< z@Q$qEzAn~OScjhjuWv^~OsEIO223t^Hc02N!fKS+~2U(oi=TAu(jg zBJa|>&&mTri$`QL`h`uQkB@mX0h~!-HEz=&cECR zq&)yua3w#%nBkDr+ZiwB<=XzqRTBiF=t4+=|C*x}HG zC_)zY%C|UqBRy#`-{uieC8|gcN_Jm0Avl_x`HlLuWxyQuA3Hsi#Ih6jTZVX5Fat=ql|mydtMh$7luDyTtF; z&YA^de!%#jOmg_K7P2R1BDu3K(&R)czxlNC<81n5#6F>aYswV%gd`w3#a$-gRxZ)j z?jC{4KTH1tm$Mf`Pq8wrRBnHa}(k|j)k7lxj3f{?un17Q6ny<1}&xZq)#N~)K`fm0^eQ$@iDz&C6&cl4>h}9<1m2jn48_-@`4 z2Obt-`L;#!IB+3y@4H7kJU=}v1ZjywpAj7yRGM|*W0|Qos@*_+z!?+$V{5%HxnY3i zc+gZpagbwrk!;mij@0em{X@Qc%GKRw=Ur@0Xt^~Z^o3We+uq@3H25;L_6w-1f>&p?poxR|mH7=l!BZA!^^yD$=H9jHvGIX)N*6>;LNXZ{?-Cl~{J zd@&Q#*}i`XS7|9gb%S2q)nM8>v=ac7R%t9e?mnywS@XFj63x@fvAT)z_ysmXl0y)^ z8fJ~QHw-Wg8a?yp1C-=?$0ajkLX#t^XWQx07C@2z&|cdDQyMIqyFWS0IV$-t|Yd zB245JrLUj;VZZ2ez4)yHhe2NfvaGs9G*KPwx~|`!Dlv-I0I8dl`IVep_w&AQ>y33Y za@)E*^Hzvzn_==g05z>nT$B|niz~BawZqOt?!14F9Mn^Ea)89EmW@K-ugut7$!%fb zyg*D`aN~`EaRcHgj6l~O?F6ZSI6>I|`C%a30*O9s@`EgnTuNVM)H~WhW39EMhZX z+xy;I=dAa2@5jun*?Z4??ll8LfS1vL=r9EMBPKR`sAA|PJ`f9-ivW9r5McK|unP=< z(eVE$j8YH+qv#Ku_b0M2asO`$4+n^ri@=cj1HOP+|2=_$57UDQ|I^~ZI6$NgX2da0I;W+*ruV1Kr3_*kR3$m>S_8h z)xh_j=#7(C&Ej(t5CSyTX7Vbqdz_kLt0en2q<+TDXqCv?>9O}WcTm*FwSEuxIG#O3 zH1>eAq)kWE*NwbbI3*XADt|YQgAuiUQZOz3951Dqbun?gBTClWw&^xjZfN{FicIG^ z^qr=L6Iv?$c%=c$wc(CwZ~~59>WA;qz6X!@$jfS?>I!|{c&SJT4;x?Awi*^*KH=Dm z6o9F;ZdpnB(-7{~WIbf8z)ICNet6|@*A0|@y>S6X+9w! z>+EEe#>qhE)N4{$CETQJ{_*Nfo1I?z=UF@X(?r!>7<>11)#USVVygZH(#z}zt2tZ2 z)Fb3GUWtLdGTD~gd$CvV^WCXZH>E%U4{X|qCS9gfvadshpM*b{ioSOI>Ssj|*dNm>YZd3bM}Tu z2?eoT${Pid__lK_jT3PC47oD@&dR-UR zvfXw_rKm%EN)NA`bFi1Jz0l_+i^-g?SJ;M0Ah8t#b60I4(xy(MZ`42E1;75Q)fUGnma7sdkH73Fx0K^^F_bjTQoSD>pp`ZdDsfOy z=^Pn}6|FWtOU6`7AaQplmLfV__z!icLF5qds*%Pk7iFNtTAt{g2P--rAwFIq9vB~t zUl=9=Ltt0^EyBXgMPTRufwIwnfIlVW9|riJ3_CR0kLza&aS@9~@aJ?X*%U|)0`tEy zY_LD}>TQGSehDOq`}2hXc@P4du9YKK7u1aVQlK;81se(?#R%ngKWNbyZ+TGcP9!w4 zt>}xC{F&Dnus-GZeI-4~AU{ufN+7PV6$nwVLFRvBBAJ(t8jYC_{#JJbI`}+>18G@UkyX=ItYEdNM2A0E3-OhUoHh_i|`a(w@sl(xF9OS|zwMME~-H(Nt)Vu1k1Gx*qf-`o-(k#kLt7kQN@UY5IIKYa# z2}qMFH(hjC-Pt85xm88D9Ze3V)yjXf%ix^Iguc^nIRb;9fYvplQ)j<4*lXX{w@2P?LSM+!s0c#G@Za_u(X^gvVGX z(98GL<29OnTakdJ(*bNAAs>!H=}SafaU^DTlNu^2QKr`v2HJw=)%xyzX+=se_;wvZ z>TaE0UI-jl?|P*ou^3!WlA8N(A|6s0*B7hN$&F1~f4Psn&sbrngKmp?rF7Ctd#qwB z@Shw8WW9U!OYu1y7I@vKZ)8WPuHOJW4={K{TDRPP( zgHBc}|159@U4A2j_{Ke}%vLSD0hug)W#@x&o+`82r}*u4>B$jK62X$$HHB__vN0R6 zM$e;0n7SM9a|y~$hBmeTCmxwx`PH{RXQ#yEne>k?nt1`GoQYM$YPWSK+(l-E+{ysd<1H0_ zTOQ734tbY`)*hO_-vjmFyZ5wj=U6`Kz>-Ivzh6uruz+ALVU5`pt!V7F#2}oS-xcPL zNK$ww4-d|Rm50@1eH`;2j_&+3c9>ic?KTfy4I(^_I%AG*tDkL#@f7IJY2!EL<5|;W zP`z5_y)l791!IFp8m@yojZMZ0({oX8aghK{TN^@nmf*C@ut!z#C2n6pvtL(g7s_)J zFYY(wGKh&E?X6z!TLN00aUAc#3I>l3c`_P3Fxb-SSTtr*gZNLX^^U)e(&d(&3+U8&a*G_Hdp*tB@_Bf5;hBgid zgtaf7wEi9RvwjX2GoUgHe|n9RyTgA0E)6OVH+W{N5f#BKv^yvS^{V@vd&B+ne5%js zI~AAOJEu;T3e(AvHOB>!k*hVWpZJy4^LF$jWnwZ=vJJF|lN^V!=;+|;%KfI!rJtJ~ z`I(D#LPG(IV#^&BJD<7(JKKL05U(vvsI-~KxhLru5z8DKm`4R&Fd8v94QQ9*KSHq} zVnHwN7QB;1k8TnlM=nEiadI)&acD+iiL_mb%jhm1S;WN@S#81m*z#_S3|tz~W_zme zXhKRYAeCnVfV{`-l*5Quw2ahNhwounw8#+N(ehGyV3E54v&EdCFZTQh*uh%uDy$xo z@lwESJJ8|!+-4m8CF0IR*1&@@4V0(oYL(vy@`X6V7 z!V6|?eO~t1k%SaML3>kSGw8u_cQ4=O=1^bWkj=nV#rWvxB5bJvG*FAnQJ5*)Hl6~S zl{kF&Q>xAN4=%P3#9fB|LORVN2{C{(7xc8UnEs=V1aZgWm(na}q2l9A7rz}>j?BbD zw7~Qkn=A$zzMyu#)RxKoIK!drADQ-y1r@`wqwZy9YEHCQnz8|{71kOwgkHsxs1*+B z2z?`UdskIs29Qq48zlk5U(2)*S0gr!0ZN{qf+W-D5^@P&V zj%kKK)(~-fq=s=2;5CYyzN11nnUj3!Lx&lX)V1KO-`K5--}*}$H|NKzR`scUFfX=3 zFg-7mDt+dJ%?Pbr%ORT%en~!AI{H!H4f4>d?@Wk@&>>M%pe{u9#CjGPMG47zCIfh8 zGcQ?sdD}m)TZ6{#wstd9`$8k~Dnd$u+*yRB!d}?2!+}Z3Q-6ogoE*jDd)4szeXgd# ztr=ed#*q+{;oV%4gZZ%qgBo1fMsyD4y5LB$^=0!8E-xC`2q z!ad^Hn%|O@RV*kqS6~Se@cA3ddGNAV#I2ghFXzb-4*AVgS?ZVO3>-468rMK%>B$sC z*{ui)NV;xLLnXd&Z=utNl{9KqoxEZbF@F73?D^B~Yw_db-rij;9<$r&OJ2wL+roQK zGELO}%*6T7#D(8lYV8K0bWIu(eRfLM&$|2k8-G%JWV6{CbEDGK`Z%tU)V0ny<4Wrb zOre~9Li(_F5rNJ)`pkV-WESVvM;V`V#$1m1NNb&V8j*+Jx(-#&^1fSx$J|xgw$|lC z#U^Talx92HZ>K7B#e+4N9dWhC&n^zI%HNxR)I;E z6n|diJ`BFW3N+cCbYRdo8NOQNK2R-=&@K!Ge1bL8IMq!HFZl^aQ7-0K+ZD4hF;fob z4aASj_eSkc2j#yjEZV!RDt0$dZc=JpP$VI&M~G>-)ulM6NcVi40EVhk`Ig=6C z=e(9iKPxhK5^SQykqgyhytD|*fu~T4??F4;NKZ2L8NufVcInwtm!FqnHTw+G*Xc63 zu%hOlW}+^h?SgAKn=3n*IM*L7y>x3r7oj0VB;|T9ACzt$SZd+oWP`1)Sz|$)HPQp8 zEO);)8&UB}smvl~mCEhg^@_M$*0w%1T-7?oSC;fMbKAiJ{pXfE%WfWD-P3l01H%Q4 z8d}2AzxP0iRIi4B%WqTsKbnRquX<8Wr(Lf(BECJ98G|D-w@w>*0PjkE)+F;pMX6J8 ziyF$+s&LNQUJP7e`Ourt7~uB0>1(RClCSwATSID2r7n1hW8Bu2?YQ! z9R>+thDZTr0|Wso1Q23;Th7Opzb{z$hntXYsgZz!1dwzpKc~#?g^VLIx^a%vi5dH@ zFjv%hp^kxkUl6MCfH9?SCw=TH`UynwPE={YloTo~`KoR1R@lhOmawdPnDeB!?)QSV zIOVac)P%88tofM)Amo&cJZ9fz;9Cp_A&8qwo-TgW1>_G)U}TG1G5^BZ{v zK>lqN-3|dxuw8cN^PgaQ?id_a9xyHVxA!LHNjgA8>wePO{U?-VoA=lIV=%VeO-OHa z-_D5-qB@{LfSXk6BgYcY%ykpoqZtN2SCPAbGFL^O6FQ)LKQ;0h;KN}sCAbmXCrn4N zebg`y)3rgGUXebISS`Bzwl`*!+V)&@)yUH zN)F9VcM2VfNR&`gG5_mUrFZtp*@AL$Y1t9hAct?E*t&jPcA_qdZ<_}Agj&*(E)=aV zB?2_+(Y9+kK&SDmh1eVQo+2%i!7j;irAG?48k4}dq|Vb5Nov2pgczPs)nxkG&~qFL z<+$i}m1dA1j*JbVzlO2Lrp))N!a*^W7u=h(OA+aQIj!9Ahad^#Z9xafzQLifI1mM> zijxP#R;P>p5t0GFx~J`Hn_;=;10Bp(k;cp9Zj&w`$PPb9rk@%zVfc1Nj@_@rhV#e- zUWeHbbgTVmc5||)qWXtFN{hd#3gjsu_lZyqudz)g@S}TTZv!MkNJDfoqOa|;Scwv7 zU%p|NUAhO%0+#)zXX)e+$k-x}wJXjPH|WUEqLf;YN$=LgdxEgT zf9-CArPQXCa?!3!V`GRoIWI#3cj7%Vmx>X?O?AQflWj3G z$RRckYCcz-DqD@Qq!GLO`%^Uq4jWT~4SBS$Le{Cg8kQrM$2;TBtdWUOiB`9Yo($_5 zm)b&4IV^=B(`mVEeE*8>%LFJfmd1sbApFEOpe5Qh5oe0HFYFJ9@IYO%VEqAM-Fb#F z`i$CMc!QFH8mc>b{p%5FRsL_H&>*#c08D5_(7~2hbq8c@b-5XG&Th+JgU2fZ&KR~k zq+gOrhg!Z`IocCzxt)Nz@qJwOC_P?3{>FpFAc@f@!r4tmnINXhpe)VafHLv6Ew9t2 zn@ux;ZnrYpeiERp(Yhn+5i(u z8#I!hk+@l(@lI7rvs`aEw#o1V=DfIK40aWas~yOt(+YfS!kn*4c}A6GrU2?E%M|cO zb5(6sYc?=VFflL<1_@w>NC9O71OfpC00ba~r$rH|ZYqk(@`Q9=E+0T2+E)!JfRC8( k^$W)!Ll&U~6gD3mCn;3e?HLZuySxgCMZd+)90CF-5X`reng9R* literal 0 HcmV?d00001 diff --git a/src/test/resources/logback.xml b/src/test/resources/logback.xml new file mode 100644 index 0000000..63d1d80 --- /dev/null +++ b/src/test/resources/logback.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %c{1} %-5level - %msg %ex{full} %n + + + + + 1000000 + 20 + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/resources/messageid-generator-test.properties b/src/test/resources/messageid-generator-test.properties new file mode 100644 index 0000000..7751dfb --- /dev/null +++ b/src/test/resources/messageid-generator-test.properties @@ -0,0 +1,78 @@ +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=1000 +redis.scan.limit=10 +redis.regex.scan.filename=scanregex.txt +redis.pipeline.size=2 +redis.netty.threads=0 +redis.decode.in.executor=true +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=admin +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=300000 +service.name=Ecall +messageid.generator.type=org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +mqtt.broker.url=tcp://127.0.0.1:1883 +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.service.topic.name=test +bootstrap.servers=localhost:9092 +event.transformer.classes=genericIgniteEventTransformer +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +#Serialization class +ingestion.serializer.class=org.eclipse.ecsp.serializer.IngestionSerializerFstImpl +kafka.ssl.enable=false +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +dma.auto.offset.reset=earliest +dma.service.max.retry=-1 +dma.service.retry.interval.millis=5000 +dma.service.retry.min.threshold.millis=1000 +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=false +scheduler.enabled=false \ No newline at end of file diff --git a/src/test/resources/mongo-sink-node.properties b/src/test/resources/mongo-sink-node.properties new file mode 100644 index 0000000..783d555 --- /dev/null +++ b/src/test/resources/mongo-sink-node.properties @@ -0,0 +1,9 @@ +db.url=localhost +db.port=27017 +db.auth.username=admin +db.auth.password=test@123 +db.auth.db=admin +db.name=testDB +db.pool.max.size=200 +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region \ No newline at end of file diff --git a/src/test/resources/moquette.conf b/src/test/resources/moquette.conf new file mode 100644 index 0000000..73b6cfe --- /dev/null +++ b/src/test/resources/moquette.conf @@ -0,0 +1,88 @@ +############################################## +# Moquette configuration file. +# +# The synthax is equals to mosquitto.conf +# +############################################## + +port 1883 + +websocket_port 8088 + +#********************************************************************* +# Secure Websocket port (wss) +# decommend this to enable wss +#********************************************************************* +#secure_websocket_port 8883 + +#********************************************************************* +# SSL tcp part +# jks_path: define the file that contains the Java Key Store, +# relative to the current broker home +# +# key_store_password: is the password used to open the keystore +# +# key_manager_password: is the password used to manage the alias in the +# keystore +#********************************************************************* + +ssl_port 8883 +#use ssl.sh to generate this +jks_path src/test/resources/server.jks +key_store_password expectBrilliance +key_manager_password expectBrilliance + +#********************************************************************* +# The interface to bind the server +# 0.0.0.0 means "any" +#********************************************************************* +host 0.0.0.0 + +#********************************************************************* +# acl_file: +# defines the path to the ACL file relative to moquette home dir +# contained in the moquette.path system property +#********************************************************************* +#acl_file config/acl.conf + +#********************************************************************* +# allow_anonymous is used to accept MQTT connections also from not +# authenticated clients. +# - false to accept ONLY client connetions with credentials. +# - true to accept client connection without credentails, validating +# only against the password_file, the ones that provides. +#********************************************************************* +allow_anonymous false +#********************************************************************* +# password_file: +# defines the path to the file that contains the credentials for +# authenticated client connection. It's relative to moquette home dir +# defined by the system property moquette.path +#********************************************************************* +password_file src/test/resources/password_file.conf + + +#********************************************************************* +# Optional +# authorizator_class: +# class name of the authorizator, by default uses the +# password_file.conf. +# If not specified uses the class: AuthorizationsCollector +# +# Optional +# authenticator_class: +# class name of the authenticator, default implementation uses +# definitions in the acl.conf. +# If not specified uses FileAuthenticator +#********************************************************************* +# authenticator_class [[path to your class>]] +# authorizator_class [[path to your class>]] + + +#********************************************************************* +# Persistence configuration +# autosave_interval: +# interval between flushes of MapDB storage to disk. It's in +# seconds, if not specified defaults is 30 s. +#********************************************************************* +# autosave_interval 120 diff --git a/src/test/resources/mqtt-health-monitor.properties b/src/test/resources/mqtt-health-monitor.properties new file mode 100644 index 0000000..541bac2 --- /dev/null +++ b/src/test/resources/mqtt-health-monitor.properties @@ -0,0 +1,40 @@ +mqtt.broker.url=tcp://127.0.0.1:1883 +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.topic.to.device.infix=/2d +mqtt.service.topic.name=test +mqtt.conn.retry.count=3 +mqtt.conn.retry.interval=1000 +mqtt.health.monitor.enabled=true +mqtt.client=paho +mqtt.clean.session=true +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=admin +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +service.name=Ecall +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +bootstrap.servers=localhost:9092 +kafka.ssl.enable=false +health.mqtt.monitor.enabled=true +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=true +scheduler.enabled=false \ No newline at end of file diff --git a/src/test/resources/mqtt.conf b/src/test/resources/mqtt.conf new file mode 100644 index 0000000..d4f1885 --- /dev/null +++ b/src/test/resources/mqtt.conf @@ -0,0 +1,2 @@ +host localhost +port 1883 \ No newline at end of file diff --git a/src/test/resources/mqtt_ssl.conf b/src/test/resources/mqtt_ssl.conf new file mode 100644 index 0000000..357c802 --- /dev/null +++ b/src/test/resources/mqtt_ssl.conf @@ -0,0 +1,6 @@ +host localhost +port 1883 +ssl_port 8883 +jks_path src/test/resources/hivemq-keystore.jks +key_store_password harman +key_manager_password harman \ No newline at end of file diff --git a/src/test/resources/notification-alerts-template-message.json b/src/test/resources/notification-alerts-template-message.json new file mode 100644 index 0000000..42bd0d0 --- /dev/null +++ b/src/test/resources/notification-alerts-template-message.json @@ -0,0 +1,21 @@ +{ + "LowFuel_push": "Low fuel detected. Current fuel level is ${Data.fuelLevel}%.", + "GeoFence_push_out": "Your vehicle is outside of the set boundary.", + "GeoFence_push_in": "Your vehicle is back inside the set boundary.", + "Collision_push": "Your vehicle got an accident at location http://maps.google.com/maps?q=loc:${Data.Latitude},${Data.Longitude} at speed ${Data.VehicleSpeed} ${Data.VehicleSpeedUnit} Time is ${Timestamp}.", + "Collision_sms": "Your vehicle got an accident at location http://maps.google.com/maps?q=loc:${Data.Latitude},${Data.Longitude} at speed ${Data.VehicleSpeed} ${Data.VehicleSpeedUnit} Time is ${Timestamp}.", + "DTCStored_push_set": "A problem has been detected with your vehicle. Identified as:${DTC_LIST}.", + "DTCStored_push_cleared": "One or more problems (${DTC_LIST}) have been fixed.", + "OverSpeeding_push": "Your vehicle has gone over the speed limit, at ${Data.speed} km/hr.", + "GeoFence_valet_push_in": "Your vehicle is back inside the valet boundary.", + "GeoFence_valet_push_out": "Your vehicle has gone outside of the valet boundary.", + "GeoFence_generic_push_out": "Your vehicle is outside of the ${Data.name} boundary.", + "GeoFence_generic_push_in": "Your vehicle is back inside the ${Data.name} boundary.", + "CurfewViolation_push": "Your vehicle is being used past curfew.", + "Idling_push": "Your vehicle has been idling for ${Data.duration} minutes.", + "Tow_push": "Your vehicle is being towed from latitude={Data.latitude},longitude=${Data.longitude}.", + "GeoFence_privatelocation_push_out": "Your vehicle is back outside of the ${Data.name} boundary.", + "GeoFence_privatelocation_push_in": "Your vehicle has entered the ${Data.name} boundary.", + "DongleDetached_push": "The dongle has been disconnected from your vehicle.", + "DocumentExpiry_push": "Your ${Data.DocumentName} document will get expire in ${Data.ExpiryDate} day(s)." +} \ No newline at end of file diff --git a/src/test/resources/offsetmanager-test.properties b/src/test/resources/offsetmanager-test.properties new file mode 100644 index 0000000..bc26696 --- /dev/null +++ b/src/test/resources/offsetmanager-test.properties @@ -0,0 +1,87 @@ +#The modes are SINGLE,REPLICA,CLUSTER,SENTINEL +redis.address=127.0.0.1:6379 +redis.sentinels= +redis.master.name= +redis.dns.monitoring.interval=5000 +redis.read.mode=SLAVE +redis.subscription.mode=SLAVE +redis.subscription.conn.min.idle.size=1 +redis.subscription.conn.pool.size=50 +redis.slave.conn.min.idle.size=32 +redis.slave.pool.size=64 +redis.master.conn.min.idle.size=32 +redis.master.conn.pool.size=64 +redis.idle.conn.timeout=10000 +redis.conn.timeout=10000 +redis.timeout=3000 +redis.retry.attempts=3 +redis.retry.interval=1500 +redis.reconnection.timeout=3000 +redis.failed.attempts=3 +redis.database=0 +redis.password= +redis.subscriptions.per.conn=5 +redis.client.name=yellow +redis.conn.min.idle.size=32 +redis.conn.pool.size=64 +redis.cluster.masters= +redis.scan.interval=1000 +redis.scan.limit=10 +redis.regex.scan.filename=scanregex.txt +redis.pipeline.size=2 +redis.netty.threads=0 +redis.decode.in.executor=true +mongodb.hosts=localhost +mongodb.port=27017 +mongodb.username=admin +mongodb.password=password +mongodb.auth.db=admin +mongodb.name=admin +mongodb.pool.max.size=200 +mongodb.max.wait.time.ms=60000 +mongodb.connection.timeout.ms=60000 +mongodb.socket.timeout.ms=60000 +mongodb.max.connections.per.host=200 +mongodb.block.threads.allowed.multiplier=10 +mongodb.read.preference=secondaryPreferred +morphia.map.packages=org.eclipse.ecsp.dao +mongodb.server.selection.timeout=300000 +service.name=Ecall-sp&service.test_hi +messageid.generator.type=org.eclipse.ecsp.analytics.stream.base.idgen.internal.GlobalMessageIdGenerator +mongodb.taggable.read.preference.enabled=false +mongodb.read.preference.tag=primary_region +mqtt.broker.url=tcp://127.0.0.1:1883 +mqtt.topic.separator=/ +mqtt.config.qos=1 +mqtt.user.name=8146ccc47e84ac1e43de623403133d55 +mqtt.user.password=simulator16 +mqtt.service.topic.name=test +bootstrap.servers=localhost:9092 +event.transformer.classes=genericIgniteEventTransformer +ignite.key.transformer.class=org.eclipse.ecsp.transform.IgniteKeyTransformerStringImpl +#Serialization class +ingestion.serializer.class=org.eclipse.ecsp.serializer.IngestionSerializerFstImpl +kafka.ssl.enable=false +device.messaging.event.transformer.class=org.eclipse.ecsp.transform.DeviceMessageIgniteEventTransformer +dma.auto.offset.reset=earliest +dma.service.max.retry=3 +dma.service.retry.interval.millis=5000 +dma.service.retry.min.threshold.millis=1000 +prometheus.agent.port=9100 +metrics.prometheus.enabled=false +prometheus.histogram.buckets=0.005, 0.010, 0.015, 0.020, 0.025, 0.030, 0.080, 0.1, 0.2, 0.3 +source.topic.name=raw-events +start.device.status.consumer=false +kafka.streams.offset.persistence.delay=1000 +kafka.streams.offset.persistence.init.delay=10 +kafka.streams.offset.persistence.enabled=true +health.mqtt.monitor.enabled=false +health.mongo.monitor.enabled=false +health.kafka.consumer.group.monitor.enabled=false +health.device.status.backdoor.monitor.enabled=false +health.kafka.topics.monitor.enabled=false +health.redis.monitor.enabled=false +enable.input.validation=false +#Enabling DMA/SCHEDULER Module Configurations For StreamBase +dma.enabled=false +scheduler.enabled=false \ No newline at end of file diff --git a/src/test/resources/password_file.conf b/src/test/resources/password_file.conf new file mode 100644 index 0000000..ecbe0f4 --- /dev/null +++ b/src/test/resources/password_file.conf @@ -0,0 +1,2 @@ +#passwords is simulator16 ; how to create password echo -n "yourpassword" | sha256sum on linux box +8146ccc47e84ac1e43de623403133d55:9a350b66705722b89c3997f7f80fd9ac6d2ef324295d33f5409e1cfe48072dc2 \ No newline at end of file diff --git a/src/test/resources/redis-server.exe b/src/test/resources/redis-server.exe new file mode 100644 index 0000000000000000000000000000000000000000..2a0f5e826c09b9bef621cd78669f1448e1d3a459 GIT binary patch literal 1925632 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 SG00)EZT>pri{IBTNUzL1-|nEb*) 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

    Or#)AmIrB%FTKj1RnfdnY`uc0V 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

    ^5COOW~Bgw9QD0y&3cXBi|@51{9~ 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�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%0DZ85c;WaQyHKfn%;Ky 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ν2>}~SOSg!4+BSn?CA?egY&>K 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

    CHOG~X@gZT-sxs#rC2L5!<=~m6? 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 z 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 HpB?x&q;a?o literal 0 HcmV?d00001 diff --git a/src/test/resources/redis-server.pdb b/src/test/resources/redis-server.pdb new file mode 100644 index 0000000000000000000000000000000000000000..4fb5cf5d51b853bacd3b3520eb12499820975ea1 GIT binary patch literal 11833344 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