FINERACT-1724: Implemented support for multiple parallel JMS producers for external events

This commit is contained in:
Arnold Galovics
2023-01-19 10:34:11 +01:00
committed by Arnold Gálovics
parent 54aae1c0a3
commit 7d28646c7d
45 changed files with 952 additions and 248 deletions
@@ -39,4 +39,9 @@ public class LoanAccountsStayedLockedBusinessEvent extends AbstractBusinessEvent
public String getCategory() { public String getCategory() {
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return null;
}
} }
@@ -165,6 +165,7 @@ public class FineractProperties {
private String brokerUrl; private String brokerUrl;
private String brokerUsername; private String brokerUsername;
private String brokerPassword; private String brokerPassword;
private int producerCount;
public boolean isBrokerPasswordProtected() { public boolean isBrokerPasswordProtected() {
return StringUtils.isNotBlank(brokerUsername) || StringUtils.isNotBlank(brokerPassword); return StringUtils.isNotBlank(brokerUsername) || StringUtils.isNotBlank(brokerPassword);
@@ -16,21 +16,20 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
package org.apache.fineract.infrastructure.event.external.config; package org.apache.fineract.infrastructure.core.messaging.jms;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import javax.jms.BytesMessage;
import org.springframework.context.annotation.Bean; import javax.jms.JMSException;
import org.springframework.context.annotation.Configuration; import org.apache.activemq.command.ActiveMQBytesMessage;
import org.springframework.integration.channel.DirectChannel; import org.springframework.stereotype.Component;
import org.springframework.integration.config.EnableIntegration;
@Configuration @Component
@EnableIntegration public class ActiveMQMessageFactory implements MessageFactory {
@ConditionalOnProperty(value = "fineract.events.external.enabled", havingValue = "true")
public class ExternalEventProducerConfiguration {
@Bean @Override
public DirectChannel outboundRequestsEvents() { public BytesMessage createByteMessage(byte[] msg) throws JMSException {
return new DirectChannel(); ActiveMQBytesMessage result = new ActiveMQBytesMessage();
result.writeBytes(msg);
return result;
} }
} }
@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.core.messaging.jms;
import javax.jms.BytesMessage;
import javax.jms.JMSException;
public interface MessageFactory {
BytesMessage createByteMessage(byte[] msg) throws JMSException;
}
@@ -0,0 +1,30 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.core.service;
import com.google.common.hash.Hashing;
import org.springframework.stereotype.Component;
@Component
public class HashingService {
public int consistentHash(long input, int buckets) {
return Hashing.consistentHash(input, buckets);
}
}
@@ -0,0 +1,50 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.core.service;
import java.time.Duration;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.springframework.util.StopWatch;
public final class MeasuringUtil {
private MeasuringUtil() {}
public static void measure(Runnable r, Consumer<Duration> c) {
measure(() -> {
r.run();
return null;
}, c);
}
public static <T> T measure(Supplier<T> s, Consumer<Duration> c) {
return measure(s, (result, timeTaken) -> c.accept(timeTaken));
}
public static <T> T measure(Supplier<T> s, BiConsumer<T, Duration> c) {
StopWatch sw = new StopWatch();
sw.start();
T result = s.get();
sw.stop();
c.accept(result, Duration.ofMillis(sw.getTotalTimeMillis()));
return result;
}
}
@@ -18,7 +18,11 @@
*/ */
package org.apache.fineract.infrastructure.event.business.domain; package org.apache.fineract.infrastructure.event.business.domain;
import static java.util.stream.Collectors.toSet;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Set;
public class BulkBusinessEvent extends AbstractBusinessEvent<List<BusinessEvent<?>>> { public class BulkBusinessEvent extends AbstractBusinessEvent<List<BusinessEvent<?>>> {
@@ -27,6 +31,14 @@ public class BulkBusinessEvent extends AbstractBusinessEvent<List<BusinessEvent<
public BulkBusinessEvent(List<BusinessEvent<?>> value) { public BulkBusinessEvent(List<BusinessEvent<?>> value) {
super(value); super(value);
verifySameAggregate(value);
}
private void verifySameAggregate(List<BusinessEvent<?>> events) {
Set<Long> aggregateRootIds = events.stream().map(BusinessEvent::getAggregateRootId).filter(Objects::nonNull).collect(toSet());
if (aggregateRootIds.size() > 1) {
throw new IllegalArgumentException("The business events are related to multiple aggregate roots which is not allowed");
}
} }
@Override @Override
@@ -38,4 +50,9 @@ public class BulkBusinessEvent extends AbstractBusinessEvent<List<BusinessEvent<
public String getCategory() { public String getCategory() {
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return get().iterator().next().getAggregateRootId();
}
} }
@@ -25,4 +25,6 @@ public interface BusinessEvent<T> {
String getType(); String getType();
String getCategory(); String getCategory();
Long getAggregateRootId();
} }
@@ -33,4 +33,9 @@ public abstract class ClientBusinessEvent extends AbstractBusinessEvent<Client>
public String getCategory() { public String getCategory() {
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return get().getId();
}
} }
@@ -33,4 +33,9 @@ public abstract class FixedDepositAccountBusinessEvent extends AbstractBusinessE
public String getCategory() { public String getCategory() {
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return get().getId();
}
} }
@@ -33,4 +33,9 @@ public abstract class RecurringDepositAccountBusinessEvent extends AbstractBusin
public String getCategory() { public String getCategory() {
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return get().getId();
}
} }
@@ -33,4 +33,9 @@ public abstract class GroupsBusinessEvent extends AbstractBusinessEvent<CommandP
public String getCategory() { public String getCategory() {
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return get().getResourceId();
}
} }
@@ -43,6 +43,11 @@ public class LoanAdjustTransactionBusinessEvent extends AbstractBusinessEvent<Lo
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return get().getTransactionToAdjust().getLoan().getId();
}
@RequiredArgsConstructor @RequiredArgsConstructor
@Getter @Getter
public static class Data { public static class Data {
@@ -33,4 +33,9 @@ public abstract class LoanBusinessEvent extends AbstractBusinessEvent<Loan> {
public String getCategory() { public String getCategory() {
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return get().getId();
}
} }
@@ -33,4 +33,9 @@ public abstract class LoanChargeBusinessEvent extends AbstractBusinessEvent<Loan
public String getCategory() { public String getCategory() {
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return get().getLoan().getId();
}
} }
@@ -33,4 +33,9 @@ public abstract class LoanProductBusinessEvent extends AbstractBusinessEvent<Loa
public String getCategory() { public String getCategory() {
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return get().getId();
}
} }
@@ -33,4 +33,9 @@ public abstract class LoanRepaymentBusinessEvent extends AbstractBusinessEvent<L
public String getCategory() { public String getCategory() {
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return get().getLoan().getId();
}
} }
@@ -33,4 +33,9 @@ public abstract class LoanTransactionBusinessEvent extends AbstractBusinessEvent
public String getCategory() { public String getCategory() {
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return get().getLoan().getId();
}
} }
@@ -33,4 +33,9 @@ public abstract class SavingsAccountBusinessEvent extends AbstractBusinessEvent<
public String getCategory() { public String getCategory() {
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return get().getId();
}
} }
@@ -33,4 +33,9 @@ public abstract class SavingsAccountTransactionBusinessEvent extends AbstractBus
public String getCategory() { public String getCategory() {
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return get().getSavingsAccount().getId();
}
} }
@@ -33,4 +33,9 @@ public abstract class ShareAccountBusinessEvent extends AbstractBusinessEvent<Sh
public String getCategory() { public String getCategory() {
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return get().getId();
}
} }
@@ -38,4 +38,9 @@ public class ShareProductDividentsCreateBusinessEvent extends AbstractBusinessEv
public String getCategory() { public String getCategory() {
return CATEGORY; return CATEGORY;
} }
@Override
public Long getAggregateRootId() {
return get();
}
} }
@@ -28,10 +28,11 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration @Configuration
@ConditionalOnProperty(value = "fineract.events.external.producer.jms.enabled", havingValue = "true") @ConditionalOnProperty(value = "fineract.events.external.producer.jms.enabled", havingValue = "true")
public class ExternalEventJMSBrokerConfiguration { public class ExternalEventJMSConfiguration {
@Autowired @Autowired
private FineractProperties fineractProperties; private FineractProperties fineractProperties;
@@ -60,4 +61,13 @@ public class ExternalEventJMSBrokerConfiguration {
public ActiveMQQueue activeMqQueue() { public ActiveMQQueue activeMqQueue() {
return new ActiveMQQueue(fineractProperties.getEvents().getExternal().getProducer().getJms().getEventQueueName()); return new ActiveMQQueue(fineractProperties.getEvents().getExternal().getProducer().getJms().getEventQueueName());
} }
@Bean("externalEventJmsProducerExecutor")
public ThreadPoolTaskExecutor externalEventJmsProducerExecutor() {
ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
threadPoolTaskExecutor.setCorePoolSize(10);
threadPoolTaskExecutor.setMaxPoolSize(100);
threadPoolTaskExecutor.setThreadNamePrefix("externalEventJms");
return threadPoolTaskExecutor;
}
} }
@@ -1,55 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.event.external.config;
import javax.jms.ConnectionFactory;
import javax.jms.Destination;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.integration.channel.DirectChannel;
import org.springframework.integration.config.EnableIntegration;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.dsl.IntegrationFlows;
import org.springframework.integration.jms.dsl.Jms;
@Configuration
@EnableIntegration
@ConditionalOnProperty(value = "fineract.events.external.producer.jms.enabled", havingValue = "true")
@Import(value = { ExternalEventJMSBrokerConfiguration.class })
public class ExternalEventJMSProducerConfiguration {
@Autowired
private DirectChannel outboundRequestsEvents;
@Autowired
private FineractProperties fineractProperties;
@Bean
public IntegrationFlow outboundFlowEvents(ConnectionFactory connectionFactory,
@Qualifier("eventDestination") Destination eventDestination) {
return IntegrationFlows.from(outboundRequestsEvents) //
.handle(Jms.outboundAdapter(connectionFactory).destination(eventDestination)).get();
}
}
@@ -19,6 +19,7 @@
package org.apache.fineract.infrastructure.event.external.jobs; package org.apache.fineract.infrastructure.event.external.jobs;
import org.apache.fineract.infrastructure.jobs.service.JobName; import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.infrastructure.jobs.service.StepName;
import org.springframework.batch.core.Job; import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step; import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.JobBuilderFactory; import org.springframework.batch.core.configuration.annotation.JobBuilderFactory;
@@ -40,7 +41,7 @@ public class SendAsynchronousEventsConfig {
@Bean @Bean
protected Step sendAsynchronousEventsStep() { protected Step sendAsynchronousEventsStep() {
return steps.get(JobName.SEND_ASYNCHRONOUS_EVENTS.name()).tasklet(tasklet).build(); return steps.get(StepName.SEND_ASYNCHRONOUS_EVENTS_STEP.name()).tasklet(tasklet).build();
} }
@Bean @Bean
@@ -18,8 +18,17 @@
*/ */
package org.apache.fineract.infrastructure.event.external.jobs; package org.apache.fineract.infrastructure.event.external.jobs;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.toMap;
import static org.apache.fineract.infrastructure.core.service.MeasuringUtil.measure;
import com.google.common.collect.Lists;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.avro.MessageV1; import org.apache.fineract.avro.MessageV1;
@@ -28,8 +37,8 @@ import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.event.external.producer.ExternalEventProducer; import org.apache.fineract.infrastructure.event.external.producer.ExternalEventProducer;
import org.apache.fineract.infrastructure.event.external.repository.ExternalEventRepository; import org.apache.fineract.infrastructure.event.external.repository.ExternalEventRepository;
import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEvent;
import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventStatus; import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventStatus;
import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventView;
import org.apache.fineract.infrastructure.event.external.service.message.MessageFactory; import org.apache.fineract.infrastructure.event.external.service.message.MessageFactory;
import org.apache.fineract.infrastructure.event.external.service.support.ByteBufferConverter; import org.apache.fineract.infrastructure.event.external.service.support.ByteBufferConverter;
import org.springframework.batch.core.StepContribution; import org.springframework.batch.core.StepContribution;
@@ -56,8 +65,9 @@ public class SendAsynchronousEventsTasklet implements Tasklet {
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
try { try {
if (isDownstreamChannelEnabled()) { if (isDownstreamChannelEnabled()) {
List<ExternalEvent> events = getQueuedEventsBatch(); List<ExternalEventView> events = getQueuedEventsBatch();
processEvents(events); log.debug("Queued events size: {}", events.size());
sendEvents(events);
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Error occurred while processing events: ", e); log.error("Error occurred while processing events: ", e);
@@ -69,20 +79,66 @@ public class SendAsynchronousEventsTasklet implements Tasklet {
return fineractProperties.getEvents().getExternal().getProducer().getJms().isEnabled(); return fineractProperties.getEvents().getExternal().getProducer().getJms().isEnabled();
} }
private List<ExternalEvent> getQueuedEventsBatch() { private List<ExternalEventView> getQueuedEventsBatch() {
int readBatchSize = getBatchSize(); int readBatchSize = getBatchSize();
Pageable batchSize = PageRequest.ofSize(readBatchSize); Pageable batchSize = PageRequest.ofSize(readBatchSize);
return repository.findByStatusOrderById(ExternalEventStatus.TO_BE_SENT, batchSize); return measure(() -> repository.findByStatusOrderById(ExternalEventStatus.TO_BE_SENT, batchSize),
(events, timeTaken) -> log.debug("Loaded {} events in {}ms", events.size(), timeTaken.toMillis()));
} }
private void processEvents(List<ExternalEvent> queuedEvents) throws IOException { private void sendEvents(List<ExternalEventView> queuedEvents) {
for (ExternalEvent event : queuedEvents) { Map<Long, List<byte[]>> partitions = generatePartitions(queuedEvents);
List<Long> eventIds = queuedEvents.stream().map(ExternalEventView::getId).toList();
sendEventsToProducer(partitions);
markEventsAsSent(eventIds);
}
private void sendEventsToProducer(Map<Long, List<byte[]>> partitions) {
eventProducer.sendEvents(partitions);
}
private void markEventsAsSent(List<Long> eventIds) {
OffsetDateTime sentAt = DateUtils.getOffsetDateTimeOfTenant();
// Partitioning dataset to avoid exception: PreparedStatement can have at most 65,535 parameters
List<List<Long>> partitions = Lists.partition(eventIds, 5_000);
partitions.forEach(partitionedEventIds -> {
measure(() -> {
repository.markEventsSent(partitionedEventIds, sentAt);
}, timeTaken -> {
log.debug("Took {}ms to update {} events", timeTaken.toMillis(), partitionedEventIds.size());
});
});
}
private Map<Long, List<byte[]>> generatePartitions(List<ExternalEventView> queuedEvents) {
Map<Long, List<ExternalEventView>> initialPartitions = queuedEvents.stream().collect(groupingBy(externalEvent -> {
Long aggregateRootId = externalEvent.getAggregateRootId();
if (aggregateRootId == null) {
aggregateRootId = -1L;
}
return aggregateRootId;
}));
Map<Long, List<byte[]>> partitions = measure(
() -> initialPartitions.entrySet().stream().collect(toMap(Map.Entry::getKey, e -> createMessages(e.getValue()))),
timeTaken -> {
log.debug("Took {}ms to create message partitions", timeTaken.toMillis());
});
return partitions;
}
private List<byte[]> createMessages(List<ExternalEventView> events) {
try {
List<byte[]> messages = new ArrayList<>();
for (ExternalEventView event : events) {
MessageV1 message = messageFactory.createMessage(event); MessageV1 message = messageFactory.createMessage(event);
byte[] byteMessage = byteBufferConverter.convert(message.toByteBuffer()); ByteBuffer toByteBuffer = message.toByteBuffer();
eventProducer.sendEvent(byteMessage); byte[] convert = byteBufferConverter.convert(toByteBuffer);
event.setStatus(ExternalEventStatus.SENT); messages.add(convert);
event.setSentAt(DateUtils.getOffsetDateTimeOfTenant()); }
repository.save(event); return messages;
} catch (IOException e) {
throw new RuntimeException("Error while serializing the message", e);
} }
} }
@@ -18,15 +18,11 @@
*/ */
package org.apache.fineract.infrastructure.event.external.producer; package org.apache.fineract.infrastructure.event.external.producer;
import java.util.List;
import java.util.Map;
import org.apache.fineract.infrastructure.event.external.exception.AcknowledgementTimeoutException; import org.apache.fineract.infrastructure.event.external.exception.AcknowledgementTimeoutException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.integration.annotation.Gateway;
import org.springframework.integration.annotation.MessagingGateway;
@MessagingGateway(name = "externalEventGateway")
@ConditionalOnProperty(value = "fineract.events.external.enabled", havingValue = "true")
public interface ExternalEventProducer { public interface ExternalEventProducer {
@Gateway(requestChannel = "outboundRequestsEvents", replyTimeout = 2, requestTimeout = 200) void sendEvents(Map<Long, List<byte[]>> partitions) throws AcknowledgementTimeoutException;
void sendEvent(byte[] message) throws AcknowledgementTimeoutException;
} }
@@ -18,16 +18,18 @@
*/ */
package org.apache.fineract.infrastructure.event.external.producer; package org.apache.fineract.infrastructure.event.external.producer;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.event.external.exception.AcknowledgementTimeoutException; import org.apache.fineract.infrastructure.event.external.exception.AcknowledgementTimeoutException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Component;
@Service @Component
@ConditionalOnProperty(value = "fineract.events.external.enabled", havingValue = "false") @ConditionalOnProperty(value = "fineract.events.external.producer.jms.enabled", havingValue = "false")
public class DummyExternalEventProducerImpl implements ExternalEventProducer { @Slf4j
public class NoopExternalEventProducer implements ExternalEventProducer {
@Override @Override
public void sendEvent(byte[] message) throws AcknowledgementTimeoutException { public void sendEvents(Map<Long, List<byte[]>> messages) throws AcknowledgementTimeoutException {}
return;
}
} }
@@ -0,0 +1,153 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.event.external.producer.jms;
import static org.apache.fineract.infrastructure.core.service.MeasuringUtil.measure;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Future;
import javax.jms.Connection;
import javax.jms.ConnectionFactory;
import javax.jms.Destination;
import javax.jms.JMSException;
import javax.jms.MessageProducer;
import javax.jms.Session;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.messaging.jms.MessageFactory;
import org.apache.fineract.infrastructure.core.service.HashingService;
import org.apache.fineract.infrastructure.event.external.exception.AcknowledgementTimeoutException;
import org.apache.fineract.infrastructure.event.external.producer.ExternalEventProducer;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.stereotype.Component;
@Component
@Slf4j
@RequiredArgsConstructor
@ConditionalOnProperty(value = "fineract.events.external.producer.jms.enabled", havingValue = "true")
public class JMSMultiExternalEventProducer implements ExternalEventProducer, InitializingBean {
@Qualifier("eventDestination")
private final Destination destination;
private final ConnectionFactory connectionFactory;
private final MessageFactory messageFactory;
@Qualifier("externalEventJmsProducerExecutor")
private final AsyncTaskExecutor taskExecutor;
private final HashingService hashingService;
private final FineractProperties fineractProperties;
private final List<MessageProducer> producers = new ArrayList<>();
@Override
public void afterPropertiesSet() throws Exception {
int producerCount = getProducerCount();
try (Connection connection = connectionFactory.createConnection()) {
for (int i = 0; i < producerCount; i++) {
// It's crucial to create the session within the loop, otherwise the producers won't be handled as
// parallel
// producers
Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);
MessageProducer producer = session.createProducer(destination);
producers.add(producer);
}
}
log.info("Initialized JMS multi producer for external events with {} parallel producers", producerCount);
}
private int getProducerCount() {
return fineractProperties.getEvents().getExternal().getProducer().getJms().getProducerCount();
}
@Override
public void sendEvents(Map<Long, List<byte[]>> partitions) throws AcknowledgementTimeoutException {
Map<Integer, List<byte[]>> indexedPartitions = mapPartitionsToProducers(partitions);
measure(() -> {
List<Future<?>> tasks = sendPartitions(indexedPartitions);
waitForSendingCompletion(tasks);
}, timeTaken -> {
if (log.isDebugEnabled()) {
// in case execution is faster than 1sec
long seconds = Math.max(timeTaken.toSeconds(), 1L);
Integer eventCount = partitions.values().stream().map(Collection::size).reduce(0, Integer::sum);
log.debug("Sent messages with {} msg/s", (eventCount / seconds));
}
});
}
private List<Future<?>> sendPartitions(Map<Integer, List<byte[]>> indexedPartitions) {
List<Future<?>> tasks = new ArrayList<>();
for (Map.Entry<Integer, List<byte[]>> entry : indexedPartitions.entrySet()) {
Integer producerIndex = entry.getKey();
List<byte[]> messages = entry.getValue();
Future<?> future = createSendingTask(producerIndex, messages);
tasks.add(future);
}
return tasks;
}
private Future<?> createSendingTask(Integer producerIndex, List<byte[]> messages) {
return taskExecutor.submit(() -> {
MessageProducer messageProducer = producers.get(producerIndex);
for (byte[] message : messages) {
try {
messageProducer.send(destination, messageFactory.createByteMessage(message));
} catch (JMSException e) {
throw new RuntimeException("Error while sending the message", e);
}
}
});
}
private Map<Integer, List<byte[]>> mapPartitionsToProducers(Map<Long, List<byte[]>> partitions) {
Map<Integer, List<byte[]>> indexedPartitions = new HashMap<>();
for (Map.Entry<Long, List<byte[]>> partition : partitions.entrySet()) {
Long key = partition.getKey();
List<byte[]> messages = partition.getValue();
int producerIndex = hashingService.consistentHash(key, getProducerCount());
indexedPartitions.putIfAbsent(producerIndex, new ArrayList<>());
indexedPartitions.get(producerIndex).addAll(messages);
}
return indexedPartitions;
}
private void waitForSendingCompletion(List<Future<?>> tasks) {
try {
for (Future<?> task : tasks) {
task.get();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
@@ -19,9 +19,11 @@
package org.apache.fineract.infrastructure.event.external.repository; package org.apache.fineract.infrastructure.event.external.repository;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEvent; import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEvent;
import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventStatus; import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventStatus;
import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventView;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Modifying;
@@ -29,9 +31,13 @@ import org.springframework.data.jpa.repository.Query;
public interface ExternalEventRepository extends JpaRepository<ExternalEvent, Long> { public interface ExternalEventRepository extends JpaRepository<ExternalEvent, Long> {
List<ExternalEvent> findByStatusOrderById(ExternalEventStatus status, Pageable batchSize); List<ExternalEventView> findByStatusOrderById(ExternalEventStatus status, Pageable batchSize);
@Modifying(flushAutomatically = true) @Modifying(flushAutomatically = true)
@Query("delete from ExternalEvent e where e.status = :status and e.businessDate <= :dateForPurgeCriteria") @Query("delete from ExternalEvent e where e.status = :status and e.businessDate <= :dateForPurgeCriteria")
void deleteOlderEventsWithSentStatus(ExternalEventStatus status, LocalDate dateForPurgeCriteria); void deleteOlderEventsWithSentStatus(ExternalEventStatus status, LocalDate dateForPurgeCriteria);
@Modifying
@Query("UPDATE ExternalEvent e SET e.status = org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventStatus.SENT, e.sentAt = :sentAt WHERE e.id IN :ids")
void markEventsSent(List<Long> ids, OffsetDateTime sentAt);
} }
@@ -70,7 +70,10 @@ public class ExternalEvent extends AbstractPersistableCustom {
@Column(name = "business_date", nullable = false) @Column(name = "business_date", nullable = false)
private LocalDate businessDate; private LocalDate businessDate;
public ExternalEvent(String type, String category, String schema, byte[] data, String idempotencyKey) { @Column(name = "aggregate_root_id", nullable = true)
private Long aggregateRootId;
public ExternalEvent(String type, String category, String schema, byte[] data, String idempotencyKey, Long aggregateRootId) {
this.type = type; this.type = type;
this.category = category; this.category = category;
this.schema = schema; this.schema = schema;
@@ -0,0 +1,47 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.event.external.repository.domain;
import java.time.LocalDate;
import java.time.OffsetDateTime;
public interface ExternalEventView {
Long getId();
String getType();
String getCategory();
String getSchema();
byte[] getData();
OffsetDateTime getCreatedAt();
ExternalEventStatus getStatus();
OffsetDateTime getSentAt();
String getIdempotencyKey();
LocalDate getBusinessDate();
Long getAggregateRootId();
}
@@ -85,7 +85,7 @@ public class ExternalEventService {
byte[] data = byteBufferConverter.convert(avroDto.toByteBuffer()); byte[] data = byteBufferConverter.convert(avroDto.toByteBuffer());
return new ExternalEvent(bulkBusinessEvent.getType(), bulkBusinessEvent.getCategory(), BulkMessagePayloadV1.class.getName(), data, return new ExternalEvent(bulkBusinessEvent.getType(), bulkBusinessEvent.getCategory(), BulkMessagePayloadV1.class.getName(), data,
idempotencyKey); idempotencyKey, bulkBusinessEvent.getAggregateRootId());
} }
private <T> ExternalEvent handleRegularBusinessEvent(BusinessEvent<T> event) throws IOException { private <T> ExternalEvent handleRegularBusinessEvent(BusinessEvent<T> event) throws IOException {
@@ -95,8 +95,9 @@ public class ExternalEventService {
BusinessEventSerializer serializer = serializerFactory.create(event); BusinessEventSerializer serializer = serializerFactory.create(event);
String schema = serializer.getSupportedSchema().getName(); String schema = serializer.getSupportedSchema().getName();
byte[] data = serializer.serialize(event); byte[] data = serializer.serialize(event);
Long aggregateRootId = event.getAggregateRootId();
return new ExternalEvent(eventType, eventCategory, schema, data, idempotencyKey); return new ExternalEvent(eventType, eventCategory, schema, data, idempotencyKey, aggregateRootId);
} }
private void flushChangesBeforeSerialization() { private void flushChangesBeforeSerialization() {
@@ -27,7 +27,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.avro.MessageV1; import org.apache.fineract.avro.MessageV1;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEvent; import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventView;
import org.apache.fineract.infrastructure.event.external.service.message.domain.MessageBusinessDate; import org.apache.fineract.infrastructure.event.external.service.message.domain.MessageBusinessDate;
import org.apache.fineract.infrastructure.event.external.service.message.domain.MessageCategory; import org.apache.fineract.infrastructure.event.external.service.message.domain.MessageCategory;
import org.apache.fineract.infrastructure.event.external.service.message.domain.MessageCreatedAt; import org.apache.fineract.infrastructure.event.external.service.message.domain.MessageCreatedAt;
@@ -66,7 +66,7 @@ public class MessageFactory implements InitializingBean {
return result; return result;
} }
public MessageV1 createMessage(ExternalEvent event) { public MessageV1 createMessage(ExternalEventView event) {
MessageId id = new MessageId(event.getId().intValue()); MessageId id = new MessageId(event.getId().intValue());
MessageSource source = new MessageSource(SOURCE_UUID); MessageSource source = new MessageSource(SOURCE_UUID);
MessageType type = new MessageType(event.getType()); MessageType type = new MessageType(event.getType());
@@ -19,5 +19,5 @@
package org.apache.fineract.infrastructure.jobs.service; package org.apache.fineract.infrastructure.jobs.service;
public enum StepName { public enum StepName {
PURGE_PROCESSED_COMMANDS_STEP; PURGE_PROCESSED_COMMANDS_STEP, SEND_ASYNCHRONOUS_EVENTS_STEP
} }
@@ -62,6 +62,7 @@ fineract.events.external.producer.jms.event-topic-name=${FINERACT_EXTERNAL_EVENT
fineract.events.external.producer.jms.broker-url=${FINERACT_EXTERNAL_EVENTS_PRODUCER_JMS_BROKER_URL:tcp://127.0.0.1:61616} fineract.events.external.producer.jms.broker-url=${FINERACT_EXTERNAL_EVENTS_PRODUCER_JMS_BROKER_URL:tcp://127.0.0.1:61616}
fineract.events.external.producer.jms.broker-username=${FINERACT_EXTERNAL_EVENTS_PRODUCER_JMS_BROKER_USERNAME:} fineract.events.external.producer.jms.broker-username=${FINERACT_EXTERNAL_EVENTS_PRODUCER_JMS_BROKER_USERNAME:}
fineract.events.external.producer.jms.broker-password=${FINERACT_EXTERNAL_EVENTS_PRODUCER_JMS_BROKER_PASSWORD:} fineract.events.external.producer.jms.broker-password=${FINERACT_EXTERNAL_EVENTS_PRODUCER_JMS_BROKER_PASSWORD:}
fineract.events.external.producer.jms.producer-count=${FINERACT_EXTERNAL_EVENTS_PRODUCER_JMS_PRODUCER_COUNT:1}
fineract.idempotency-key-header-name=${FINERACT_IDEMPOTENCY_KEY_HEADER_NAME:Idempotency-Key} fineract.idempotency-key-header-name=${FINERACT_IDEMPOTENCY_KEY_HEADER_NAME:Idempotency-Key}
@@ -104,4 +104,5 @@
<include file="parts/0082_add_external_event_default_configuration.xml" relativeToChangelogFile="true" /> <include file="parts/0082_add_external_event_default_configuration.xml" relativeToChangelogFile="true" />
<include file="parts/0083_add_loan_transaction_enum_values.xml" relativeToChangelogFile="true" /> <include file="parts/0083_add_loan_transaction_enum_values.xml" relativeToChangelogFile="true" />
<include file="parts/0084_add_general_accounting_table_reports.xml" relativeToChangelogFile="true" /> <include file="parts/0084_add_general_accounting_table_reports.xml" relativeToChangelogFile="true" />
<include file="parts/0085_add_aggregate_root_id_external_events.xml" relativeToChangelogFile="true" />
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd">
<changeSet author="fineract" id="1">
<addColumn tableName="m_external_event">
<column name="aggregate_root_id" type="BIGINT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,62 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.core.service;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Random;
import org.junit.jupiter.api.Test;
class HashingServiceTest {
private final Random rnd = new Random();
private HashingService underTest = new HashingService();
@Test
public void testConsistentHashGeneratesHashesConsistentlyForTheSameValue() {
// given
int initialResult = underTest.consistentHash(1L, 20);
// when & then
for (int i = 0; i < 20_000; i++) {
int result = underTest.consistentHash(1L, 20);
assertThat(result).isEqualTo(initialResult);
}
}
@Test
public void testConsistentHashWorksForNegativeValues() {
// given
// when
int result = underTest.consistentHash(-1L, 20);
// then
assertThat(result).isBetween(0, 19);
}
@Test
public void testConsistentHashGeneratesHashesWithinBuckets() {
// given
int buckets = 10;
// when & then
for (int i = 0; i < 20_000; i++) {
int result = underTest.consistentHash(rnd.nextLong(), buckets);
assertThat(result).isLessThan(buckets);
}
}
}
@@ -0,0 +1,64 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.event.business.domain;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import java.util.List;
import org.apache.fineract.infrastructure.event.business.domain.client.ClientActivateBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.client.ClientCreateBusinessEvent;
import org.apache.fineract.portfolio.client.domain.Client;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class BulkBusinessEventTest {
@Test
public void testConstructorWorksForSameAggregateId() {
// given
// when
new BulkBusinessEvent(List.of(new ClientCreateBusinessEvent(client(1L)), new ClientActivateBusinessEvent(client(1L))));
// then no exception thrown
}
@Test
public void testConstructorThrowsExceptionForDifferentAggregateId() {
// given
// when
Assertions.assertThrows(IllegalArgumentException.class, () -> new BulkBusinessEvent(
List.of(new ClientCreateBusinessEvent(client(1L)), new ClientActivateBusinessEvent(client(2L)))));
// then no exception thrown
}
@Test
public void testConstructorWorksForNullAggregateId() {
// given
// when
new BulkBusinessEvent(List.of(new ClientCreateBusinessEvent(client(1L)), new ClientActivateBusinessEvent(client(null))));
// then no exception thrown
}
private Client client(Long id) {
Client client = mock(Client.class);
given(client.getId()).willReturn(id);
return client;
}
}
@@ -226,6 +226,11 @@ class BusinessEventNotifierServiceImplTest {
public String getCategory() { public String getCategory() {
return null; return null;
} }
@Override
public Long getAggregateRootId() {
return null;
}
} }
} }
@@ -20,7 +20,6 @@ package org.apache.fineract.infrastructure.event.external.jobs;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -34,6 +33,7 @@ import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Random;
import org.apache.fineract.avro.MessageV1; import org.apache.fineract.avro.MessageV1;
import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
@@ -43,8 +43,7 @@ import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.event.external.exception.AcknowledgementTimeoutException; import org.apache.fineract.infrastructure.event.external.exception.AcknowledgementTimeoutException;
import org.apache.fineract.infrastructure.event.external.producer.ExternalEventProducer; import org.apache.fineract.infrastructure.event.external.producer.ExternalEventProducer;
import org.apache.fineract.infrastructure.event.external.repository.ExternalEventRepository; import org.apache.fineract.infrastructure.event.external.repository.ExternalEventRepository;
import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEvent; import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventView;
import org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventStatus;
import org.apache.fineract.infrastructure.event.external.service.message.MessageFactory; import org.apache.fineract.infrastructure.event.external.service.message.MessageFactory;
import org.apache.fineract.infrastructure.event.external.service.support.ByteBufferConverter; import org.apache.fineract.infrastructure.event.external.service.support.ByteBufferConverter;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -54,12 +53,15 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.batch.core.StepContribution; import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext; import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.repeat.RepeatStatus; import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SendAsynchronousEventsTaskletTest { class SendAsynchronousEventsTaskletTest {
@Mock @Mock
@@ -81,6 +83,8 @@ class SendAsynchronousEventsTaskletTest {
private SendAsynchronousEventsTasklet underTest; private SendAsynchronousEventsTasklet underTest;
private RepeatStatus resultStatus; private RepeatStatus resultStatus;
private Random rnd = new Random();
@BeforeEach @BeforeEach
public void setUp() { public void setUp() {
ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null)); ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null));
@@ -108,8 +112,9 @@ class SendAsynchronousEventsTaskletTest {
@Test @Test
public void givenBatchSize2WhenTaskExecutionThenSend2Events() throws Exception { public void givenBatchSize2WhenTaskExecutionThenSend2Events() throws Exception {
// given // given
List<ExternalEvent> events = Arrays.asList(new ExternalEvent("aType", "aCategory", "aSchema", new byte[0], "aIdempotencyKey"), List<ExternalEventView> events = Arrays.asList(
new ExternalEvent("aType", "aCategory", "aSchema", new byte[0], "aIdempotencyKey")); createExternalEventView("aType", "aCategory", "aSchema", new byte[0], "aIdempotencyKey", 1L),
createExternalEventView("aType", "aCategory", "aSchema", new byte[0], "aIdempotencyKey", 1L));
// Dummy Message // Dummy Message
MessageV1 dummyMessage = new MessageV1(1, "aSource", "aType", "nocategory", "aCreateDate", "aBusinessDate", "aTenantId", MessageV1 dummyMessage = new MessageV1(1, "aSource", "aType", "nocategory", "aCreateDate", "aBusinessDate", "aTenantId",
"anidempotencyKey", "aSchema", Mockito.mock(ByteBuffer.class)); "anidempotencyKey", "aSchema", Mockito.mock(ByteBuffer.class));
@@ -117,64 +122,95 @@ class SendAsynchronousEventsTaskletTest {
when(repository.findByStatusOrderById(Mockito.any(), Mockito.any())).thenReturn(events); when(repository.findByStatusOrderById(Mockito.any(), Mockito.any())).thenReturn(events);
when(messageFactory.createMessage(Mockito.any())).thenReturn(dummyMessage); when(messageFactory.createMessage(Mockito.any())).thenReturn(dummyMessage);
when(byteBufferConverter.convert(Mockito.any(ByteBuffer.class))).thenReturn(new byte[0]); when(byteBufferConverter.convert(Mockito.any(ByteBuffer.class))).thenReturn(new byte[0]);
doNothing().when(eventProducer).sendEvent(Mockito.any());
// when // when
resultStatus = this.underTest.execute(stepContribution, chunkContext); resultStatus = underTest.execute(stepContribution, chunkContext);
// then // then
verify(eventProducer, times(2)).sendEvent(new byte[0]); verify(eventProducer).sendEvents(Mockito.any());
verify(repository, times(2)).save(Mockito.any(ExternalEvent.class)); verify(repository).markEventsSent(Mockito.eq(events.stream().map(ExternalEventView::getId).toList()), Mockito.any());
assertEquals(RepeatStatus.FINISHED, resultStatus); assertEquals(RepeatStatus.FINISHED, resultStatus);
} }
@Test @Test
public void givenBatchSize2WhenEventSendFailsThenExecutionStops() throws Exception { public void givenBatchSize2WhenEventSendFailsThenExecutionStops() throws Exception {
// given // given
List<ExternalEvent> events = Arrays.asList(new ExternalEvent("aType", "aCategory", "aSchema", new byte[0], "aIdempotencyKey"), List<ExternalEventView> events = Arrays.asList(
new ExternalEvent("aType", "aCategory", "aSchema", new byte[0], "aIdempotencyKey")); createExternalEventView("aType", "aCategory", "aSchema", new byte[0], "aIdempotencyKey", 1L),
createExternalEventView("aType", "aCategory", "aSchema", new byte[0], "aIdempotencyKey", 1L));
MessageV1 dummyMessage = new MessageV1(1, "aSource", "aType", "nocategory", "aCreateDate", "aBusinessDate", "aTenantId", MessageV1 dummyMessage = new MessageV1(1, "aSource", "aType", "nocategory", "aCreateDate", "aBusinessDate", "aTenantId",
"anidempotencyKey", "aSchema", Mockito.mock(ByteBuffer.class)); "anidempotencyKey", "aSchema", Mockito.mock(ByteBuffer.class));
when(repository.findByStatusOrderById(Mockito.any(), Mockito.any())).thenReturn(events); when(repository.findByStatusOrderById(Mockito.any(), Mockito.any())).thenReturn(events);
when(messageFactory.createMessage(Mockito.any())).thenReturn(dummyMessage); when(messageFactory.createMessage(Mockito.any())).thenReturn(dummyMessage);
when(byteBufferConverter.convert(Mockito.any(ByteBuffer.class))).thenReturn(new byte[0]); when(byteBufferConverter.convert(Mockito.any(ByteBuffer.class))).thenReturn(new byte[0]);
doThrow(new AcknowledgementTimeoutException("Event Send Exception", new RuntimeException())).when(eventProducer) doThrow(new AcknowledgementTimeoutException("Event Send Exception", new RuntimeException())).when(eventProducer)
.sendEvent(Mockito.any()); .sendEvents(Mockito.any());
// when // when
resultStatus = this.underTest.execute(stepContribution, chunkContext); resultStatus = underTest.execute(stepContribution, chunkContext);
// then // then
verify(repository, times(0)).save(Mockito.any(ExternalEvent.class)); verify(repository, times(0)).markEventsSent(Mockito.any(), Mockito.any());
assertEquals(RepeatStatus.FINISHED, resultStatus); assertEquals(RepeatStatus.FINISHED, resultStatus);
} }
@Test @Test
public void givenOneEventWhenEventSentThenEventStatusUpdates() throws Exception { public void givenOneEventWhenEventSentThenEventStatusUpdates() throws Exception {
// given // given
ArgumentCaptor<ExternalEvent> externalEventArgumentCaptor = ArgumentCaptor.forClass(ExternalEvent.class); List<ExternalEventView> events = Arrays
List<ExternalEvent> events = Arrays.asList(new ExternalEvent("aType", "aCategory", "aSchema", new byte[0], "aIdempotencyKey")); .asList(createExternalEventView("aType", "aCategory", "aSchema", new byte[0], "aIdempotencyKey", 1L));
MessageV1 dummyMessage = new MessageV1(1, "aSource", "aType", "nocategory", "aCreateDate", "aBusinessDate", "aTenantId", MessageV1 dummyMessage = new MessageV1(1, "aSource", "aType", "nocategory", "aCreateDate", "aBusinessDate", "aTenantId",
"anidempotencyKey", "aSchema", Mockito.mock(ByteBuffer.class)); "anidempotencyKey", "aSchema", Mockito.mock(ByteBuffer.class));
when(repository.findByStatusOrderById(Mockito.any(), Mockito.any())).thenReturn(events); when(repository.findByStatusOrderById(Mockito.any(), Mockito.any())).thenReturn(events);
when(messageFactory.createMessage(Mockito.any())).thenReturn(dummyMessage); when(messageFactory.createMessage(Mockito.any())).thenReturn(dummyMessage);
when(byteBufferConverter.convert(Mockito.any(ByteBuffer.class))).thenReturn(new byte[0]); when(byteBufferConverter.convert(Mockito.any(ByteBuffer.class))).thenReturn(new byte[0]);
doNothing().when(eventProducer).sendEvent(Mockito.any());
// when // when
resultStatus = this.underTest.execute(stepContribution, chunkContext); resultStatus = underTest.execute(stepContribution, chunkContext);
// then // then
verify(messageFactory).createMessage(Mockito.any()); verify(messageFactory).createMessage(Mockito.any());
verify(repository).save(externalEventArgumentCaptor.capture()); verify(eventProducer).sendEvents(Mockito.any());
ExternalEvent externalEvent = externalEventArgumentCaptor.getValue(); verify(repository).markEventsSent(Mockito.eq(events.stream().map(ExternalEventView::getId).toList()), Mockito.any());
assertThat(externalEvent.getStatus()).isEqualTo(ExternalEventStatus.SENT); assertEquals(RepeatStatus.FINISHED, resultStatus);
}
@Test
public void testExecuteShouldHandleNullAggregateId() throws Exception {
// given
List<ExternalEventView> events = Arrays
.asList(createExternalEventView("aType", "aCategory", "aSchema", new byte[0], "aIdempotencyKey", null));
MessageV1 dummyMessage = new MessageV1(1, "aSource", "aType", "nocategory", "aCreateDate", "aBusinessDate", "aTenantId",
"anidempotencyKey", "aSchema", Mockito.mock(ByteBuffer.class));
when(repository.findByStatusOrderById(Mockito.any(), Mockito.any())).thenReturn(events);
when(messageFactory.createMessage(Mockito.any())).thenReturn(dummyMessage);
byte[] byteMsg = new byte[0];
when(byteBufferConverter.convert(Mockito.any(ByteBuffer.class))).thenReturn(byteMsg);
// when
resultStatus = underTest.execute(stepContribution, chunkContext);
// then
verify(messageFactory).createMessage(Mockito.any());
verify(eventProducer).sendEvents(Map.of(-1L, List.of(byteMsg)));
verify(repository).markEventsSent(Mockito.eq(events.stream().map(ExternalEventView::getId).toList()), Mockito.any());
assertEquals(RepeatStatus.FINISHED, resultStatus); assertEquals(RepeatStatus.FINISHED, resultStatus);
} }
@Test @Test
public void givenEventBatchSizeIsConfiguredAs10WhenTaskExecutionThenEventReadPageSizeIsCorrect() { public void givenEventBatchSizeIsConfiguredAs10WhenTaskExecutionThenEventReadPageSizeIsCorrect() {
ArgumentCaptor<Pageable> externalEventPageSizeArgumentCaptor = ArgumentCaptor.forClass(Pageable.class); ArgumentCaptor<Pageable> externalEventPageSizeArgumentCaptor = ArgumentCaptor.forClass(Pageable.class);
List<ExternalEvent> events = new ArrayList<>(); List<ExternalEventView> events = new ArrayList<>();
when(repository.findByStatusOrderById(Mockito.any(), Mockito.any())).thenReturn(events); when(repository.findByStatusOrderById(Mockito.any(), Mockito.any())).thenReturn(events);
// when // when
resultStatus = this.underTest.execute(stepContribution, chunkContext); resultStatus = underTest.execute(stepContribution, chunkContext);
// then // then
verify(repository).findByStatusOrderById(Mockito.any(), externalEventPageSizeArgumentCaptor.capture()); verify(repository).findByStatusOrderById(Mockito.any(), externalEventPageSizeArgumentCaptor.capture());
assertThat(externalEventPageSizeArgumentCaptor.getValue().getPageSize()).isEqualTo(10); assertThat(externalEventPageSizeArgumentCaptor.getValue().getPageSize()).isEqualTo(10);
} }
private ExternalEventView createExternalEventView(String type, String category, String schema, byte[] data, String idempotencyKey,
Long aggregateRootId) {
ExternalEventView result = Mockito.mock(ExternalEventView.class);
Mockito.when(result.getId()).thenReturn(rnd.nextLong());
Mockito.when(result.getType()).thenReturn(type);
Mockito.when(result.getCategory()).thenReturn(category);
Mockito.when(result.getSchema()).thenReturn(schema);
Mockito.when(result.getData()).thenReturn(data);
Mockito.when(result.getIdempotencyKey()).thenReturn(idempotencyKey);
Mockito.when(result.getAggregateRootId()).thenReturn(aggregateRootId);
return result;
}
} }
@@ -1,120 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.event.external.producer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.concurrent.atomic.AtomicInteger;
import javax.jms.ConnectionFactory;
import org.apache.activemq.ActiveMQConnectionFactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.annotation.IntegrationComponentScan;
import org.springframework.integration.channel.DirectChannel;
import org.springframework.integration.config.EnableIntegration;
import org.springframework.integration.config.GlobalChannelInterceptor;
import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.dsl.IntegrationFlows;
import org.springframework.integration.handler.LoggingHandler;
import org.springframework.integration.jms.dsl.Jms;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.stereotype.Component;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@ExtendWith(SpringExtension.class)
@ContextConfiguration
@DirtiesContext
@TestPropertySource(properties = { "fineract.events.external.enabled=true" })
public class EventsJMSIntegrationTest {
@Autowired
@Qualifier("outboundRequestsEvents")
private DirectChannel outboundRequestsEvents;
@Autowired
private ContextConfiguration.TestChannelInterceptor testChannelInterceptor;
@Autowired
private ExternalEventProducer underTest;
@Test
public void testJmsDownstreamChannelIntegration() {
assertThat(outboundRequestsEvents.getSubscriberCount()).isEqualTo(1);
}
@Test
void given2EventsThenOutBoundChannelIsInvokedTwice() {
// when
underTest.sendEvent(new byte[0]);
underTest.sendEvent(new byte[0]);
// then
assertTrue(outboundRequestsEvents.getInterceptors().contains(this.testChannelInterceptor));
assertThat(testChannelInterceptor.getInvoked()).isEqualTo(2);
}
@Configuration
@IntegrationComponentScan
@EnableIntegration
public static class ContextConfiguration {
private static ConnectionFactory connectionFactory = new ActiveMQConnectionFactory("vm://localhost?broker.persistent=false");
@Bean
public DirectChannel outboundRequestsEvents() {
return new DirectChannel();
}
@Bean
public IntegrationFlow outboundFlow() {
return IntegrationFlows.from("outboundRequestsEvents") //
.log(LoggingHandler.Level.DEBUG) //
.handle(Jms.outboundAdapter(connectionFactory).destination("destinationChannel")).get();
}
@Component
@GlobalChannelInterceptor(patterns = "outboundRequestsEvents")
public static class TestChannelInterceptor implements ChannelInterceptor {
private final AtomicInteger invoked = new AtomicInteger();
public int getInvoked() {
return invoked.get();
}
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
this.invoked.incrementAndGet();
return message;
}
}
}
}
@@ -0,0 +1,200 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.event.external.producer.jms;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Random;
import javax.jms.BytesMessage;
import javax.jms.Connection;
import javax.jms.Destination;
import javax.jms.JMSException;
import javax.jms.MessageProducer;
import javax.jms.Session;
import org.apache.activemq.ActiveMQConnectionFactory;
import org.apache.fineract.avro.MessageV1;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
import org.apache.fineract.infrastructure.core.messaging.jms.MessageFactory;
import org.apache.fineract.infrastructure.core.service.HashingService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class JMSMultiExternalEventProducerTest {
private static final int PRODUCER_COUNT = 3;
private final Random rnd = new Random();
@Mock
private Destination destination;
@Mock
private ActiveMQConnectionFactory connectionFactory;
@Mock
private MessageFactory messageFactory;
@Mock
private HashingService hashingService;
private AsyncTaskExecutor taskExecutor = new SimpleAsyncTaskExecutor();
@Mock
private Connection connection;
@Mock
private Session session1;
@Mock
private Session session2;
@Mock
private Session session3;
@Mock
private MessageProducer producer1;
@Mock
private MessageProducer producer2;
@Mock
private MessageProducer producer3;
private JMSMultiExternalEventProducer underTest;
private FineractProperties fineractProperties;
private List<MessageProducer> producers = new ArrayList<>();
@Test
public void testAfterPropertiesShouldCreateMultipleSessions() throws Exception {
// given
initializeMocks();
// when
underTest.afterPropertiesSet();
// then
verify(connection, times(3)).createSession(false, Session.AUTO_ACKNOWLEDGE);
verify(session1).createProducer(destination);
verify(session2).createProducer(destination);
verify(session3).createProducer(destination);
verify(connection).close();
}
@Test
public void testSendEventsShouldWork() throws Exception {
// given
initializeMocks();
underTest.afterPropertiesSet();
byte[] msg1 = createMessage();
List<byte[]> messages = new ArrayList<>();
messages.add(msg1);
Map<Long, List<byte[]>> partitions = Map.of(1L, messages);
BytesMessage bytesMsg1 = Mockito.mock(BytesMessage.class);
given(messageFactory.createByteMessage(msg1)).willReturn(bytesMsg1);
given(hashingService.consistentHash(1L, producers.size())).willReturn(0);
// when
underTest.sendEvents(partitions);
// then
verify(producer1).send(destination, bytesMsg1);
}
@Test
public void testSendEventsBalancesBetweenProducers() throws Exception {
// given
initializeMocks();
underTest.afterPropertiesSet();
byte[] msg1 = createMessage();
byte[] msg2 = createMessage();
byte[] msg3 = createMessage();
List<byte[]> messages1 = new ArrayList<>();
messages1.add(msg1);
List<byte[]> messages2 = new ArrayList<>();
messages2.add(msg2);
List<byte[]> messages3 = new ArrayList<>();
messages3.add(msg3);
Map<Long, List<byte[]>> partitions = Map.of(1L, messages1, 2L, messages2, 3L, messages3);
BytesMessage bytesMsg1 = Mockito.mock(BytesMessage.class);
BytesMessage bytesMsg2 = Mockito.mock(BytesMessage.class);
BytesMessage bytesMsg3 = Mockito.mock(BytesMessage.class);
given(messageFactory.createByteMessage(msg1)).willReturn(bytesMsg1);
given(messageFactory.createByteMessage(msg2)).willReturn(bytesMsg2);
given(messageFactory.createByteMessage(msg3)).willReturn(bytesMsg3);
given(hashingService.consistentHash(1L, producers.size())).willReturn(0);
given(hashingService.consistentHash(2L, producers.size())).willReturn(1);
given(hashingService.consistentHash(3L, producers.size())).willReturn(2);
// when
underTest.sendEvents(partitions);
// then
verify(producer1).send(destination, bytesMsg1);
verify(producer2).send(destination, bytesMsg2);
verify(producer3).send(destination, bytesMsg3);
}
private byte[] createMessage() throws IOException {
MessageV1 messageV1 = new MessageV1();
messageV1.setId(rnd.nextInt());
messageV1.setSource("");
messageV1.setBusinessDate("");
messageV1.setCategory("");
messageV1.setCreatedAt("");
messageV1.setDataschema("");
messageV1.setIdempotencyKey("");
messageV1.setTenantId("");
messageV1.setType("");
messageV1.setData(ByteBuffer.wrap(new byte[0]));
return messageV1.toByteBuffer().array();
}
private void initializeMocks() throws JMSException {
FineractProperties.FineractExternalEventsProducerJmsProperties jms = new FineractProperties.FineractExternalEventsProducerJmsProperties();
jms.setProducerCount(PRODUCER_COUNT);
FineractProperties.FineractExternalEventsProducerProperties producer = new FineractProperties.FineractExternalEventsProducerProperties();
producer.setJms(jms);
FineractProperties.FineractExternalEventsProperties external = new FineractProperties.FineractExternalEventsProperties();
external.setProducer(producer);
FineractProperties.FineractEventsProperties events = new FineractProperties.FineractEventsProperties();
events.setExternal(external);
fineractProperties = new FineractProperties();
fineractProperties.setEvents(events);
underTest = new JMSMultiExternalEventProducer(destination, connectionFactory, messageFactory, taskExecutor, hashingService,
fineractProperties);
given(connectionFactory.createConnection()).willReturn(connection);
given(connection.createSession(false, Session.AUTO_ACKNOWLEDGE)).willReturn(session1, session2, session3);
given(session1.createProducer(destination)).willReturn(producer1);
given(session2.createProducer(destination)).willReturn(producer2);
given(session3.createProducer(destination)).willReturn(producer3);
producers.add(producer1);
producers.add(producer2);
producers.add(producer3);
}
}
+2
View File
@@ -19,3 +19,5 @@
config.stopBubbling = true config.stopBubbling = true
lombok.addLombokGeneratedAnnotation = true lombok.addLombokGeneratedAnnotation = true
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier