diff --git a/persistence-service-v1/persistence-service-processor-v1/src/main/java/com/iqser/red/service/persistence/management/v1/processor/service/persistence/NotificationPreferencesPersistenceService.java b/persistence-service-v1/persistence-service-processor-v1/src/main/java/com/iqser/red/service/persistence/management/v1/processor/service/persistence/NotificationPreferencesPersistenceService.java index cb125312b..d6605b8ec 100644 --- a/persistence-service-v1/persistence-service-processor-v1/src/main/java/com/iqser/red/service/persistence/management/v1/processor/service/persistence/NotificationPreferencesPersistenceService.java +++ b/persistence-service-v1/persistence-service-processor-v1/src/main/java/com/iqser/red/service/persistence/management/v1/processor/service/persistence/NotificationPreferencesPersistenceService.java @@ -10,6 +10,8 @@ import java.util.stream.Collectors; import javax.transaction.Transactional; import org.springframework.beans.BeanUtils; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; import org.springframework.stereotype.Service; import com.iqser.red.service.persistence.management.v1.processor.entity.notification.NotificationPreferencesEntity; @@ -18,15 +20,20 @@ import com.iqser.red.service.persistence.management.v1.processor.service.persist import com.iqser.red.service.persistence.service.v1.api.model.notification.NotificationPreferences; import com.iqser.red.service.persistence.service.v1.api.model.notification.NotificationType; +import lombok.AccessLevel; import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; @Service @RequiredArgsConstructor +@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) public class NotificationPreferencesPersistenceService { - private final NotificationPreferencesRepository notificationPreferencesRepository; + NotificationPreferencesRepository notificationPreferencesRepository; - private final NotificationRepository notificationRepository; + NonThreadSafeNotificationPreferencesRepositoryWrapper notificationPreferencesRepositoryWrapper; + + NotificationRepository notificationRepository; @Transactional @@ -60,31 +67,28 @@ public class NotificationPreferencesPersistenceService { @Transactional public void deleteNotificationPreferences(String userId) { - notificationPreferencesRepository.deleteById(userId); + notificationPreferencesRepository.deleteByUserId(userId); } - @Transactional + // This method intentionally does not have a @Transactional annotation, since it needs to handle an underlying transaction exception. public NotificationPreferencesEntity getOrCreateNotificationPreferences(String userId) { - return notificationPreferencesRepository.findById(userId).orElseGet(() -> { - - var notificationPreference = new NotificationPreferencesEntity(); - notificationPreference.setUserId(userId); - notificationPreference.setEmailNotificationsEnabled(false); - notificationPreference.setInAppNotificationsEnabled(true); - notificationPreference.setInAppNotifications(Arrays.stream(NotificationType.values()).map(Enum::name).collect(Collectors.toList())); - return notificationPreferencesRepository.save(notificationPreference); - }); + try { + // The method called here will fail if it is called concurrently (more than 1 thread), since it will always try to create + // the desired entity. But the exception only means, that the entity has been created by another thread. + // In that case we can just fetch the data from the db. + return notificationPreferencesRepositoryWrapper.getOrCreateNotificationPreferences(userId); + } catch (DataIntegrityViolationException ex) { + return notificationPreferencesRepository.getByUserId(userId); + } } @Transactional public void initializePreferencesIfNotExists(String userId) { - if (!notificationPreferencesRepository.existsByUserId(userId)) { - getOrCreateNotificationPreferences(userId); - } + getOrCreateNotificationPreferences(userId); } @@ -93,4 +97,29 @@ public class NotificationPreferencesPersistenceService { return notificationPreferencesRepository.findAll(); } + + @Component + @RequiredArgsConstructor + @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) + private static class NonThreadSafeNotificationPreferencesRepositoryWrapper { + + NotificationPreferencesRepository notificationPreferencesRepository; + + + @Transactional(Transactional.TxType.REQUIRES_NEW) + public NotificationPreferencesEntity getOrCreateNotificationPreferences(String userId) { + + return notificationPreferencesRepository.findByUserId(userId).orElseGet(() -> { + + var notificationPreference = new NotificationPreferencesEntity(); + notificationPreference.setUserId(userId); + notificationPreference.setEmailNotificationsEnabled(false); + notificationPreference.setInAppNotificationsEnabled(true); + notificationPreference.setInAppNotifications(Arrays.stream(NotificationType.values()).map(Enum::name).collect(Collectors.toList())); + return notificationPreferencesRepository.save(notificationPreference); + }); + } + + } + } diff --git a/persistence-service-v1/persistence-service-processor-v1/src/main/java/com/iqser/red/service/persistence/management/v1/processor/service/persistence/repository/NotificationPreferencesRepository.java b/persistence-service-v1/persistence-service-processor-v1/src/main/java/com/iqser/red/service/persistence/management/v1/processor/service/persistence/repository/NotificationPreferencesRepository.java index fa804686d..4340130f8 100644 --- a/persistence-service-v1/persistence-service-processor-v1/src/main/java/com/iqser/red/service/persistence/management/v1/processor/service/persistence/repository/NotificationPreferencesRepository.java +++ b/persistence-service-v1/persistence-service-processor-v1/src/main/java/com/iqser/red/service/persistence/management/v1/processor/service/persistence/repository/NotificationPreferencesRepository.java @@ -1,11 +1,19 @@ package com.iqser.red.service.persistence.management.v1.processor.service.persistence.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import com.iqser.red.service.persistence.management.v1.processor.entity.notification.NotificationPreferencesEntity; public interface NotificationPreferencesRepository extends JpaRepository { - boolean existsByUserId(String userId); + Optional findByUserId(String userId); + + + NotificationPreferencesEntity getByUserId(String userId); + + + void deleteByUserId(String userId); } diff --git a/persistence-service-v1/persistence-service-processor-v1/src/main/java/com/iqser/red/service/persistence/management/v1/processor/service/persistence/repository/NotificationRepository.java b/persistence-service-v1/persistence-service-processor-v1/src/main/java/com/iqser/red/service/persistence/management/v1/processor/service/persistence/repository/NotificationRepository.java index 3346bbb67..7fea3d1e9 100644 --- a/persistence-service-v1/persistence-service-processor-v1/src/main/java/com/iqser/red/service/persistence/management/v1/processor/service/persistence/repository/NotificationRepository.java +++ b/persistence-service-v1/persistence-service-processor-v1/src/main/java/com/iqser/red/service/persistence/management/v1/processor/service/persistence/repository/NotificationRepository.java @@ -15,11 +15,19 @@ public interface NotificationRepository extends JpaRepository findAppNotificationsForUser(String userId); - @Query("select n from NotificationEntity n where " + " (exists (select e from NotificationPreferencesEntity e where e.userId = :userId) and n.notificationType in ( select apn from NotificationPreferencesEntity p join p.inAppNotifications apn where p.userId = :userId and p.inAppNotificationsEnabled = true )) " + " and n.seenDate is null and n.softDeleted is null and n.userId = :userId order by n.creationDate desc ") + @Query(""" + select n from NotificationEntity n where + (exists (select e from NotificationPreferencesEntity e where e.userId = :userId) and n.notificationType in + (select apn from NotificationPreferencesEntity p join p.inAppNotifications apn where p.userId = :userId and p.inAppNotificationsEnabled = true)) + and n.seenDate is null and n.softDeleted is null and n.userId = :userId order by n.creationDate desc""") List findUnseenNotificationsForUser(String userId); diff --git a/persistence-service-v1/persistence-service-server-v1/src/test/java/com/iqser/red/service/peristence/v1/server/integration/tests/NotificationPreferencesServiceTest.java b/persistence-service-v1/persistence-service-server-v1/src/test/java/com/iqser/red/service/peristence/v1/server/integration/tests/NotificationPreferencesServiceTest.java new file mode 100644 index 000000000..75428b5c3 --- /dev/null +++ b/persistence-service-v1/persistence-service-server-v1/src/test/java/com/iqser/red/service/peristence/v1/server/integration/tests/NotificationPreferencesServiceTest.java @@ -0,0 +1,52 @@ +package com.iqser.red.service.peristence.v1.server.integration.tests; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.iqser.red.service.peristence.v1.server.integration.utils.AbstractPersistenceServerServiceTest; +import com.iqser.red.service.peristence.v1.server.integration.utils.MultithreadedTestRunner; +import com.iqser.red.service.persistence.management.v1.processor.service.persistence.NotificationPreferencesPersistenceService; +import com.iqser.red.service.persistence.management.v1.processor.utils.multitenancy.TenantContext; + +import lombok.AccessLevel; +import lombok.SneakyThrows; +import lombok.experimental.FieldDefaults; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@FieldDefaults(level = AccessLevel.PRIVATE) +public class NotificationPreferencesServiceTest extends AbstractPersistenceServerServiceTest { + + @Autowired + NotificationPreferencesPersistenceService notificationPreferencesPersistenceService; + + final MultithreadedTestRunner multithreadedTestRunner = new MultithreadedTestRunner(2, 1000); + + + @BeforeEach + public void setup() { + + TenantContext.setTenantId("redaction"); + } + + + @Test + @SneakyThrows + public void testNotificationPreferencesConcurrent() { + + final String userId = "1"; + Runnable test = () -> notificationPreferencesPersistenceService.getOrCreateNotificationPreferences(userId); + Runnable afterTest = () -> notificationPreferencesPersistenceService.deleteNotificationPreferences(userId); + var exceptions = multithreadedTestRunner.runMutlithreadedCollectingExceptions(test, afterTest); + + for (Exception ex : exceptions) { + log.error("Exception during notification creation", ex); + } + + assertThat(exceptions).isEmpty(); + } + +} diff --git a/persistence-service-v1/persistence-service-server-v1/src/test/java/com/iqser/red/service/peristence/v1/server/integration/tests/NotificationTest.java b/persistence-service-v1/persistence-service-server-v1/src/test/java/com/iqser/red/service/peristence/v1/server/integration/tests/NotificationTest.java index e76936759..2dac99468 100644 --- a/persistence-service-v1/persistence-service-server-v1/src/test/java/com/iqser/red/service/peristence/v1/server/integration/tests/NotificationTest.java +++ b/persistence-service-v1/persistence-service-server-v1/src/test/java/com/iqser/red/service/peristence/v1/server/integration/tests/NotificationTest.java @@ -12,11 +12,17 @@ import org.springframework.beans.factory.annotation.Autowired; import com.iqser.red.service.peristence.v1.server.integration.client.NotificationClient; import com.iqser.red.service.peristence.v1.server.integration.client.NotificationPreferencesClient; import com.iqser.red.service.peristence.v1.server.integration.utils.AbstractPersistenceServerServiceTest; +import com.iqser.red.service.peristence.v1.server.integration.utils.MultithreadedTestRunner; import com.iqser.red.service.persistence.service.v1.api.model.audit.AddNotificationRequest; import com.iqser.red.service.persistence.service.v1.api.model.common.JSONPrimitive; import com.iqser.red.service.persistence.service.v1.api.model.notification.Notification; import com.iqser.red.service.persistence.service.v1.api.model.notification.NotificationType; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") +@Slf4j public class NotificationTest extends AbstractPersistenceServerServiceTest { @Autowired @@ -25,6 +31,8 @@ public class NotificationTest extends AbstractPersistenceServerServiceTest { @Autowired private NotificationPreferencesClient notificationPreferencesClient; + private final MultithreadedTestRunner multithreadedTestRunner = new MultithreadedTestRunner(2, 1000); + @Test public void testNotificationPreferences() { @@ -106,4 +114,38 @@ public class NotificationTest extends AbstractPersistenceServerServiceTest { return currentNotifications.iterator().next(); } + + @Test + @SneakyThrows + public void testNotificationPreferencesConcurrent() { + + final String userId = "1"; + Runnable test = () -> notificationPreferencesClient.getNotificationPreferences(userId); + Runnable afterTest = () -> notificationPreferencesClient.deleteNotificationPreferences(userId); + var exceptions = multithreadedTestRunner.runMutlithreadedCollectingExceptions(test, afterTest); + + for (Exception ex : exceptions) { + log.error("Exception during notification creation", ex); + } + + assertThat(exceptions).isEmpty(); + } + + + @Test + @SneakyThrows + public void testNotificationsConcurrent() { + + final String userId = "1"; + Runnable test = () -> notificationClient.getNotifications(userId, false); + Runnable afterTest = () -> notificationPreferencesClient.deleteNotificationPreferences(userId); + var exceptions = multithreadedTestRunner.runMutlithreadedCollectingExceptions(test, afterTest); + + for (Exception ex : exceptions) { + log.error("Exception during notification creation", ex); + } + + assertThat(exceptions).isEmpty(); + } + } diff --git a/persistence-service-v1/persistence-service-server-v1/src/test/java/com/iqser/red/service/peristence/v1/server/integration/utils/MultithreadedTestRunner.java b/persistence-service-v1/persistence-service-server-v1/src/test/java/com/iqser/red/service/peristence/v1/server/integration/utils/MultithreadedTestRunner.java new file mode 100644 index 000000000..2dd4cc6c8 --- /dev/null +++ b/persistence-service-v1/persistence-service-server-v1/src/test/java/com/iqser/red/service/peristence/v1/server/integration/utils/MultithreadedTestRunner.java @@ -0,0 +1,71 @@ +package com.iqser.red.service.peristence.v1.server.integration.utils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.experimental.FieldDefaults; + +@RequiredArgsConstructor +@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) +public class MultithreadedTestRunner { + + int numberOfThreads; + int numberOfExecutions; + + + public List runMutlithreadedCollectingExceptions(boolean stopOnFirstRunWithExceptions, Runnable test, Runnable afterTest) { + + List allExceptions = new ArrayList<>(); + + for (int execution = 1; execution <= numberOfExecutions; execution++) { + var threads = new ArrayList(numberOfThreads); + var exceptions = Collections.synchronizedList(new ArrayList()); + + for (int threadNumber = 1; threadNumber <= numberOfThreads; threadNumber++) { + Thread t = new Thread(() -> { + try { + test.run(); + } catch (Exception e) { + exceptions.add(e); + } + }); + + threads.add(t); + } + + for (Thread t : threads) { + t.start(); + } + + for (Thread t : threads) { + try { + t.join(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + afterTest.run(); + + if (stopOnFirstRunWithExceptions) { + if (!exceptions.isEmpty()) { + return exceptions; + } + } else { + allExceptions.addAll(exceptions); + } + } + + return allExceptions; + } + + + public List runMutlithreadedCollectingExceptions(Runnable test, Runnable afterTest) { + + return runMutlithreadedCollectingExceptions(true, test, afterTest); + } + +}