From a55ebe9faec8e04d7953f0199443fbdbe5cace82 Mon Sep 17 00:00:00 2001 From: "mihaita.tinta" Date: Thu, 20 Nov 2025 17:22:04 +0200 Subject: [PATCH 01/11] wip failed attempt info Signed-off-by: mihaita.tinta --- .../modulith/events/EventPublication.java | 7 + .../modulith/events/FailedAttemptInfo.java | 19 + .../modulith/events/core/Completable.java | 8 + .../events/core/DefaultEventPublication.java | 22 + .../core/DefaultEventPublicationRegistry.java | 8 +- .../events/core/DefaultFailedAttemptInfo.java | 38 + .../events/core/EventPublicationRegistry.java | 3 +- .../core/EventPublicationRepository.java | 9 + .../support/CompletionRegisteringAdvisor.java | 14 +- ...aultEventPublicationRegistryUnitTests.java | 3 +- .../InMemoryEventPublicationRepository.java | 9 + .../core/TargetEventPublicationUnitTests.java | 21 + ...CompletionRegisteringAdvisorUnitTests.java | 16 + .../jdbc/JdbcEventPublicationRepository.java | 70 +- .../src/main/resources/schema-h2.sql | 11 + ...PublicationRepositoryIntegrationTests.java | 954 ++++++++++-------- .../PersistentDomainEventIntegrationTest.java | 17 +- 17 files changed, 798 insertions(+), 431 deletions(-) create mode 100644 spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/FailedAttemptInfo.java create mode 100644 spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultFailedAttemptInfo.java diff --git a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/EventPublication.java b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/EventPublication.java index cdf3b58cd..d0e8e1edc 100644 --- a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/EventPublication.java +++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/EventPublication.java @@ -16,6 +16,7 @@ package org.springframework.modulith.events; import java.time.Instant; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -73,6 +74,12 @@ default ApplicationEvent getApplicationEvent() { */ Optional getCompletionDate(); + /** + * Returns the list of failed attempts to publish the event + * @return will never be {@literal null}. + */ + List getFailedAttempts(); + /** * Returns whether the publication of the event has completed. * diff --git a/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/FailedAttemptInfo.java b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/FailedAttemptInfo.java new file mode 100644 index 000000000..d054801fd --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-api/src/main/java/org/springframework/modulith/events/FailedAttemptInfo.java @@ -0,0 +1,19 @@ +package org.springframework.modulith.events; + +import java.time.Instant; + +public interface FailedAttemptInfo { + /** + * Returns the time the event is published at. + * + * @return will never be {@literal null}. + */ + Instant getPublicationDate(); + + /** + * Returns the exception causing the publication to fail + * + * @return will never be {@literal null}. + */ + Throwable getFailureReason(); +} diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/Completable.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/Completable.java index 2db7e010b..770fbc755 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/Completable.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/Completable.java @@ -30,4 +30,12 @@ interface Completable { * @param instant must not be {@literal null}. */ void markCompleted(Instant instant); + + /** + * Stores the reason why the publication failed + * + * @param instant must not be {@literal null}. + * @param exception must not be {@literal null}. + */ + void markFailed(Instant instant, Throwable exception); } diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultEventPublication.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultEventPublication.java index c437b7cfe..d891e88a5 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultEventPublication.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultEventPublication.java @@ -16,13 +16,20 @@ package org.springframework.modulith.events.core; import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; import org.springframework.lang.Nullable; +import org.springframework.modulith.events.FailedAttemptInfo; import org.springframework.util.Assert; +import static java.util.Collections.unmodifiableList; + /** * Default {@link Completable} implementation. * @@ -36,6 +43,7 @@ class DefaultEventPublication implements TargetEventPublication { private final Instant publicationDate; private Optional completionDate; + private List failedAttempts; /** * Creates a new {@link DefaultEventPublication} for the given event and {@link PublicationTargetIdentifier}. @@ -100,6 +108,11 @@ public Optional getCompletionDate() { return completionDate; } + @Override + public List getFailedAttempts() { + return failedAttempts == null ? List.of() : unmodifiableList(failedAttempts); + } + /* * (non-Javadoc) * @see org.springframework.modulith.events.CompletableEventPublication#markCompleted(java.time.Instant) @@ -109,6 +122,15 @@ public void markCompleted(Instant instant) { this.completionDate = Optional.of(instant); } + + @Override + public void markFailed(Instant instant, Throwable exception) { + if (failedAttempts == null) { + failedAttempts = new ArrayList<>(); + } + failedAttempts.add(new DefaultFailedAttemptInfo(instant, exception)); + } + /* * (non-Javadoc) * @see java.lang.Object#toString() diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistry.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistry.java index 7fc1a8f4c..8c5d479e9 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistry.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistry.java @@ -136,7 +136,7 @@ public void markCompleted(Object event, PublicationTargetIdentifier targetIdenti * @see org.springframework.modulith.events.core.EventPublicationRegistry#markFailed(java.lang.Object, org.springframework.modulith.events.core.PublicationTargetIdentifier) */ @Override - public void markFailed(Object event, PublicationTargetIdentifier targetIdentifier) { + public void markFailed(Object event, PublicationTargetIdentifier targetIdentifier, Throwable o_O) { inProgress.unregister(event, targetIdentifier); } @@ -266,14 +266,14 @@ PublicationsInProgress getPublicationsInProgress() { * Marks the given {@link TargetEventPublication} as failed. * * @param publication must not be {@literal null}. - * @see #markFailed(Object, PublicationTargetIdentifier) + * @see #markFailed(Object, PublicationTargetIdentifier, Throwable) * @since 1.3 */ - void markFailed(TargetEventPublication publication) { + void markFailed(TargetEventPublication publication, Throwable exception) { Assert.notNull(publication, "TargetEventPublication must not be null!"); - markFailed(publication.getEvent(), publication.getTargetIdentifier()); + markFailed(publication.getEvent(), publication.getTargetIdentifier(), exception); } private static String getConfirmationMessage(Collection publications) { diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultFailedAttemptInfo.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultFailedAttemptInfo.java new file mode 100644 index 000000000..23d47f7f6 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultFailedAttemptInfo.java @@ -0,0 +1,38 @@ +/* + * Copyright 2017-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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.springframework.modulith.events.core; + +import org.springframework.modulith.events.FailedAttemptInfo; + +import java.time.Instant; + +/** + * Default {@link FailedAttemptInfo} implementation. + * @param publicationDate - when the event failed to be published + * @param exception - the reason of publication failure + */ +record DefaultFailedAttemptInfo(Instant publicationDate, Throwable exception) implements FailedAttemptInfo { + + @Override + public Instant getPublicationDate() { + return publicationDate; + } + + @Override + public Throwable getFailureReason() { + return exception; + } +} diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/EventPublicationRegistry.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/EventPublicationRegistry.java index 31f86fdf7..473f98824 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/EventPublicationRegistry.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/EventPublicationRegistry.java @@ -73,9 +73,10 @@ public interface EventPublicationRegistry { * * @param event must not be {@literal null}. * @param targetIdentifier must not be {@literal null}. + * @param exception cause of failing publication * @since 1.3 */ - void markFailed(Object event, PublicationTargetIdentifier targetIdentifier); + void markFailed(Object event, PublicationTargetIdentifier targetIdentifier, Throwable exception); /** * Deletes all completed {@link TargetEventPublication}s that have been completed before the given {@link Duration}. diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/EventPublicationRepository.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/EventPublicationRepository.java index 8f18c815f..411be6783 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/EventPublicationRepository.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/EventPublicationRepository.java @@ -65,6 +65,15 @@ default void markCompleted(TargetEventPublication publication, Instant completio */ void markCompleted(Object event, PublicationTargetIdentifier identifier, Instant completionDate); + /** + * Marks the publication for the given event and {@link PublicationTargetIdentifier} as failed. + * + * @param identifier must not be {@literal null}. + * @param exception cause of failing publication + * @since 1.3 + */ + void markFailed(UUID identifier, Instant failedDate, Throwable exception); + /** * Marks the publication with the given identifier completed at the given {@link Instant}. * diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisor.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisor.java index 1efd6597b..5f58850b6 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisor.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisor.java @@ -204,15 +204,15 @@ public int getOrder() { return Ordered.HIGHEST_PRECEDENCE + 10; } - private void handleFailure(Method method, Object event, Throwable o_O) { + private void handleFailure(Method method, Object event, Throwable exception) { - markFailed(method, event); + markFailed(method, event, exception); if (LOG.isDebugEnabled()) { - LOG.debug("Invocation of listener {} failed. Leaving event publication uncompleted.", method, o_O); + LOG.debug("Invocation of listener {} failed. Leaving event publication uncompleted.", method, exception); } else { LOG.info("Invocation of listener {} failed with message {}. Leaving event publication uncompleted.", - method, o_O.getMessage()); + method, exception.getMessage()); } } @@ -224,12 +224,12 @@ private void markCompleted(Method method, Object event) { registry.get().markCompleted(event, identifier); } - private void markFailed(Method method, Object event) { + private void markFailed(Method method, Object event, Throwable exception) { - // Mark publication complete if the method is a transactional event listener. + // Mark publication failed if the method is a transactional event listener. String adapterId = LISTENER_IDS.get(method); PublicationTargetIdentifier identifier = PublicationTargetIdentifier.of(adapterId); - registry.get().markFailed(event, identifier); + registry.get().markFailed(event, identifier, exception); } @SuppressWarnings("null") diff --git a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistryUnitTests.java b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistryUnitTests.java index 2bd849583..5f472d206 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistryUnitTests.java +++ b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistryUnitTests.java @@ -66,9 +66,10 @@ void removesFailingResubmissionFromInProgressPublications() { var registry = createRegistry(Instant.now()); var identifier = PublicationTargetIdentifier.of("id"); + var error = new IllegalArgumentException("some error"); var failedPublications = registry.store(new Object(), Stream.of(identifier)).stream() - .peek(registry::markFailed) + .peek(e -> registry.markFailed(e, error)) .toList(); // Failed completions are not present in the in progress ones diff --git a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/InMemoryEventPublicationRepository.java b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/InMemoryEventPublicationRepository.java index cc3c34cb5..7d03e40b5 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/InMemoryEventPublicationRepository.java +++ b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/InMemoryEventPublicationRepository.java @@ -63,6 +63,15 @@ public void markCompleted(Object event, PublicationTargetIdentifier identifier, .ifPresent(it -> it.markCompleted(completionDate)); } + @Override + public void markFailed(UUID identifier, Instant failedDate, Throwable exception) { + + publications.stream() + .filter(it -> it.getIdentifier().equals(identifier)) + .findFirst() + .ifPresent(it -> it.markFailed(failedDate, exception)); + } + /* * (non-Javadoc) * @see org.springframework.modulith.events.core.EventPublicationRepository#markCompleted(java.util.UUID, java.time.Instant) diff --git a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/TargetEventPublicationUnitTests.java b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/TargetEventPublicationUnitTests.java index 2beef052a..1f01d49da 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/TargetEventPublicationUnitTests.java +++ b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/TargetEventPublicationUnitTests.java @@ -19,6 +19,8 @@ import org.junit.jupiter.api.Test; +import java.time.Instant; + /** * @author Oliver Drotbohm * @author Björn Kieling @@ -50,6 +52,7 @@ void publicationIsIncompleteByDefault() { assertThat(publication.isCompleted()).isFalse(); assertThat(publication.getCompletionDate()).isNotPresent(); + assertThat(publication.getFailedAttempts()).isEmpty(); } @Test // GH-1056 @@ -64,6 +67,24 @@ void isOnlyAssociatedWithTheVerySameEventInstance() { assertThat(publication.isAssociatedWith(first, identifier)).isTrue(); assertThat(publication.isAssociatedWith(second, identifier)).isFalse(); } + @Test + void isFailedAttemptStored() { + + var first = new SampleEvent("Foo"); + + var identifier = PublicationTargetIdentifier.of("id"); + var publication = TargetEventPublication.of(first, identifier); + + assertThat(publication.getFailedAttempts()).isEmpty(); + + Instant failedInstant = Instant.now(); + IllegalStateException reason = new IllegalStateException("test"); + + publication.markFailed(failedInstant, reason); + + assertThat(publication.getFailedAttempts()) + .contains(new DefaultFailedAttemptInfo(failedInstant, reason)); + } record SampleEvent(String payload) {} } diff --git a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisorUnitTests.java b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisorUnitTests.java index 55ebfc3d6..ba3e109f9 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisorUnitTests.java +++ b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisorUnitTests.java @@ -89,6 +89,22 @@ void marksLazilyComputedCompletableFutureAsCompleted() throws Throwable { verify(registry).markCompleted(any(), any()); } + @Test + void marksLazilyComputedCompletableFutureAsFailed() throws Throwable { + + var result = createProxyFor(bean).asyncWithResult(true); + + assertThat(result.isDone()).isFalse(); + verify(registry, never()).markFailed(any(), any(), any()); + verify(registry, never()).markCompleted(any(), any()); + + Thread.sleep(500); + + assertThat(result.isCompletedExceptionally()).isTrue(); + verify(registry).markFailed(any(), any(), any()); + verify(registry, never()).markCompleted(any(), any()); + } + @Test // GH-483 void exposesResultForCompletableFuture() throws Exception { diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java index 6d1eac5db..447f8e2ed 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java @@ -34,6 +34,7 @@ import org.springframework.beans.factory.BeanClassLoaderAware; import org.springframework.jdbc.core.JdbcOperations; import org.springframework.lang.Nullable; +import org.springframework.modulith.events.FailedAttemptInfo; import org.springframework.modulith.events.core.EventPublicationRepository; import org.springframework.modulith.events.core.EventSerializer; import org.springframework.modulith.events.core.PublicationTargetIdentifier; @@ -60,23 +61,28 @@ class JdbcEventPublicationRepository implements EventPublicationRepository, Bean INSERT INTO %s (ID, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT) VALUES (?, ?, ?, ?, ?) """; + private static final String SQL_STATEMENT_INSERT_FAILURE = """ + INSERT INTO %s (EVENT_ID, FAILED_DATE, REASON) + VALUES (?, ?, ?) + """; private static final String SQL_STATEMENT_FIND_COMPLETED = """ - SELECT ID, COMPLETION_DATE, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT + SELECT ID, COMPLETION_DATE, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT, FAILED_EVENT_INFO FROM %s WHERE COMPLETION_DATE IS NOT NULL ORDER BY PUBLICATION_DATE ASC """; private static final String SQL_STATEMENT_FIND_UNCOMPLETED = """ - SELECT ID, COMPLETION_DATE, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT - FROM %s + SELECT e.ID, e.COMPLETION_DATE, e.EVENT_TYPE, e.LISTENER_ID, e.PUBLICATION_DATE, e.SERIALIZED_EVENT, f.REASON + FROM %s e + INNER JOIN %s f on f.EVENT_ID = e.ID WHERE COMPLETION_DATE IS NULL ORDER BY PUBLICATION_DATE ASC """; private static final String SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE = """ - SELECT ID, COMPLETION_DATE, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT + SELECT ID, COMPLETION_DATE, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT, FAILED_EVENT_INFO FROM %s WHERE COMPLETION_DATE IS NULL @@ -99,6 +105,12 @@ class JdbcEventPublicationRepository implements EventPublicationRepository, Bean WHERE ID = ? """; + private static final String SQL_STATEMENT_UPDATE_FAILED_BY_ID = """ + UPDATE %s + SET FAILED_EVENT_INFO = ? + WHERE + ID = ? + """; private static final String SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID = """ SELECT * @@ -173,6 +185,7 @@ AND NOT EXISTS (SELECT 1 FROM %s WHERE ID = EVENT_PUBLICATION.ID) private ClassLoader classLoader; private final String sqlStatementInsert, + sqlStatementInsertFailed, sqlStatementFindCompleted, sqlStatementFindUncompleted, sqlStatementFindUncompletedBefore, @@ -208,11 +221,13 @@ public JdbcEventPublicationRepository(JdbcOperations operations, EventSerializer var schema = settings.getSchema(); var table = ObjectUtils.isEmpty(schema) ? "EVENT_PUBLICATION" : schema + ".EVENT_PUBLICATION"; + var failedEventInfoTable = ObjectUtils.isEmpty(schema) ? "EVENT_FAILED_EVENT_INFO" : schema + ".EVENT_FAILED_EVENT_INFO"; var completedTable = settings.isArchiveCompletion() ? table + "_ARCHIVE" : table; this.sqlStatementInsert = SQL_STATEMENT_INSERT.formatted(table); + this.sqlStatementInsertFailed = SQL_STATEMENT_INSERT_FAILURE.formatted(failedEventInfoTable); this.sqlStatementFindCompleted = SQL_STATEMENT_FIND_COMPLETED.formatted(completedTable); - this.sqlStatementFindUncompleted = SQL_STATEMENT_FIND_UNCOMPLETED.formatted(table); + this.sqlStatementFindUncompleted = SQL_STATEMENT_FIND_UNCOMPLETED.formatted(table, failedEventInfoTable); this.sqlStatementFindUncompletedBefore = SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE.formatted(table); this.sqlStatementUpdateByEventAndListenerId = SQL_STATEMENT_UPDATE_BY_EVENT_AND_LISTENER_ID.formatted(table); this.sqlStatementUpdateById = SQL_STATEMENT_UPDATE_BY_ID.formatted(table); @@ -288,6 +303,14 @@ public void markCompleted(Object event, PublicationTargetIdentifier identifier, } } + @Override + public void markFailed(UUID identifier, Instant failedDate, Throwable exception) { + + var databaseId = uuidToDatabase(identifier); + var reason = serializer.serialize(new JdbcFailedAttemptInfo(failedDate, exception)); + this.operations.update(sqlStatementInsertFailed, databaseId, failedDate, reason); + } + /* * (non-Javadoc) * @see org.springframework.modulith.events.core.EventPublicationRepository#markCompleted(java.util.UUID, java.time.Instant) @@ -442,10 +465,12 @@ private TargetEventPublication resultSetToPublication(ResultSet rs) throws SQLEx var publicationDate = rs.getTimestamp("PUBLICATION_DATE").toInstant(); var listenerId = rs.getString("LISTENER_ID"); var serializedEvent = rs.getString("SERIALIZED_EVENT"); + var failedEventInfo = rs.getString("REASON"); return new JdbcEventPublication(id, publicationDate, listenerId, () -> serializer.deserialize(serializedEvent, eventClass), - completionDate == null ? null : completionDate.toInstant()); + completionDate == null ? null : completionDate.toInstant(), + List.of(serializer.deserialize(failedEventInfo, JdbcFailedAttemptInfo.class)));// TODO add value from resultset } private Object uuidToDatabase(UUID id) { @@ -493,6 +518,7 @@ private static class JdbcEventPublication implements TargetEventPublication { private @Nullable Instant completionDate; private @Nullable Object event; + private List failedAttempts; /** * @param id must not be {@literal null}. @@ -500,20 +526,23 @@ private static class JdbcEventPublication implements TargetEventPublication { * @param listenerId must not be {@literal null} or empty. * @param event must not be {@literal null}.. * @param completionDate can be {@literal null}. + * @param failedAttempts can be {@literal null}. */ public JdbcEventPublication(UUID id, Instant publicationDate, String listenerId, Supplier event, - @Nullable Instant completionDate) { + @Nullable Instant completionDate, List failedAttempts) { Assert.notNull(id, "Id must not be null!"); Assert.notNull(publicationDate, "Publication date must not be null!"); Assert.hasText(listenerId, "Listener id must not be null or empty!"); Assert.notNull(event, "Event must not be null!"); + Assert.notNull(failedAttempts, "Failed attempts must not be null!"); this.id = id; this.publicationDate = publicationDate; this.listenerId = listenerId; this.eventSupplier = event; this.completionDate = completionDate; + this.failedAttempts = failedAttempts; } /* @@ -567,6 +596,11 @@ public Optional getCompletionDate() { return Optional.ofNullable(completionDate); } + @Override + public List getFailedAttempts() { + return failedAttempts; + } + /* * (non-Javadoc) * @see org.springframework.modulith.events.CompletableEventPublication#isPublicationCompleted() @@ -585,6 +619,15 @@ public void markCompleted(Instant instant) { this.completionDate = instant; } + @Override + public void markFailed(Instant instant, Throwable exception) { + + if (failedAttempts == null) { + failedAttempts = new ArrayList<>(); + } + failedAttempts.add(new JdbcFailedAttemptInfo(instant, exception)); + } + /* * (non-Javadoc) * @see java.lang.Object#equals(java.lang.Object) @@ -616,4 +659,17 @@ public int hashCode() { return Objects.hash(completionDate, id, listenerId, publicationDate, getEvent()); } } + + record JdbcFailedAttemptInfo(Instant publicationDate, Throwable failureReason) implements FailedAttemptInfo { + + @Override + public Instant getPublicationDate() { + return publicationDate; + } + + @Override + public Throwable getFailureReason() { + return failureReason; + } + } } diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql index 805b9fbde..52f2f2926 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql @@ -10,3 +10,14 @@ CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION ); CREATE INDEX IF NOT EXISTS EVENT_PUBLICATION_BY_LISTENER_ID_AND_SERIALIZED_EVENT_IDX ON EVENT_PUBLICATION (LISTENER_ID, SERIALIZED_EVENT); CREATE INDEX IF NOT EXISTS EVENT_PUBLICATION_BY_COMPLETION_DATE_IDX ON EVENT_PUBLICATION (COMPLETION_DATE); +CREATE TABLE IF NOT EXISTS EVENT_FAILED_EVENT_INFO +( + EVENT_ID UUID NOT NULL, + FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, + REASON VARCHAR(4000) NOT NULL, + CONSTRAINT FK_FAILED_EVENT_INFO_EVENT + FOREIGN KEY (EVENT_ID) + REFERENCES EVENT_PUBLICATION(ID) + ON DELETE CASCADE + +); diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java index abd6b5a18..6ec9d37ad 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java @@ -15,20 +15,6 @@ */ package org.springframework.modulith.events.jdbc; -import static org.assertj.core.api.Assertions.*; -import static org.junit.jupiter.api.Assumptions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.temporal.ChronoUnit; -import java.util.Comparator; -import java.util.List; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -47,6 +33,23 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.testcontainers.junit.jupiter.Testcontainers; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; +import static org.junit.jupiter.api.Assumptions.assumeFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.when; + /** * Integration tests for {@link JdbcEventPublicationRepository}. * @@ -58,515 +61,646 @@ */ class JdbcEventPublicationRepositoryIntegrationTests { - static final PublicationTargetIdentifier TARGET_IDENTIFIER = PublicationTargetIdentifier.of("listener"); - - @Import(TestApplication.class) - @Testcontainers(disabledWithoutDocker = true) - @ContextConfiguration(classes = JdbcEventPublicationAutoConfiguration.class) - static abstract class TestBase { - - @Autowired JdbcOperations operations; - @Autowired JdbcEventPublicationRepository repository; - @Autowired JdbcRepositorySettings properties; - - @MockitoBean EventSerializer serializer; - - @AfterEach - @BeforeEach - void cleanUp() { - - operations.execute("TRUNCATE TABLE " + table()); - - if (properties.isArchiveCompletion()) { - operations.execute("TRUNCATE TABLE " + archiveTable()); - } - } + static final PublicationTargetIdentifier TARGET_IDENTIFIER = PublicationTargetIdentifier.of("listener"); - @Test // GH-3 - void shouldPersistAndUpdateEventPublication() { + @Import(TestApplication.class) + @Testcontainers(disabledWithoutDocker = true) + @ContextConfiguration(classes = JdbcEventPublicationAutoConfiguration.class) + static abstract class TestBase { - var testEvent = new TestEvent("id"); - var serializedEvent = "{\"eventId\":\"id\"}"; + @Autowired + JdbcOperations operations; + @Autowired + JdbcEventPublicationRepository repository; + @Autowired + JdbcRepositorySettings properties; - when(serializer.serialize(testEvent)).thenReturn(serializedEvent); - when(serializer.deserialize(serializedEvent, TestEvent.class)).thenReturn(testEvent); + @MockitoBean + EventSerializer serializer; - var publication = repository.create(TargetEventPublication.of(testEvent, TARGET_IDENTIFIER)); + @AfterEach + @BeforeEach + void cleanUp() { - var eventPublications = repository.findIncompletePublications(); + operations.execute("TRUNCATE TABLE " + failedInfoTable()); + operations.execute("TRUNCATE TABLE " + table()); - assertThat(eventPublications).hasSize(1); - assertThat(eventPublications).element(0).satisfies(it -> { - assertThat(it.getEvent()).isEqualTo(publication.getEvent()); - assertThat(it.getTargetIdentifier()).isEqualTo(publication.getTargetIdentifier()); - }); + if (properties.isArchiveCompletion()) { + operations.execute("TRUNCATE TABLE " + archiveTable()); + } + } - assertThat(repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER)) - .isPresent(); + @Test + // GH-3 + void shouldPersistAndUpdateEventPublication() { - // Complete publication - repository.markCompleted(publication, Instant.now()); + var testEvent = new TestEvent("id"); + var serializedEvent = "{\"eventId\":\"id\"}"; - assertThat(repository.findIncompletePublications()).isEmpty(); - } + when(serializer.serialize(testEvent)).thenReturn(serializedEvent); + when(serializer.deserialize(serializedEvent, TestEvent.class)).thenReturn(testEvent); - @Test // GH-133 - void returnsOldestIncompletePublicationsFirst() { + var publication = repository.create(TargetEventPublication.of(testEvent, TARGET_IDENTIFIER)); - when(serializer.serialize(any())).thenReturn("{}"); + var eventPublications = repository.findIncompletePublications(); - var now = LocalDateTime.now(); + assertThat(eventPublications).hasSize(1); + assertThat(eventPublications).element(0).satisfies(it -> { + assertThat(it.getEvent()).isEqualTo(publication.getEvent()); + assertThat(it.getTargetIdentifier()).isEqualTo(publication.getTargetIdentifier()); + }); - createPublicationAt(now.withHour(3)); - createPublicationAt(now.withHour(0)); - createPublicationAt(now.withHour(1)); + assertThat(repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER)) + .isPresent(); - assertThat(repository.findIncompletePublications()) - .isSortedAccordingTo(Comparator.comparing(TargetEventPublication::getPublicationDate)); - } + // Complete publication + repository.markCompleted(publication, Instant.now()); - private void createPublicationAt(LocalDateTime publicationDate) { - repository.create(TargetEventPublication.of("", TARGET_IDENTIFIER, publicationDate.toInstant(ZoneOffset.UTC))); - } + assertThat(repository.findIncompletePublications()).isEmpty(); + } - @Test // GH-3 - void shouldUpdateSingleEventPublication() { + @Test + // GH-133 + void returnsOldestIncompletePublicationsFirst() { - var testEvent1 = new TestEvent("id1"); - var testEvent2 = new TestEvent("id2"); - var serializedEvent1 = "{\"eventId\":\"id1\"}"; - var serializedEvent2 = "{\"eventId\":\"id2\"}"; + when(serializer.serialize(any())).thenReturn("{}"); - when(serializer.serialize(testEvent1)).thenReturn(serializedEvent1); - when(serializer.deserialize(serializedEvent1, TestEvent.class)).thenReturn(testEvent1); - when(serializer.serialize(testEvent2)).thenReturn(serializedEvent2); - when(serializer.deserialize(serializedEvent2, TestEvent.class)).thenReturn(testEvent2); + var now = LocalDateTime.now(); - repository.create(TargetEventPublication.of(testEvent1, TARGET_IDENTIFIER)); - var publication = repository.create(TargetEventPublication.of(testEvent2, TARGET_IDENTIFIER)); + createPublicationAt(now.withHour(3)); + createPublicationAt(now.withHour(0)); + createPublicationAt(now.withHour(1)); - // Complete publication - repository.markCompleted(publication, Instant.now()); + assertThat(repository.findIncompletePublications()) + .isSortedAccordingTo(Comparator.comparing(TargetEventPublication::getPublicationDate)); + } - assertThat(repository.findIncompletePublications()).hasSize(1) - .element(0).extracting(TargetEventPublication::getEvent).isEqualTo(testEvent1); - } + private void createPublicationAt(LocalDateTime publicationDate) { + repository.create(TargetEventPublication.of("", TARGET_IDENTIFIER, publicationDate.toInstant(ZoneOffset.UTC))); + } - @Test // GH-3 - void shouldTolerateEmptyResult() { + @Test + // GH-3 + void shouldUpdateSingleEventPublication() { - var testEvent = new TestEvent("id"); - var serializedEvent = "{\"eventId\":\"id\"}"; + var testEvent1 = new TestEvent("id1"); + var testEvent2 = new TestEvent("id2"); + var serializedEvent1 = "{\"eventId\":\"id1\"}"; + var serializedEvent2 = "{\"eventId\":\"id2\"}"; - when(serializer.serialize(testEvent)).thenReturn(serializedEvent); + when(serializer.serialize(testEvent1)).thenReturn(serializedEvent1); + when(serializer.deserialize(serializedEvent1, TestEvent.class)).thenReturn(testEvent1); + when(serializer.serialize(testEvent2)).thenReturn(serializedEvent2); + when(serializer.deserialize(serializedEvent2, TestEvent.class)).thenReturn(testEvent2); - assertThat(repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER)) - .isEmpty(); - } + repository.create(TargetEventPublication.of(testEvent1, TARGET_IDENTIFIER)); + var publication = repository.create(TargetEventPublication.of(testEvent2, TARGET_IDENTIFIER)); - @Test // GH-3 - void shouldNotReturnCompletedEvents() { + // Complete publication + repository.markCompleted(publication, Instant.now()); - var testEvent = new TestEvent("id1"); - var serializedEvent = "{\"eventId\":\"id1\"}"; + assertThat(repository.findIncompletePublications()).hasSize(1) + .element(0).extracting(TargetEventPublication::getEvent).isEqualTo(testEvent1); + } - when(serializer.serialize(testEvent)).thenReturn(serializedEvent); - when(serializer.deserialize(serializedEvent, TestEvent.class)).thenReturn(testEvent); + @Test + // GH-3 + void shouldTolerateEmptyResult() { - var publication = TargetEventPublication.of(testEvent, TARGET_IDENTIFIER); + var testEvent = new TestEvent("id"); + var serializedEvent = "{\"eventId\":\"id\"}"; - repository.create(publication); - repository.markCompleted(publication, Instant.now()); + when(serializer.serialize(testEvent)).thenReturn(serializedEvent); - var actual = repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER); + assertThat(repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER)) + .isEmpty(); + } - assertThat(actual).isEmpty(); - } + @Test + // GH-3 + void shouldNotReturnCompletedEvents() { - @Test // GH-3 - void shouldReturnTheOldestEvent() throws Exception { + var testEvent = new TestEvent("id1"); + var serializedEvent = "{\"eventId\":\"id1\"}"; - var testEvent = new TestEvent("id"); - var serializedEvent = "{\"eventId\":\"id\"}"; + when(serializer.serialize(testEvent)).thenReturn(serializedEvent); + when(serializer.deserialize(serializedEvent, TestEvent.class)).thenReturn(testEvent); - when(serializer.serialize(testEvent)).thenReturn(serializedEvent); - when(serializer.deserialize(serializedEvent, TestEvent.class)).thenReturn(testEvent); + var publication = TargetEventPublication.of(testEvent, TARGET_IDENTIFIER); - var publication = repository.create(TargetEventPublication.of(testEvent, TARGET_IDENTIFIER)); - Thread.sleep(10); - repository.create(TargetEventPublication.of(testEvent, TARGET_IDENTIFIER)); + repository.create(publication); + repository.markCompleted(publication, Instant.now()); - var actual = repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER); + var actual = repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER); - assertThat(actual).hasValueSatisfying(it -> { - assertThat(it.getPublicationDate()) // - .isCloseTo(publication.getPublicationDate(), within(1, ChronoUnit.MILLIS)); - }); - } + assertThat(actual).isEmpty(); + } - @Test // GH-3 - void shouldSilentlyIgnoreNotSerializableEvents() { + @Test + // GH-3 + void shouldReturnTheOldestEvent() throws Exception { - var testEvent = new TestEvent("id"); - var serializedEvent = "{\"eventId\":\"id\"}"; + var testEvent = new TestEvent("id"); + var serializedEvent = "{\"eventId\":\"id\"}"; - when(serializer.serialize(testEvent)).thenReturn(serializedEvent); - when(serializer.deserialize(serializedEvent, TestEvent.class)).thenReturn(testEvent); + when(serializer.serialize(testEvent)).thenReturn(serializedEvent); + when(serializer.deserialize(serializedEvent, TestEvent.class)).thenReturn(testEvent); - // Store publication - repository.create(TargetEventPublication.of(testEvent, TARGET_IDENTIFIER)); + var publication = repository.create(TargetEventPublication.of(testEvent, TARGET_IDENTIFIER)); + Thread.sleep(10); + repository.create(TargetEventPublication.of(testEvent, TARGET_IDENTIFIER)); - operations.update("UPDATE " + table() + " SET EVENT_TYPE='abc'"); + var actual = repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER); - assertThat(repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER)) - .isEmpty(); - } + assertThat(actual).hasValueSatisfying(it -> { + assertThat(it.getPublicationDate()) // + .isCloseTo(publication.getPublicationDate(), within(1, ChronoUnit.MILLIS)); + }); + } - @Test // GH-20 - void shouldDeleteCompletedEvents() { + @Test + // GH-3 + void shouldSilentlyIgnoreNotSerializableEvents() { - var testEvent1 = new TestEvent("abc"); - var serializedEvent1 = "{\"eventId\":\"abc\"}"; - var testEvent2 = new TestEvent("def"); - var serializedEvent2 = "{\"eventId\":\"def\"}"; + var testEvent = new TestEvent("id"); + var serializedEvent = "{\"eventId\":\"id\"}"; - when(serializer.serialize(testEvent1)).thenReturn(serializedEvent1); - when(serializer.deserialize(serializedEvent1, TestEvent.class)).thenReturn(testEvent1); - when(serializer.serialize(testEvent2)).thenReturn(serializedEvent2); - when(serializer.deserialize(serializedEvent2, TestEvent.class)).thenReturn(testEvent2); + when(serializer.serialize(testEvent)).thenReturn(serializedEvent); + when(serializer.deserialize(serializedEvent, TestEvent.class)).thenReturn(testEvent); - var publication = repository.create(TargetEventPublication.of(testEvent1, TARGET_IDENTIFIER)); - repository.create(TargetEventPublication.of(testEvent2, TARGET_IDENTIFIER)); + // Store publication + repository.create(TargetEventPublication.of(testEvent, TARGET_IDENTIFIER)); - repository.markCompleted(publication, Instant.now()); + operations.update("UPDATE " + table() + " SET EVENT_TYPE='abc'"); - repository.deleteCompletedPublications(); + assertThat(repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER)) + .isEmpty(); + } - assertThat(operations.query("SELECT * FROM " + table(), (rs, __) -> rs.getString("SERIALIZED_EVENT"))) - .hasSize(1).element(0).isEqualTo(serializedEvent2); + @Test + // GH-20 + void shouldDeleteCompletedEvents() { - if (properties.isArchiveCompletion()) { - assertThat(operations.query("SELECT * FROM " + archiveTable(), (rs, __) -> rs.getString("SERIALIZED_EVENT"))) - .hasSize(0); - } + var testEvent1 = new TestEvent("abc"); + var serializedEvent1 = "{\"eventId\":\"abc\"}"; + var testEvent2 = new TestEvent("def"); + var serializedEvent2 = "{\"eventId\":\"def\"}"; - } + when(serializer.serialize(testEvent1)).thenReturn(serializedEvent1); + when(serializer.deserialize(serializedEvent1, TestEvent.class)).thenReturn(testEvent1); + when(serializer.serialize(testEvent2)).thenReturn(serializedEvent2); + when(serializer.deserialize(serializedEvent2, TestEvent.class)).thenReturn(testEvent2); - @Test // GH-251 - void shouldDeleteCompletedEventsBefore() { + var publication = repository.create(TargetEventPublication.of(testEvent1, TARGET_IDENTIFIER)); + repository.create(TargetEventPublication.of(testEvent2, TARGET_IDENTIFIER)); - assumeFalse(properties.isDeleteCompletion()); + repository.markCompleted(publication, Instant.now()); - var testEvent1 = new TestEvent("abc"); - var serializedEvent1 = "{\"eventId\":\"abc\"}"; - var testEvent2 = new TestEvent("def"); - var serializedEvent2 = "{\"eventId\":\"def\"}"; + repository.deleteCompletedPublications(); - when(serializer.serialize(testEvent1)).thenReturn(serializedEvent1); - when(serializer.deserialize(serializedEvent1, TestEvent.class)).thenReturn(testEvent1); - when(serializer.serialize(testEvent2)).thenReturn(serializedEvent2); - when(serializer.deserialize(serializedEvent2, TestEvent.class)).thenReturn(testEvent2); + assertThat(operations.query("SELECT * FROM " + table(), (rs, __) -> rs.getString("SERIALIZED_EVENT"))) + .hasSize(1).element(0).isEqualTo(serializedEvent2); - repository.create(TargetEventPublication.of(testEvent1, TARGET_IDENTIFIER)); - repository.create(TargetEventPublication.of(testEvent2, TARGET_IDENTIFIER)); + if (properties.isArchiveCompletion()) { + assertThat(operations.query("SELECT * FROM " + archiveTable(), (rs, __) -> rs.getString("SERIALIZED_EVENT"))) + .hasSize(0); + } - var now = Instant.now(); + } - repository.markCompleted(testEvent1, TARGET_IDENTIFIER, now.minusSeconds(30)); - repository.markCompleted(testEvent2, TARGET_IDENTIFIER, now); + @Test + // GH-251 + void shouldDeleteCompletedEventsBefore() { - repository.deleteCompletedPublicationsBefore(now.minusSeconds(15)); + assumeFalse(properties.isDeleteCompletion()); - var table = properties.isArchiveCompletion() ? archiveTable() : table(); + var testEvent1 = new TestEvent("abc"); + var serializedEvent1 = "{\"eventId\":\"abc\"}"; + var testEvent2 = new TestEvent("def"); + var serializedEvent2 = "{\"eventId\":\"def\"}"; - assertThat(operations.query("SELECT * FROM " + table, (rs, __) -> rs.getString("SERIALIZED_EVENT"))) - .hasSize(1).element(0).isEqualTo(serializedEvent2); - } + when(serializer.serialize(testEvent1)).thenReturn(serializedEvent1); + when(serializer.deserialize(serializedEvent1, TestEvent.class)).thenReturn(testEvent1); + when(serializer.serialize(testEvent2)).thenReturn(serializedEvent2); + when(serializer.deserialize(serializedEvent2, TestEvent.class)).thenReturn(testEvent2); - @Test // GH-294 - void deletesPublicationsByIdentifier() { + repository.create(TargetEventPublication.of(testEvent1, TARGET_IDENTIFIER)); + repository.create(TargetEventPublication.of(testEvent2, TARGET_IDENTIFIER)); - var first = createPublication(new TestEvent("first")); - var second = createPublication(new TestEvent("second")); - var third = createPublication(new TestEvent("third")); + var now = Instant.now(); - repository.deletePublications(List.of(first.getIdentifier(), second.getIdentifier())); + repository.markCompleted(testEvent1, TARGET_IDENTIFIER, now.minusSeconds(30)); + repository.markCompleted(testEvent2, TARGET_IDENTIFIER, now); - assertThat(repository.findIncompletePublications()) - .hasSize(1) - .element(0) - .matches(it -> it.getIdentifier().equals(third.getIdentifier())) - .matches(it -> it.getEvent().equals(third.getEvent())); - } + repository.deleteCompletedPublicationsBefore(now.minusSeconds(15)); - @Test // GH-294 - void findsPublicationsOlderThanReference() throws Exception { + var table = properties.isArchiveCompletion() ? archiveTable() : table(); - var first = createPublication(new TestEvent("first")); + assertThat(operations.query("SELECT * FROM " + table, (rs, __) -> rs.getString("SERIALIZED_EVENT"))) + .hasSize(1).element(0).isEqualTo(serializedEvent2); + } - Thread.sleep(100); + @Test + // GH-294 + void deletesPublicationsByIdentifier() { - var now = Instant.now(); - var second = createPublication(new TestEvent("second")); + var first = createPublication(new TestEvent("first")); + var second = createPublication(new TestEvent("second")); + var third = createPublication(new TestEvent("third")); - assertThat(repository.findIncompletePublications()) - .extracting(TargetEventPublication::getIdentifier) - .containsExactly(first.getIdentifier(), second.getIdentifier()); + repository.deletePublications(List.of(first.getIdentifier(), second.getIdentifier())); - assertThat(repository.findIncompletePublicationsPublishedBefore(now)) - .hasSize(1) - .element(0).extracting(TargetEventPublication::getIdentifier).isEqualTo(first.getIdentifier()); - } + assertThat(repository.findIncompletePublications()) + .hasSize(1) + .element(0) + .matches(it -> it.getIdentifier().equals(third.getIdentifier())) + .matches(it -> it.getEvent().equals(third.getEvent())); + } - @Test // GH-451 - void findsCompletedPublications() { + @Test + // GH-294 + void findsPublicationsOlderThanReference() throws Exception { - var event = new TestEvent("first"); - var publication = createPublication(event); + var first = createPublication(new TestEvent("first")); - repository.markCompleted(publication, Instant.now()); + Thread.sleep(100); - if (properties.isDeleteCompletion()) { + var now = Instant.now(); + var second = createPublication(new TestEvent("second")); - assertThat(repository.findCompletedPublications()).isEmpty(); - assertThat(repository.findIncompletePublications()).isEmpty(); + assertThat(repository.findIncompletePublications()) + .extracting(TargetEventPublication::getIdentifier) + .containsExactly(first.getIdentifier(), second.getIdentifier()); - } else { + assertThat(repository.findIncompletePublicationsPublishedBefore(now)) + .hasSize(1) + .element(0).extracting(TargetEventPublication::getIdentifier).isEqualTo(first.getIdentifier()); + } - assertThat(repository.findCompletedPublications()) - .hasSize(1) - .element(0) - .extracting(TargetEventPublication::getEvent) - .isEqualTo(event); - } - } + @Test + // GH-451 + void findsCompletedPublications() { - @Test // GH-258 - void marksPublicationAsCompletedById() { + var event = new TestEvent("first"); + var publication = createPublication(event); - var event = new TestEvent("first"); - var publication = createPublication(event); + repository.markCompleted(publication, Instant.now()); - repository.markCompleted(publication.getIdentifier(), Instant.now()); + if (properties.isDeleteCompletion()) { - assertThat(repository.findIncompletePublications()).isEmpty(); + assertThat(repository.findCompletedPublications()).isEmpty(); + assertThat(repository.findIncompletePublications()).isEmpty(); - if (properties.isDeleteCompletion()) { + } else { - assertThat(repository.findCompletedPublications()).isEmpty(); + assertThat(repository.findCompletedPublications()) + .hasSize(1) + .element(0) + .extracting(TargetEventPublication::getEvent) + .isEqualTo(event); + } + } - } else { + @Test + // GH-258 + void marksPublicationAsCompletedById() { - assertThat(repository.findCompletedPublications()) - .extracting(TargetEventPublication::getIdentifier) - .containsExactly(publication.getIdentifier()); - } + var event = new TestEvent("first"); + var publication = createPublication(event); - if (properties.isArchiveCompletion()) { - assertThat(operations.queryForObject("SELECT COUNT(*) FROM " + archiveTable(), int.class)).isOne(); - } - } + repository.markCompleted(publication.getIdentifier(), Instant.now()); - @Test // GH-753 - void returnsSameEventInstanceFromPublication() { + assertThat(repository.findIncompletePublications()).isEmpty(); - // An event not implementing equals(…) / hashCode() - var event = new Sample(); + if (properties.isDeleteCompletion()) { - // Serialize to whatever - doReturn("sample").when(serializer).serialize(event); + assertThat(repository.findCompletedPublications()).isEmpty(); - // Return fresh instances for every deserialization attempt - doAnswer(__ -> new Sample()).when(serializer).deserialize("sample", Sample.class); + } else { - repository.create(TargetEventPublication.of(event, TARGET_IDENTIFIER)); + assertThat(repository.findCompletedPublications()) + .extracting(TargetEventPublication::getIdentifier) + .containsExactly(publication.getIdentifier()); + } - var publication = repository.findIncompletePublications().get(0); - - assertThat(publication.getEvent()).isSameAs(publication.getEvent()); - } - - String table() { - return "EVENT_PUBLICATION"; - } - - String archiveTable() { return table() + "_ARCHIVE"; } - - private TargetEventPublication createPublication(Object event) { - - var token = event.toString(); - - doReturn(token).when(serializer).serialize(event); - doReturn(event).when(serializer).deserialize(token, event.getClass()); + if (properties.isArchiveCompletion()) { + assertThat(operations.queryForObject("SELECT COUNT(*) FROM " + archiveTable(), int.class)).isOne(); + } + } - return repository.create(TargetEventPublication.of(event, TARGET_IDENTIFIER)); - } - } + @Test + // GH-753 + void returnsSameEventInstanceFromPublication() { - @JdbcTest(properties = "spring.modulith.events.jdbc.schema-initialization.enabled=true") - static abstract class WithNoDefinedSchemaName extends TestBase {} + // An event not implementing equals(…) / hashCode() + var event = new Sample(); - @JdbcTest(properties = { "spring.modulith.events.jdbc.schema-initialization.enabled=true", - "spring.modulith.events.jdbc.schema=test" }) - static abstract class WithDefinedSchemaName extends TestBase { + // Serialize to whatever + doReturn("sample").when(serializer).serialize(event); - @Override - String table() { - return "test." + super.table(); - } - } + // Return fresh instances for every deserialization attempt + doAnswer(__ -> new Sample()).when(serializer).deserialize("sample", Sample.class); - @JdbcTest(properties = { "spring.modulith.events.jdbc.schema-initialization.enabled=true", - "spring.modulith.events.jdbc.schema=" }) - static abstract class WithEmptySchemaName extends TestBase {} + repository.create(TargetEventPublication.of(event, TARGET_IDENTIFIER)); - @JdbcTest(properties = { "spring.modulith.events.jdbc.schema-initialization.enabled=true", - CompletionMode.PROPERTY + "=DELETE" }) - static abstract class WithDeleteCompletion extends TestBase {} + var publication = repository.findIncompletePublications().get(0); - @JdbcTest(properties = { "spring.modulith.events.jdbc.schema-initialization.enabled=true", - CompletionMode.PROPERTY + "=ARCHIVE" }) - static abstract class WithArchiveCompletion extends TestBase { + assertThat(publication.getEvent()).isSameAs(publication.getEvent()); + } - @Override - String archiveTable() { - return "EVENT_PUBLICATION_ARCHIVE"; - } - } - // HSQL + @Test + // GH-294 + void findsPublicationsThatFailedOnce() { - @WithHsql - class HsqlWithNoDefinedSchemaName extends WithNoDefinedSchemaName {} + var first = createPublication(new TestEvent("first")); + Instant now = Instant.now(); + IllegalStateException reason = new IllegalStateException("failed once"); + var entry = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(now, reason); + doReturn(entry.toString()).when(serializer).serialize(entry); + doReturn(entry).when(serializer).deserialize(entry.toString(), entry.getClass()); - @WithHsql - class HsqlWithDefinedSchemaName extends WithDefinedSchemaName {} + repository.markFailed(first.getIdentifier(), now, reason); - @WithHsql - class HsqlWithEmptySchemaName extends WithEmptySchemaName {} + assertThat(repository.findIncompletePublications()) + .extracting(TargetEventPublication::getFailedAttempts) + .containsExactly(List.of(entry)); - @WithHsql - class HsqlWithDeleteCoqmpletion extends WithDeleteCompletion {} + } - @WithHsql - class HsqlWithArchiveCompletion extends WithArchiveCompletion {} + String table() { + return "EVENT_PUBLICATION"; + } - // H2 + String failedInfoTable() { + return "EVENT_FAILED_EVENT_INFO"; + } - @WithH2 - class H2WithNoDefinedSchemaName extends WithNoDefinedSchemaName {} + String archiveTable() { + return table() + "_ARCHIVE"; + } - @WithH2 - class H2WithDefinedSchemaName extends WithDefinedSchemaName {} + private TargetEventPublication createPublication(Object event) { - @WithH2 - class H2WithEmptySchemaName extends WithEmptySchemaName {} + var token = event.toString(); - @WithH2 - class H2WithDeleteCompletion extends WithDeleteCompletion {} - - @WithH2 - class H2WithArchiveCompletion extends WithArchiveCompletion {} - - // Postgres - - @WithPostgres - class PostgresWithNoDefinedSchemaName extends WithNoDefinedSchemaName {} - - @WithPostgres - class PostgresWithDefinedSchemaName extends WithDefinedSchemaName {} - - @WithPostgres - class PostgresWithEmptySchemaName extends WithEmptySchemaName {} - - @WithPostgres - class PostgresWithDeleteCompletion extends WithDeleteCompletion {} - - @WithPostgres - class PostgresWithArchiveCompletion extends WithArchiveCompletion {} - - // MySQL - - @WithMySql - class MysqlWithNoDefinedSchemaName extends WithNoDefinedSchemaName {} - - @WithMySql - class MysqlWithDeleteCompletion extends WithDeleteCompletion {} - - @WithMySql - class MysqlWithArchiveCompletion extends WithArchiveCompletion {} - - // MariaDB - - @WithMariaDB - class MariaDBWithNoDefinedSchemaName extends WithNoDefinedSchemaName {} - - @WithMariaDB - class MariaDBWithDeleteCompletion extends WithDeleteCompletion {} - - @WithMariaDB - class MariaDBWithArchiveCompletion extends WithArchiveCompletion {} - - // MSSQL - - @WithMssql - class MssqlWithNoDefinedSchemaName extends WithNoDefinedSchemaName {} - - @WithMssql - class MssqlWithDeleteCompletion extends WithDeleteCompletion {} - - @WithMssql - class MssqlWithArchiveCompletion extends WithArchiveCompletion {} - - // Oracle - - @WithOracle - class OracleWithNoDefinedSchemaName extends WithNoDefinedSchemaName {} - - @WithOracle - class OracleWithDeleteCompletion extends WithDeleteCompletion {} - - @WithOracle - class OracleWithArchiveCompletion extends WithArchiveCompletion {} - - private record TestEvent(String eventId) {} - - private static final class Sample {} - - @Nested - @ActiveProfiles("h2") - @Testcontainers(disabledWithoutDocker = false) - @Retention(RetentionPolicy.RUNTIME) - @interface WithH2 {} - - @Nested - @ActiveProfiles("hsql") - @Testcontainers(disabledWithoutDocker = false) - @Retention(RetentionPolicy.RUNTIME) - @interface WithHsql {} - - @Nested - @ActiveProfiles("mysql") - @Retention(RetentionPolicy.RUNTIME) - @interface WithMySql {} - - @Nested - @ActiveProfiles("mariadb") - @Retention(RetentionPolicy.RUNTIME) - @interface WithMariaDB {} - - @Nested - @ActiveProfiles("postgres") - @Retention(RetentionPolicy.RUNTIME) - @interface WithPostgres {} - - @Nested - @ActiveProfiles("mssql") - @Retention(RetentionPolicy.RUNTIME) - @interface WithMssql {} + doReturn(token).when(serializer).serialize(event); + doReturn(event).when(serializer).deserialize(token, event.getClass()); - @Nested - @ActiveProfiles("oracle") - @Retention(RetentionPolicy.RUNTIME) - @interface WithOracle {} + return repository.create(TargetEventPublication.of(event, TARGET_IDENTIFIER)); + } + } + + @JdbcTest(properties = "spring.modulith.events.jdbc.schema-initialization.enabled=true") + static abstract class WithNoDefinedSchemaName extends TestBase { + } + + @JdbcTest(properties = {"spring.modulith.events.jdbc.schema-initialization.enabled=true", + "spring.modulith.events.jdbc.schema=test"}) + static abstract class WithDefinedSchemaName extends TestBase { + + @Override + String table() { + return "test." + super.table(); + } + } + + @JdbcTest(properties = {"spring.modulith.events.jdbc.schema-initialization.enabled=true", + "spring.modulith.events.jdbc.schema="}) + static abstract class WithEmptySchemaName extends TestBase { + } + + @JdbcTest(properties = {"spring.modulith.events.jdbc.schema-initialization.enabled=true", + CompletionMode.PROPERTY + "=DELETE"}) + static abstract class WithDeleteCompletion extends TestBase { + } + + @JdbcTest(properties = {"spring.modulith.events.jdbc.schema-initialization.enabled=true", + CompletionMode.PROPERTY + "=ARCHIVE"}) + static abstract class WithArchiveCompletion extends TestBase { + + @Override + String archiveTable() { + return "EVENT_PUBLICATION_ARCHIVE"; + } + } + + // HSQL + + @WithHsql + class HsqlWithNoDefinedSchemaName extends WithNoDefinedSchemaName { + } + + @WithHsql + class HsqlWithDefinedSchemaName extends WithDefinedSchemaName { + } + + @WithHsql + class HsqlWithEmptySchemaName extends WithEmptySchemaName { + } + + @WithHsql + class HsqlWithDeleteCoqmpletion extends WithDeleteCompletion { + } + + @WithHsql + class HsqlWithArchiveCompletion extends WithArchiveCompletion { + } + + // H2 + + @WithH2 + class H2WithNoDefinedSchemaName extends WithNoDefinedSchemaName { + + // issue: https://github.com/h2database/h2database/issues/2065 + @BeforeEach + void cleanUp() { + operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); + } + + @AfterEach + void after() { + operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); + } + } + + @WithH2 + class H2WithDefinedSchemaName extends WithDefinedSchemaName { + @BeforeEach + void cleanUp() { + operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); + } + + @AfterEach + void after() { + operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); + } + } + + @WithH2 + class H2WithEmptySchemaName extends WithEmptySchemaName { + @BeforeEach + void cleanUp() { + operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); + } + + @AfterEach + void after() { + operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); + } + } + + @WithH2 + class H2WithDeleteCompletion extends WithDeleteCompletion { + @BeforeEach + void cleanUp() { + operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); + } + + @AfterEach + void after() { + operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); + } + } + + @WithH2 + class H2WithArchiveCompletion extends WithArchiveCompletion { + @BeforeEach + void cleanUp() { + operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); + } + + @AfterEach + void after() { + operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); + } + } + + // Postgres + + @WithPostgres + class PostgresWithNoDefinedSchemaName extends WithNoDefinedSchemaName { + } + + @WithPostgres + class PostgresWithDefinedSchemaName extends WithDefinedSchemaName { + } + + @WithPostgres + class PostgresWithEmptySchemaName extends WithEmptySchemaName { + } + + @WithPostgres + class PostgresWithDeleteCompletion extends WithDeleteCompletion { + } + + @WithPostgres + class PostgresWithArchiveCompletion extends WithArchiveCompletion { + } + + // MySQL + + @WithMySql + class MysqlWithNoDefinedSchemaName extends WithNoDefinedSchemaName { + } + + @WithMySql + class MysqlWithDeleteCompletion extends WithDeleteCompletion { + } + + @WithMySql + class MysqlWithArchiveCompletion extends WithArchiveCompletion { + } + + // MariaDB + + @WithMariaDB + class MariaDBWithNoDefinedSchemaName extends WithNoDefinedSchemaName { + } + + @WithMariaDB + class MariaDBWithDeleteCompletion extends WithDeleteCompletion { + } + + @WithMariaDB + class MariaDBWithArchiveCompletion extends WithArchiveCompletion { + } + + // MSSQL + + @WithMssql + class MssqlWithNoDefinedSchemaName extends WithNoDefinedSchemaName { + } + + @WithMssql + class MssqlWithDeleteCompletion extends WithDeleteCompletion { + } + + @WithMssql + class MssqlWithArchiveCompletion extends WithArchiveCompletion { + } + + // Oracle + + @WithOracle + class OracleWithNoDefinedSchemaName extends WithNoDefinedSchemaName { + } + + @WithOracle + class OracleWithDeleteCompletion extends WithDeleteCompletion { + } + + @WithOracle + class OracleWithArchiveCompletion extends WithArchiveCompletion { + } + + private record TestEvent(String eventId) { + } + + private static final class Sample { + } + + @Nested + @ActiveProfiles("h2") + @Testcontainers(disabledWithoutDocker = false) + @Retention(RetentionPolicy.RUNTIME) + @interface WithH2 { + } + + @Nested + @ActiveProfiles("hsql") + @Testcontainers(disabledWithoutDocker = false) + @Retention(RetentionPolicy.RUNTIME) + @interface WithHsql { + } + + @Nested + @ActiveProfiles("mysql") + @Retention(RetentionPolicy.RUNTIME) + @interface WithMySql { + } + + @Nested + @ActiveProfiles("mariadb") + @Retention(RetentionPolicy.RUNTIME) + @interface WithMariaDB { + } + + @Nested + @ActiveProfiles("postgres") + @Retention(RetentionPolicy.RUNTIME) + @interface WithPostgres { + } + + @Nested + @ActiveProfiles("mssql") + @Retention(RetentionPolicy.RUNTIME) + @interface WithMssql { + } + + @Nested + @ActiveProfiles("oracle") + @Retention(RetentionPolicy.RUNTIME) + @interface WithOracle { + } } diff --git a/spring-modulith-events/spring-modulith-events-tests/src/test/java/example/events/PersistentDomainEventIntegrationTest.java b/spring-modulith-events/spring-modulith-events-tests/src/test/java/example/events/PersistentDomainEventIntegrationTest.java index fcb218749..e18fe8def 100644 --- a/spring-modulith-events/spring-modulith-events-tests/src/test/java/example/events/PersistentDomainEventIntegrationTest.java +++ b/spring-modulith-events/spring-modulith-events-tests/src/test/java/example/events/PersistentDomainEventIntegrationTest.java @@ -29,6 +29,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.event.EventListener; import org.springframework.core.env.MapPropertySource; +import org.springframework.modulith.events.FailedAttemptInfo; import org.springframework.modulith.events.IncompleteEventPublications; import org.springframework.modulith.events.config.EnablePersistentDomainEvents; import org.springframework.modulith.events.core.EventPublicationRegistry; @@ -86,7 +87,14 @@ void exposesEventPublicationForFailedListener() throws Exception { // Resubmit failed publications var incompletePublications = context.getBean(IncompleteEventPublications.class); - incompletePublications.resubmitIncompletePublications(__ -> true); + incompletePublications.resubmitIncompletePublications(e -> { + if (e.getFailedAttempts().size() > 10) { + return false; + } + return e.getFailedAttempts().stream() + .map(FailedAttemptInfo::getFailureReason) + .anyMatch(reason-> reason instanceof SomeOtherException); + }); Thread.sleep(200); @@ -218,4 +226,11 @@ public void on(DomainEvent event) throws InterruptedException { throw new RuntimeException("Error!"); } } + + class SomeException extends RuntimeException { + + } + class SomeOtherException extends RuntimeException { + + } } From 0e9170d9096b7c56c45ef8c2d31a630bb46dfbc3 Mon Sep 17 00:00:00 2001 From: "mihaita.tinta" Date: Thu, 4 Dec 2025 12:52:16 +0200 Subject: [PATCH 02/11] fix tests Signed-off-by: mihaita.tinta --- .../jdbc/JdbcEventPublicationRepository.java | 1299 +++++++++-------- .../src/main/resources/schema-h2.sql | 3 +- ...PublicationRepositoryIntegrationTests.java | 71 +- 3 files changed, 700 insertions(+), 673 deletions(-) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java index 447f8e2ed..9d0b6bc6b 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java @@ -15,20 +15,6 @@ */ package org.springframework.modulith.events.jdbc; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Timestamp; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import java.util.function.Supplier; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.BeanClassLoaderAware; @@ -44,6 +30,20 @@ import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + /** * JDBC-based repository to store {@link TargetEventPublication}s. * @@ -55,621 +55,658 @@ */ class JdbcEventPublicationRepository implements EventPublicationRepository, BeanClassLoaderAware { - private static final Logger LOGGER = LoggerFactory.getLogger(JdbcEventPublicationRepository.class); - - private static final String SQL_STATEMENT_INSERT = """ - INSERT INTO %s (ID, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT) - VALUES (?, ?, ?, ?, ?) - """; - private static final String SQL_STATEMENT_INSERT_FAILURE = """ - INSERT INTO %s (EVENT_ID, FAILED_DATE, REASON) - VALUES (?, ?, ?) - """; - - private static final String SQL_STATEMENT_FIND_COMPLETED = """ - SELECT ID, COMPLETION_DATE, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT, FAILED_EVENT_INFO - FROM %s - WHERE COMPLETION_DATE IS NOT NULL - ORDER BY PUBLICATION_DATE ASC - """; - - private static final String SQL_STATEMENT_FIND_UNCOMPLETED = """ - SELECT e.ID, e.COMPLETION_DATE, e.EVENT_TYPE, e.LISTENER_ID, e.PUBLICATION_DATE, e.SERIALIZED_EVENT, f.REASON - FROM %s e - INNER JOIN %s f on f.EVENT_ID = e.ID - WHERE COMPLETION_DATE IS NULL - ORDER BY PUBLICATION_DATE ASC - """; - - private static final String SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE = """ - SELECT ID, COMPLETION_DATE, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT, FAILED_EVENT_INFO - FROM %s - WHERE - COMPLETION_DATE IS NULL - AND PUBLICATION_DATE < ? - ORDER BY PUBLICATION_DATE ASC - """; - - private static final String SQL_STATEMENT_UPDATE_BY_EVENT_AND_LISTENER_ID = """ - UPDATE %s - SET COMPLETION_DATE = ? - WHERE - LISTENER_ID = ? - AND COMPLETION_DATE IS NULL - AND SERIALIZED_EVENT = ? - """; - - private static final String SQL_STATEMENT_UPDATE_BY_ID = """ - UPDATE %s - SET COMPLETION_DATE = ? - WHERE - ID = ? - """; - private static final String SQL_STATEMENT_UPDATE_FAILED_BY_ID = """ - UPDATE %s - SET FAILED_EVENT_INFO = ? - WHERE - ID = ? - """; - - private static final String SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID = """ - SELECT * - FROM %s - WHERE - SERIALIZED_EVENT = ? - AND LISTENER_ID = ? - AND COMPLETION_DATE IS NULL - ORDER BY PUBLICATION_DATE - """; - - private static final String SQL_STATEMENT_DELETE = """ - DELETE - FROM %s - WHERE - ID IN - """; - - private static final String SQL_STATEMENT_DELETE_BY_EVENT_AND_LISTENER_ID = """ - DELETE FROM %s - WHERE - LISTENER_ID = ? - AND SERIALIZED_EVENT = ? - """; - - private static final String SQL_STATEMENT_DELETE_BY_ID = """ - DELETE - FROM %s - WHERE - ID = ? - """; - - private static final String SQL_STATEMENT_DELETE_COMPLETED = """ - DELETE - FROM %s - WHERE - COMPLETION_DATE IS NOT NULL - """; - - private static final String SQL_STATEMENT_DELETE_COMPLETED_BEFORE = """ - DELETE - FROM %s - WHERE - COMPLETION_DATE < ? - """; - - private static final String SQL_STATEMENT_COPY_TO_ARCHIVE_BY_ID = """ - -- Only copy if no entry in target table - INSERT INTO %s (ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, COMPLETION_DATE) - SELECT ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, ? - FROM %s - WHERE ID = ? - AND NOT EXISTS (SELECT 1 FROM %s WHERE ID = EVENT_PUBLICATION.ID) - """; - - private static final String SQL_STATEMENT_COPY_TO_ARCHIVE_BY_EVENT_AND_LISTENER_ID = """ - -- Only copy if no entry in target table - INSERT INTO %s (ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, COMPLETION_DATE) - SELECT ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, ? - FROM %s - WHERE LISTENER_ID = ? - AND SERIALIZED_EVENT = ? - AND NOT EXISTS (SELECT 1 FROM %s WHERE ID = EVENT_PUBLICATION.ID) - """; - - private static final int DELETE_BATCH_SIZE = 100; - - private final JdbcOperations operations; - private final EventSerializer serializer; - private final JdbcRepositorySettings settings; - - private ClassLoader classLoader; - - private final String sqlStatementInsert, - sqlStatementInsertFailed, - sqlStatementFindCompleted, - sqlStatementFindUncompleted, - sqlStatementFindUncompletedBefore, - sqlStatementUpdateByEventAndListenerId, - sqlStatementUpdateById, - sqlStatementFindByEventAndListenerId, - sqlStatementDelete, - sqlStatementDeleteByEventAndListenerId, - sqlStatementDeleteById, - sqlStatementDeleteCompleted, - sqlStatementDeleteCompletedBefore, - sqlStatementCopyToArchive, - sqlStatementCopyToArchiveByEventAndListenerId; - - /** - * Creates a new {@link JdbcEventPublicationRepository} for the given {@link JdbcOperations}, {@link EventSerializer}, - * {@link DatabaseType} and {@link JdbcConfigurationProperties}. - * - * @param operations must not be {@literal null}. - * @param serializer must not be {@literal null}. - * @param settings must not be {@literal null}. - */ - public JdbcEventPublicationRepository(JdbcOperations operations, EventSerializer serializer, - JdbcRepositorySettings settings) { - - Assert.notNull(operations, "JdbcOperations must not be null!"); - Assert.notNull(serializer, "EventSerializer must not be null!"); - Assert.notNull(settings, "DatabaseType must not be null!"); - - this.operations = operations; - this.serializer = serializer; - this.settings = settings; - - var schema = settings.getSchema(); - var table = ObjectUtils.isEmpty(schema) ? "EVENT_PUBLICATION" : schema + ".EVENT_PUBLICATION"; - var failedEventInfoTable = ObjectUtils.isEmpty(schema) ? "EVENT_FAILED_EVENT_INFO" : schema + ".EVENT_FAILED_EVENT_INFO"; - var completedTable = settings.isArchiveCompletion() ? table + "_ARCHIVE" : table; - - this.sqlStatementInsert = SQL_STATEMENT_INSERT.formatted(table); - this.sqlStatementInsertFailed = SQL_STATEMENT_INSERT_FAILURE.formatted(failedEventInfoTable); - this.sqlStatementFindCompleted = SQL_STATEMENT_FIND_COMPLETED.formatted(completedTable); - this.sqlStatementFindUncompleted = SQL_STATEMENT_FIND_UNCOMPLETED.formatted(table, failedEventInfoTable); - this.sqlStatementFindUncompletedBefore = SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE.formatted(table); - this.sqlStatementUpdateByEventAndListenerId = SQL_STATEMENT_UPDATE_BY_EVENT_AND_LISTENER_ID.formatted(table); - this.sqlStatementUpdateById = SQL_STATEMENT_UPDATE_BY_ID.formatted(table); - this.sqlStatementFindByEventAndListenerId = SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID.formatted(table); - this.sqlStatementDelete = SQL_STATEMENT_DELETE.formatted(table); - this.sqlStatementDeleteByEventAndListenerId = SQL_STATEMENT_DELETE_BY_EVENT_AND_LISTENER_ID.formatted(table); - this.sqlStatementDeleteById = SQL_STATEMENT_DELETE_BY_ID.formatted(table); - this.sqlStatementDeleteCompleted = SQL_STATEMENT_DELETE_COMPLETED.formatted(completedTable); - this.sqlStatementDeleteCompletedBefore = SQL_STATEMENT_DELETE_COMPLETED_BEFORE.formatted(completedTable); - this.sqlStatementCopyToArchive = SQL_STATEMENT_COPY_TO_ARCHIVE_BY_ID.formatted(completedTable, table, completedTable); - this.sqlStatementCopyToArchiveByEventAndListenerId = SQL_STATEMENT_COPY_TO_ARCHIVE_BY_EVENT_AND_LISTENER_ID.formatted(completedTable, table, completedTable); - } - - /* - * (non-Javadoc) - * @see org.springframework.beans.factory.BeanClassLoaderAware#setBeanClassLoader(java.lang.ClassLoader) - */ - @Override - public void setBeanClassLoader(ClassLoader classLoader) { - this.classLoader = classLoader; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublicationRepository#create(org.springframework.modulith.events.EventPublication) - */ - @Override - @Transactional - public TargetEventPublication create(TargetEventPublication publication) { - - var serializedEvent = serializeEvent(publication.getEvent()); - - operations.update( // - sqlStatementInsert, // - uuidToDatabase(publication.getIdentifier()), // - publication.getEvent().getClass().getName(), // - publication.getTargetIdentifier().getValue(), // - Timestamp.from(publication.getPublicationDate()), // - serializedEvent); - - return publication; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublicationRepository#markCompleted(java.lang.Object, org.springframework.modulith.events.PublicationTargetIdentifier, java.time.Instant) - */ - @Override - @Transactional - public void markCompleted(Object event, PublicationTargetIdentifier identifier, Instant completionDate) { - - var targetIdentifier = identifier.getValue(); - var serializedEvent = serializer.serialize(event); - - if (settings.isDeleteCompletion()) { - - operations.update(sqlStatementDeleteByEventAndListenerId, targetIdentifier, serializedEvent); - - } else if (settings.isArchiveCompletion()) { - - operations.update(sqlStatementCopyToArchiveByEventAndListenerId, // - Timestamp.from(completionDate), // - targetIdentifier, // - serializedEvent); - operations.update(sqlStatementDeleteByEventAndListenerId, targetIdentifier, serializedEvent); - - } else { - - operations.update(sqlStatementUpdateByEventAndListenerId, // - Timestamp.from(completionDate), // - targetIdentifier, // - serializedEvent); - } - } - - @Override - public void markFailed(UUID identifier, Instant failedDate, Throwable exception) { - - var databaseId = uuidToDatabase(identifier); - var reason = serializer.serialize(new JdbcFailedAttemptInfo(failedDate, exception)); - this.operations.update(sqlStatementInsertFailed, databaseId, failedDate, reason); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#markCompleted(java.util.UUID, java.time.Instant) - */ - @Override - @Transactional - public void markCompleted(UUID identifier, Instant completionDate) { - - var databaseId = uuidToDatabase(identifier); - var timestamp = Timestamp.from(completionDate); - - if (settings.isDeleteCompletion()) { - operations.update(sqlStatementDeleteById, databaseId); - - } else if (settings.isArchiveCompletion()) { - operations.update(sqlStatementCopyToArchive, timestamp, databaseId); - operations.update(sqlStatementDeleteById, databaseId); - - } else { - operations.update(sqlStatementUpdateById, timestamp, databaseId); - } - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#findIncompletePublicationsByEventAndTargetIdentifier(java.lang.Object, org.springframework.modulith.events.core.PublicationTargetIdentifier) - */ - @Override - @Transactional(readOnly = true) - public Optional findIncompletePublicationsByEventAndTargetIdentifier( // - Object event, PublicationTargetIdentifier targetIdentifier) { - - var result = operations.query(sqlStatementFindByEventAndListenerId, // - this::resultSetToPublications, // - serializeEvent(event), // - targetIdentifier.getValue()); - - return result == null ? Optional.empty() : result.stream().findFirst(); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#findCompletedPublications() - */ - @Override - public List findCompletedPublications() { - - var result = operations.query(sqlStatementFindCompleted, this::resultSetToPublications); - - return result == null ? Collections.emptyList() : result; - } - - @Override - @Transactional(readOnly = true) - @SuppressWarnings("null") - public List findIncompletePublications() { - return operations.query(sqlStatementFindUncompleted, this::resultSetToPublications); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#findIncompletePublicationsPublishedBefore(java.time.Instant) - */ - @Override - public List findIncompletePublicationsPublishedBefore(Instant instant) { - - var result = operations.query(sqlStatementFindUncompletedBefore, - this::resultSetToPublications, Timestamp.from(instant)); - - return result == null ? Collections.emptyList() : result; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#deletePublications(java.util.List) - */ - @Override - public void deletePublications(List identifiers) { - - var dbIdentifiers = identifiers.stream().map(this::uuidToDatabase).toList(); - - batch(dbIdentifiers, DELETE_BATCH_SIZE) - .forEach(it -> operations.update(sqlStatementDelete.concat(toParameterPlaceholders(it.length)), it)); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#deleteCompletedPublications() - */ - @Override - public void deleteCompletedPublications() { - operations.execute(sqlStatementDeleteCompleted); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublicationRepository#deleteCompletedPublicationsBefore(java.time.Instant) - */ - @Override - public void deleteCompletedPublicationsBefore(Instant instant) { - - Assert.notNull(instant, "Instant must not be null!"); - - operations.update(sqlStatementDeleteCompletedBefore, Timestamp.from(instant)); - } - - private String serializeEvent(Object event) { - return serializer.serialize(event).toString(); - } - - /** - * Effectively a {@link ResultSetExtractor} to drop {@link TargetEventPublication}s that cannot be deserialized. - * - * @param resultSet must not be {@literal null}. - * @return will never be {@literal null}. - * @throws SQLException - */ - private List resultSetToPublications(ResultSet resultSet) throws SQLException { - - List result = new ArrayList<>(); - - while (resultSet.next()) { - - var publication = resultSetToPublication(resultSet); - - if (publication != null) { - result.add(publication); - } - } - - return result; - } - - /** - * Effectively a {@link RowMapper} to turn a single row into an {@link TargetEventPublication}. - * - * @param rs must not be {@literal null}. - * @return can be {@literal null}. - * @throws SQLException - */ - @Nullable - private TargetEventPublication resultSetToPublication(ResultSet rs) throws SQLException { - - var id = getUuidFromResultSet(rs); - var eventClass = loadClass(id, rs.getString("EVENT_TYPE")); - - if (eventClass == null) { - return null; - } - - var completionDate = rs.getTimestamp("COMPLETION_DATE"); - var publicationDate = rs.getTimestamp("PUBLICATION_DATE").toInstant(); - var listenerId = rs.getString("LISTENER_ID"); - var serializedEvent = rs.getString("SERIALIZED_EVENT"); - var failedEventInfo = rs.getString("REASON"); - - return new JdbcEventPublication(id, publicationDate, listenerId, - () -> serializer.deserialize(serializedEvent, eventClass), - completionDate == null ? null : completionDate.toInstant(), - List.of(serializer.deserialize(failedEventInfo, JdbcFailedAttemptInfo.class)));// TODO add value from resultset - } - - private Object uuidToDatabase(UUID id) { - return settings.getDatabaseType().uuidToDatabase(id); - } - - private UUID getUuidFromResultSet(ResultSet rs) throws SQLException { - return settings.getDatabaseType().databaseToUUID(rs.getObject("ID")); - } - - @Nullable - private Class loadClass(UUID id, String className) { - - try { - return ClassUtils.forName(className, classLoader); - } catch (ClassNotFoundException e) { - LOGGER.warn("Event '{}' of unknown type '{}' found", id, className); - return null; - } - } - - private static List batch(List input, int batchSize) { - - var inputSize = input.size(); - - return IntStream.range(0, (inputSize + batchSize - 1) / batchSize) - .mapToObj(i -> input.subList(i * batchSize, Math.min((i + 1) * batchSize, inputSize))) - .map(List::toArray) - .toList(); - } - - private static String toParameterPlaceholders(int length) { - - return IntStream.range(0, length) - .mapToObj(__ -> "?") - .collect(Collectors.joining(", ", "(", ")")); - } - - private static class JdbcEventPublication implements TargetEventPublication { - - private final UUID id; - private final Instant publicationDate; - private final String listenerId; - private final Supplier eventSupplier; - - private @Nullable Instant completionDate; - private @Nullable Object event; - private List failedAttempts; - - /** - * @param id must not be {@literal null}. - * @param publicationDate must not be {@literal null}. - * @param listenerId must not be {@literal null} or empty. - * @param event must not be {@literal null}.. - * @param completionDate can be {@literal null}. - * @param failedAttempts can be {@literal null}. - */ - public JdbcEventPublication(UUID id, Instant publicationDate, String listenerId, Supplier event, - @Nullable Instant completionDate, List failedAttempts) { - - Assert.notNull(id, "Id must not be null!"); - Assert.notNull(publicationDate, "Publication date must not be null!"); - Assert.hasText(listenerId, "Listener id must not be null or empty!"); - Assert.notNull(event, "Event must not be null!"); - Assert.notNull(failedAttempts, "Failed attempts must not be null!"); - - this.id = id; - this.publicationDate = publicationDate; - this.listenerId = listenerId; - this.eventSupplier = event; - this.completionDate = completionDate; - this.failedAttempts = failedAttempts; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublication#getPublicationIdentifier() - */ - @Override - public UUID getIdentifier() { - return id; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublication#getEvent() - */ - @Override - @SuppressWarnings("null") - public Object getEvent() { - - if (event == null) { - this.event = eventSupplier.get(); - } - - return event; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublication#getTargetIdentifier() - */ - @Override - public PublicationTargetIdentifier getTargetIdentifier() { - return PublicationTargetIdentifier.of(listenerId); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublication#getPublicationDate() - */ - @Override - public Instant getPublicationDate() { - return publicationDate; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.CompletableEventPublication#getCompletionDate() - */ - @Override - public Optional getCompletionDate() { - return Optional.ofNullable(completionDate); - } - - @Override - public List getFailedAttempts() { - return failedAttempts; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.CompletableEventPublication#isPublicationCompleted() - */ - @Override - public boolean isPublicationCompleted() { - return completionDate != null; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.Completable#markCompleted(java.time.Instant) - */ - @Override - public void markCompleted(Instant instant) { - this.completionDate = instant; - } - - @Override - public void markFailed(Instant instant, Throwable exception) { - - if (failedAttempts == null) { - failedAttempts = new ArrayList<>(); - } - failedAttempts.add(new JdbcFailedAttemptInfo(instant, exception)); - } - - /* - * (non-Javadoc) - * @see java.lang.Object#equals(java.lang.Object) - */ - @Override - public boolean equals(@Nullable Object obj) { - - if (this == obj) { - return true; - } - - if (!(obj instanceof JdbcEventPublication that)) { - return false; - } - - return Objects.equals(completionDate, that.completionDate) // - && Objects.equals(id, that.id) // - && Objects.equals(listenerId, that.listenerId) // - && Objects.equals(publicationDate, that.publicationDate) // - && Objects.equals(getEvent(), that.getEvent()); - } - - /* - * (non-Javadoc) - * @see java.lang.Object#hashCode() - */ - @Override - public int hashCode() { - return Objects.hash(completionDate, id, listenerId, publicationDate, getEvent()); - } - } - - record JdbcFailedAttemptInfo(Instant publicationDate, Throwable failureReason) implements FailedAttemptInfo { - - @Override - public Instant getPublicationDate() { - return publicationDate; - } - - @Override - public Throwable getFailureReason() { - return failureReason; - } - } + private static final Logger LOGGER = LoggerFactory.getLogger(JdbcEventPublicationRepository.class); + + private static final String SQL_STATEMENT_INSERT = """ + INSERT INTO %s (ID, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT) + VALUES (?, ?, ?, ?, ?) + """; + private static final String SQL_STATEMENT_INSERT_FAILURE = """ + INSERT INTO %s (EVENT_ID, FAILED_DATE, SERIALIZED_REASON, REASON_TYPE) + VALUES (?, ?, ?, ?) + """; + + private static final String SQL_STATEMENT_FIND_COMPLETED = """ + SELECT e.ID, e.COMPLETION_DATE, e.EVENT_TYPE, e.LISTENER_ID, e.PUBLICATION_DATE, e.SERIALIZED_EVENT, + f.SERIALIZED_REASON, f.REASON_TYPE, f.FAILED_DATE + FROM %s e + LEFT JOIN %s f on f.EVENT_ID = e.ID + WHERE e.COMPLETION_DATE IS NOT NULL + ORDER BY e.PUBLICATION_DATE ASC + """; + + private static final String SQL_STATEMENT_FIND_UNCOMPLETED = """ + SELECT e.ID, e.COMPLETION_DATE, e.EVENT_TYPE, e.LISTENER_ID, e.PUBLICATION_DATE, e.SERIALIZED_EVENT, + f.SERIALIZED_REASON, f.REASON_TYPE, f.FAILED_DATE + FROM %s e + LEFT JOIN %s f on f.EVENT_ID = e.ID + WHERE COMPLETION_DATE IS NULL + ORDER BY PUBLICATION_DATE ASC + """; + + private static final String SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE = """ + SELECT e.ID, e.COMPLETION_DATE, e.EVENT_TYPE, e.LISTENER_ID, e.PUBLICATION_DATE, e.SERIALIZED_EVENT, + f.SERIALIZED_REASON, f.REASON_TYPE, f.FAILED_DATE + FROM %s e + LEFT JOIN %s f on f.EVENT_ID = e.ID + WHERE + e.COMPLETION_DATE IS NULL + AND e.PUBLICATION_DATE < ? + ORDER BY e.PUBLICATION_DATE ASC + """; + + private static final String SQL_STATEMENT_UPDATE_BY_EVENT_AND_LISTENER_ID = """ + UPDATE %s + SET COMPLETION_DATE = ? + WHERE + LISTENER_ID = ? + AND COMPLETION_DATE IS NULL + AND SERIALIZED_EVENT = ? + """; + + private static final String SQL_STATEMENT_UPDATE_BY_ID = """ + UPDATE %s + SET COMPLETION_DATE = ? + WHERE + ID = ? + """; + private static final String SQL_STATEMENT_UPDATE_FAILED_BY_ID = """ + UPDATE %s + SET FAILED_EVENT_INFO = ? + WHERE + ID = ? + """; + + private static final String SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID = """ + SELECT e.*, f.SERIALIZED_REASON, f.REASON_TYPE, f.FAILED_DATE + FROM %s e + LEFT JOIN %s f on f.EVENT_ID = e.ID + WHERE + e.SERIALIZED_EVENT = ? + AND e.LISTENER_ID = ? + AND e.COMPLETION_DATE IS NULL + ORDER BY e.PUBLICATION_DATE + """; + + private static final String SQL_STATEMENT_DELETE = """ + DELETE + FROM %s + WHERE + ID IN + """; + + private static final String SQL_STATEMENT_DELETE_BY_EVENT_AND_LISTENER_ID = """ + DELETE FROM %s + WHERE + LISTENER_ID = ? + AND SERIALIZED_EVENT = ? + """; + + private static final String SQL_STATEMENT_DELETE_BY_ID = """ + DELETE + FROM %s + WHERE + ID = ? + """; + + private static final String SQL_STATEMENT_DELETE_COMPLETED = """ + DELETE + FROM %s + WHERE + COMPLETION_DATE IS NOT NULL + """; + + private static final String SQL_STATEMENT_DELETE_COMPLETED_BEFORE = """ + DELETE + FROM %s + WHERE + COMPLETION_DATE < ? + """; + + private static final String SQL_STATEMENT_COPY_TO_ARCHIVE_BY_ID = """ + -- Only copy if no entry in target table + INSERT INTO %s (ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, COMPLETION_DATE) + SELECT ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, ? + FROM %s + WHERE ID = ? + AND NOT EXISTS (SELECT 1 FROM %s WHERE ID = EVENT_PUBLICATION.ID) + """; + + private static final String SQL_STATEMENT_COPY_TO_ARCHIVE_BY_EVENT_AND_LISTENER_ID = """ + -- Only copy if no entry in target table + INSERT INTO %s (ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, COMPLETION_DATE) + SELECT ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, ? + FROM %s + WHERE LISTENER_ID = ? + AND SERIALIZED_EVENT = ? + AND NOT EXISTS (SELECT 1 FROM %s WHERE ID = EVENT_PUBLICATION.ID) + """; + + private static final int DELETE_BATCH_SIZE = 100; + + private final JdbcOperations operations; + private final EventSerializer serializer; + private final JdbcRepositorySettings settings; + + private ClassLoader classLoader; + + private final String sqlStatementInsert, + sqlStatementInsertFailed, + sqlStatementFindCompleted, + sqlStatementFindUncompleted, + sqlStatementFindUncompletedBefore, + sqlStatementUpdateByEventAndListenerId, + sqlStatementUpdateById, + sqlStatementFindByEventAndListenerId, + sqlStatementDelete, + sqlStatementDeleteByEventAndListenerId, + sqlStatementDeleteById, + sqlStatementDeleteCompleted, + sqlStatementDeleteCompletedBefore, + sqlStatementCopyToArchive, + sqlStatementCopyToArchiveByEventAndListenerId; + + /** + * Creates a new {@link JdbcEventPublicationRepository} for the given {@link JdbcOperations}, {@link EventSerializer}, + * {@link DatabaseType} and {@link JdbcConfigurationProperties}. + * + * @param operations must not be {@literal null}. + * @param serializer must not be {@literal null}. + * @param settings must not be {@literal null}. + */ + public JdbcEventPublicationRepository(JdbcOperations operations, EventSerializer serializer, + JdbcRepositorySettings settings) { + + Assert.notNull(operations, "JdbcOperations must not be null!"); + Assert.notNull(serializer, "EventSerializer must not be null!"); + Assert.notNull(settings, "DatabaseType must not be null!"); + + this.operations = operations; + this.serializer = serializer; + this.settings = settings; + + var schema = settings.getSchema(); + var table = ObjectUtils.isEmpty(schema) ? "EVENT_PUBLICATION" : schema + ".EVENT_PUBLICATION"; + var failedAttemptInfoTable = ObjectUtils.isEmpty(schema) ? "EVENT_FAILED_EVENT_INFO" : schema + ".EVENT_FAILED_EVENT_INFO"; + var completedTable = settings.isArchiveCompletion() ? table + "_ARCHIVE" : table; + + this.sqlStatementInsert = SQL_STATEMENT_INSERT.formatted(table); + this.sqlStatementInsertFailed = SQL_STATEMENT_INSERT_FAILURE.formatted(failedAttemptInfoTable); + this.sqlStatementFindCompleted = SQL_STATEMENT_FIND_COMPLETED.formatted(completedTable, failedAttemptInfoTable); + this.sqlStatementFindUncompleted = SQL_STATEMENT_FIND_UNCOMPLETED.formatted(table, failedAttemptInfoTable); + this.sqlStatementFindUncompletedBefore = SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE.formatted(table, failedAttemptInfoTable); + this.sqlStatementUpdateByEventAndListenerId = SQL_STATEMENT_UPDATE_BY_EVENT_AND_LISTENER_ID.formatted(table); + this.sqlStatementUpdateById = SQL_STATEMENT_UPDATE_BY_ID.formatted(table); + this.sqlStatementFindByEventAndListenerId = SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID.formatted(table, failedAttemptInfoTable); + this.sqlStatementDelete = SQL_STATEMENT_DELETE.formatted(table); + this.sqlStatementDeleteByEventAndListenerId = SQL_STATEMENT_DELETE_BY_EVENT_AND_LISTENER_ID.formatted(table); + this.sqlStatementDeleteById = SQL_STATEMENT_DELETE_BY_ID.formatted(table); + this.sqlStatementDeleteCompleted = SQL_STATEMENT_DELETE_COMPLETED.formatted(completedTable); + this.sqlStatementDeleteCompletedBefore = SQL_STATEMENT_DELETE_COMPLETED_BEFORE.formatted(completedTable); + this.sqlStatementCopyToArchive = SQL_STATEMENT_COPY_TO_ARCHIVE_BY_ID.formatted(completedTable, table, completedTable); + this.sqlStatementCopyToArchiveByEventAndListenerId = SQL_STATEMENT_COPY_TO_ARCHIVE_BY_EVENT_AND_LISTENER_ID.formatted(completedTable, table, completedTable); + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.BeanClassLoaderAware#setBeanClassLoader(java.lang.ClassLoader) + */ + @Override + public void setBeanClassLoader(ClassLoader classLoader) { + this.classLoader = classLoader; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublicationRepository#create(org.springframework.modulith.events.EventPublication) + */ + @Override + @Transactional + public TargetEventPublication create(TargetEventPublication publication) { + + var serializedEvent = serializeEvent(publication.getEvent()); + + operations.update( // + sqlStatementInsert, // + uuidToDatabase(publication.getIdentifier()), // + publication.getEvent().getClass().getName(), // + publication.getTargetIdentifier().getValue(), // + Timestamp.from(publication.getPublicationDate()), // + serializedEvent); + + return publication; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublicationRepository#markCompleted(java.lang.Object, org.springframework.modulith.events.PublicationTargetIdentifier, java.time.Instant) + */ + @Override + @Transactional + public void markCompleted(Object event, PublicationTargetIdentifier identifier, Instant completionDate) { + + var targetIdentifier = identifier.getValue(); + var serializedEvent = serializer.serialize(event); + + if (settings.isDeleteCompletion()) { + + operations.update(sqlStatementDeleteByEventAndListenerId, targetIdentifier, serializedEvent); + + } else if (settings.isArchiveCompletion()) { + + operations.update(sqlStatementCopyToArchiveByEventAndListenerId, // + Timestamp.from(completionDate), // + targetIdentifier, // + serializedEvent); + operations.update(sqlStatementDeleteByEventAndListenerId, targetIdentifier, serializedEvent); + + } else { + + operations.update(sqlStatementUpdateByEventAndListenerId, // + Timestamp.from(completionDate), // + targetIdentifier, // + serializedEvent); + } + } + + @Override + public void markFailed(UUID identifier, Instant failedDate, Throwable exception) { + + var databaseId = uuidToDatabase(identifier); + var reason = serializer.serialize(exception); + this.operations.update(sqlStatementInsertFailed, databaseId, failedDate, + reason, exception.getClass().getName()); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#markCompleted(java.util.UUID, java.time.Instant) + */ + @Override + @Transactional + public void markCompleted(UUID identifier, Instant completionDate) { + + var databaseId = uuidToDatabase(identifier); + var timestamp = Timestamp.from(completionDate); + + if (settings.isDeleteCompletion()) { + operations.update(sqlStatementDeleteById, databaseId); + + } else if (settings.isArchiveCompletion()) { + operations.update(sqlStatementCopyToArchive, timestamp, databaseId); + operations.update(sqlStatementDeleteById, databaseId); + + } else { + operations.update(sqlStatementUpdateById, timestamp, databaseId); + } + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#findIncompletePublicationsByEventAndTargetIdentifier(java.lang.Object, org.springframework.modulith.events.core.PublicationTargetIdentifier) + */ + @Override + @Transactional(readOnly = true) + public Optional findIncompletePublicationsByEventAndTargetIdentifier( // + Object event, PublicationTargetIdentifier targetIdentifier) { + + var result = operations.query(sqlStatementFindByEventAndListenerId, // + this::resultSetToPublications, // + serializeEvent(event), // + targetIdentifier.getValue()); + + return result == null ? Optional.empty() : result.stream().findFirst(); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#findCompletedPublications() + */ + @Override + public List findCompletedPublications() { + + var result = operations.query(sqlStatementFindCompleted, this::resultSetToPublications); + + return result == null ? Collections.emptyList() : result; + } + + @Override + @Transactional(readOnly = true) + @SuppressWarnings("null") + public List findIncompletePublications() { + return operations.query(sqlStatementFindUncompleted, this::resultSetToPublications); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#findIncompletePublicationsPublishedBefore(java.time.Instant) + */ + @Override + public List findIncompletePublicationsPublishedBefore(Instant instant) { + + var result = operations.query(sqlStatementFindUncompletedBefore, + this::resultSetToPublications, Timestamp.from(instant)); + + return result == null ? Collections.emptyList() : result; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#deletePublications(java.util.List) + */ + @Override + public void deletePublications(List identifiers) { + + var dbIdentifiers = identifiers.stream().map(this::uuidToDatabase).toList(); + + batch(dbIdentifiers, DELETE_BATCH_SIZE) + .forEach(it -> operations.update(sqlStatementDelete.concat(toParameterPlaceholders(it.length)), it)); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#deleteCompletedPublications() + */ + @Override + public void deleteCompletedPublications() { + operations.execute(sqlStatementDeleteCompleted); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublicationRepository#deleteCompletedPublicationsBefore(java.time.Instant) + */ + @Override + public void deleteCompletedPublicationsBefore(Instant instant) { + + Assert.notNull(instant, "Instant must not be null!"); + + operations.update(sqlStatementDeleteCompletedBefore, Timestamp.from(instant)); + } + + private String serializeEvent(Object event) { + return serializer.serialize(event).toString(); + } + + /** + * Effectively a {@link ResultSetExtractor} to drop {@link TargetEventPublication}s that cannot be deserialized. + * + * @param resultSet must not be {@literal null}. + * @return will never be {@literal null}. + * @throws SQLException + */ + private List resultSetToPublications(ResultSet resultSet) throws SQLException { + + List result = new ArrayList<>(); + + TargetEventPublication lastPublication = null; + while (resultSet.next()) { + + var publication = resultSetToPublication(resultSet); + + if (publication != null) { + + if (lastPublication == null || !lastPublication.getIdentifier().equals(publication.id)) { + lastPublication = new JdbcEventPublication(publication.id(), + publication.publicationDate, + publication.listenerId, + publication.event, + publication.completionDate, + new ArrayList<>() + ); + result.add(lastPublication); + } + + if (publication.failedAttempt.isPresent()) { + lastPublication.getFailedAttempts().add(publication.failedAttempt.get()); + } + } + } + return result; + } + + /** + * Effectively a {@link RowMapper} to turn a single row into an {@link ResultSetJdbcEventPublication}. + * + * @param rs must not be {@literal null}. + * @return can be {@literal null}. + * @throws SQLException + */ + @Nullable + private ResultSetJdbcEventPublication resultSetToPublication(ResultSet rs) throws SQLException { + + var id = getUuidFromResultSet(rs); + var eventClass = loadClass(id, rs.getString("EVENT_TYPE")); + + if (eventClass == null) { + return null; + } + + var completionDate = rs.getTimestamp("COMPLETION_DATE"); + var publicationDate = rs.getTimestamp("PUBLICATION_DATE").toInstant(); + var listenerId = rs.getString("LISTENER_ID"); + var serializedEvent = rs.getString("SERIALIZED_EVENT"); + + var failedAttemptReasonType = rs.getString("REASON_TYPE"); + Optional attempt = Optional.empty(); + if (failedAttemptReasonType != null) { + var attemptClass = loadClass(id, failedAttemptReasonType); + if (attemptClass != null) { + var failedAttemptReason = rs.getString("SERIALIZED_REASON"); + var failedAttemptDate = rs.getTimestamp("FAILED_DATE").toInstant(); + attempt = Optional.of(new JdbcFailedAttemptInfo(failedAttemptDate, + serializer.deserialize(failedAttemptReason, (Class) attemptClass) + )); + } + } + return new ResultSetJdbcEventPublication(id, publicationDate, listenerId, + () -> serializer.deserialize(serializedEvent, eventClass), + completionDate == null ? null : completionDate.toInstant(), + attempt);// TODO add value from resultset + } + + private Object uuidToDatabase(UUID id) { + return settings.getDatabaseType().uuidToDatabase(id); + } + + private UUID getUuidFromResultSet(ResultSet rs) throws SQLException { + return settings.getDatabaseType().databaseToUUID(rs.getObject("ID")); + } + + @Nullable + private Class loadClass(UUID id, String className) { + + try { + return ClassUtils.forName(className, classLoader); + } catch (ClassNotFoundException e) { + LOGGER.warn("Event '{}' of unknown type '{}' found", id, className); + return null; + } + } + + private static List batch(List input, int batchSize) { + + var inputSize = input.size(); + + return IntStream.range(0, (inputSize + batchSize - 1) / batchSize) + .mapToObj(i -> input.subList(i * batchSize, Math.min((i + 1) * batchSize, inputSize))) + .map(List::toArray) + .toList(); + } + + private static String toParameterPlaceholders(int length) { + + return IntStream.range(0, length) + .mapToObj(__ -> "?") + .collect(Collectors.joining(", ", "(", ")")); + } + + private static class JdbcEventPublication implements TargetEventPublication { + + private final UUID id; + private final Instant publicationDate; + private final String listenerId; + private final Supplier eventSupplier; + + private @Nullable Instant completionDate; + private @Nullable Object event; + private List failedAttempts; + + /** + * @param id must not be {@literal null}. + * @param publicationDate must not be {@literal null}. + * @param listenerId must not be {@literal null} or empty. + * @param event must not be {@literal null}.. + * @param completionDate can be {@literal null}. + * @param failedAttempts can be {@literal null}. + */ + public JdbcEventPublication(UUID id, Instant publicationDate, String listenerId, Supplier event, + @Nullable Instant completionDate, List failedAttempts) { + + Assert.notNull(id, "Id must not be null!"); + Assert.notNull(publicationDate, "Publication date must not be null!"); + Assert.hasText(listenerId, "Listener id must not be null or empty!"); + Assert.notNull(event, "Event must not be null!"); + Assert.notNull(failedAttempts, "Failed attempts must not be null!"); + + this.id = id; + this.publicationDate = publicationDate; + this.listenerId = listenerId; + this.eventSupplier = event; + this.completionDate = completionDate; + this.failedAttempts = failedAttempts; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublication#getPublicationIdentifier() + */ + @Override + public UUID getIdentifier() { + return id; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublication#getEvent() + */ + @Override + @SuppressWarnings("null") + public Object getEvent() { + + if (event == null) { + this.event = eventSupplier.get(); + } + + return event; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublication#getTargetIdentifier() + */ + @Override + public PublicationTargetIdentifier getTargetIdentifier() { + return PublicationTargetIdentifier.of(listenerId); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublication#getPublicationDate() + */ + @Override + public Instant getPublicationDate() { + return publicationDate; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.CompletableEventPublication#getCompletionDate() + */ + @Override + public Optional getCompletionDate() { + return Optional.ofNullable(completionDate); + } + + @Override + public List getFailedAttempts() { + return failedAttempts; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.CompletableEventPublication#isPublicationCompleted() + */ + @Override + public boolean isPublicationCompleted() { + return completionDate != null; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.Completable#markCompleted(java.time.Instant) + */ + @Override + public void markCompleted(Instant instant) { + this.completionDate = instant; + } + + @Override + public void markFailed(Instant instant, Throwable exception) { + + if (failedAttempts == null) { + failedAttempts = new ArrayList<>(); + } + failedAttempts.add(new JdbcFailedAttemptInfo(instant, exception)); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ + @Override + public boolean equals(@Nullable Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof JdbcEventPublication that)) { + return false; + } + + return Objects.equals(completionDate, that.completionDate) // + && Objects.equals(id, that.id) // + && Objects.equals(listenerId, that.listenerId) // + && Objects.equals(publicationDate, that.publicationDate) // + && Objects.equals(getEvent(), that.getEvent()); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + return Objects.hash(completionDate, id, listenerId, publicationDate, getEvent()); + } + } + + record ResultSetJdbcEventPublication(UUID id, Instant publicationDate, String listenerId, Supplier event, + @Nullable Instant completionDate, Optional failedAttempt) { + + } + + record JdbcFailedAttemptInfo(Instant publicationDate, Throwable failureReason) implements FailedAttemptInfo { + + @Override + public Instant getPublicationDate() { + return publicationDate; + } + + @Override + public Throwable getFailureReason() { + return failureReason; + } + } } diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql index 52f2f2926..52a163d13 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql @@ -14,7 +14,8 @@ CREATE TABLE IF NOT EXISTS EVENT_FAILED_EVENT_INFO ( EVENT_ID UUID NOT NULL, FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, - REASON VARCHAR(4000) NOT NULL, + SERIALIZED_REASON VARCHAR(4000) NOT NULL, + REASON_TYPE VARCHAR(512) NOT NULL, CONSTRAINT FK_FAILED_EVENT_INFO_EVENT FOREIGN KEY (EVENT_ID) REFERENCES EVENT_PUBLICATION(ID) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java index 6ec9d37ad..b9483bc6b 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java @@ -82,11 +82,10 @@ static abstract class TestBase { @BeforeEach void cleanUp() { - operations.execute("TRUNCATE TABLE " + failedInfoTable()); - operations.execute("TRUNCATE TABLE " + table()); + operations.execute("DELETE FROM " + table()); if (properties.isArchiveCompletion()) { - operations.execute("TRUNCATE TABLE " + archiveTable()); + operations.execute("DELETE FROM " + archiveTable()); } } @@ -417,8 +416,8 @@ void findsPublicationsThatFailedOnce() { Instant now = Instant.now(); IllegalStateException reason = new IllegalStateException("failed once"); var entry = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(now, reason); - doReturn(entry.toString()).when(serializer).serialize(entry); - doReturn(entry).when(serializer).deserialize(entry.toString(), entry.getClass()); + doReturn(reason.toString()).when(serializer).serialize(reason); + doReturn(reason).when(serializer).deserialize(reason.toString(), reason.getClass()); repository.markFailed(first.getIdentifier(), now, reason); @@ -428,6 +427,30 @@ void findsPublicationsThatFailedOnce() { } + @Test + // GH-294 + void findsPublicationsThatFailedTwice() { + + var first = createPublication(new TestEvent("first")); + Instant now = Instant.now(); + IllegalStateException reason1 = new IllegalStateException("failed once"); + IllegalStateException reason2 = new IllegalStateException("failed second time"); + var entry1 = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(now, reason1); + var entry2 = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(now, reason2); + doReturn(reason1.toString()).when(serializer).serialize(reason1); + doReturn(reason2.toString()).when(serializer).serialize(reason2); + doReturn(reason1).when(serializer).deserialize(reason1.toString(), reason1.getClass()); + doReturn(reason2).when(serializer).deserialize(reason2.toString(), reason2.getClass()); + + repository.markFailed(first.getIdentifier(), now, reason1); + repository.markFailed(first.getIdentifier(), now, reason2); + + assertThat(repository.findIncompletePublications()) + .extracting(TargetEventPublication::getFailedAttempts) + .containsExactly(List.of(entry1, entry2)); + + } + String table() { return "EVENT_PUBLICATION"; } @@ -515,65 +538,31 @@ class H2WithNoDefinedSchemaName extends WithNoDefinedSchemaName { // issue: https://github.com/h2database/h2database/issues/2065 @BeforeEach void cleanUp() { + super.cleanUp(); operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); } @AfterEach void after() { + super.cleanUp(); operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); } } @WithH2 class H2WithDefinedSchemaName extends WithDefinedSchemaName { - @BeforeEach - void cleanUp() { - operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); - } - - @AfterEach - void after() { - operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); - } } @WithH2 class H2WithEmptySchemaName extends WithEmptySchemaName { - @BeforeEach - void cleanUp() { - operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); - } - - @AfterEach - void after() { - operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); - } } @WithH2 class H2WithDeleteCompletion extends WithDeleteCompletion { - @BeforeEach - void cleanUp() { - operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); - } - - @AfterEach - void after() { - operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); - } } @WithH2 class H2WithArchiveCompletion extends WithArchiveCompletion { - @BeforeEach - void cleanUp() { - operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); - } - - @AfterEach - void after() { - operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); - } } // Postgres From 458f5e0d555499d7965be1f23eb716ff30d9575d Mon Sep 17 00:00:00 2001 From: "mihaita.tinta" Date: Thu, 4 Dec 2025 15:55:10 +0200 Subject: [PATCH 03/11] update schemas Signed-off-by: mihaita.tinta --- .../events/jdbc/JdbcEventPublicationRepository.java | 2 +- .../src/main/resources/schema-h2.sql | 2 +- .../src/main/resources/schema-hsqldb.sql | 12 ++++++++++++ .../src/main/resources/schema-mariadb.sql | 12 ++++++++++++ .../src/main/resources/schema-mysql.sql | 12 ++++++++++++ .../src/main/resources/schema-oracle.sql | 12 ++++++++++++ .../src/main/resources/schema-postgresql.sql | 12 ++++++++++++ .../src/main/resources/schema-sqlserver.sql | 12 ++++++++++++ ...bcEventPublicationRepositoryIntegrationTests.java | 2 +- 9 files changed, 75 insertions(+), 3 deletions(-) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java index 9d0b6bc6b..9496612ab 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java @@ -227,7 +227,7 @@ public JdbcEventPublicationRepository(JdbcOperations operations, EventSerializer var schema = settings.getSchema(); var table = ObjectUtils.isEmpty(schema) ? "EVENT_PUBLICATION" : schema + ".EVENT_PUBLICATION"; - var failedAttemptInfoTable = ObjectUtils.isEmpty(schema) ? "EVENT_FAILED_EVENT_INFO" : schema + ".EVENT_FAILED_EVENT_INFO"; + var failedAttemptInfoTable = ObjectUtils.isEmpty(schema) ? "EVENT_FAILED_ATTEMPT_INFO" : schema + ".EVENT_FAILED_ATTEMPT_INFO"; var completedTable = settings.isArchiveCompletion() ? table + "_ARCHIVE" : table; this.sqlStatementInsert = SQL_STATEMENT_INSERT.formatted(table); diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql index 52a163d13..30b86988a 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION ); CREATE INDEX IF NOT EXISTS EVENT_PUBLICATION_BY_LISTENER_ID_AND_SERIALIZED_EVENT_IDX ON EVENT_PUBLICATION (LISTENER_ID, SERIALIZED_EVENT); CREATE INDEX IF NOT EXISTS EVENT_PUBLICATION_BY_COMPLETION_DATE_IDX ON EVENT_PUBLICATION (COMPLETION_DATE); -CREATE TABLE IF NOT EXISTS EVENT_FAILED_EVENT_INFO +CREATE TABLE IF NOT EXISTS EVENT_FAILED_ATTEMPT_INFO ( EVENT_ID UUID NOT NULL, FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-hsqldb.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-hsqldb.sql index a7bf44600..086197b40 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-hsqldb.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-hsqldb.sql @@ -10,3 +10,15 @@ CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION ); CREATE INDEX IF NOT EXISTS EVENT_PUBLICATION_BY_LISTENER_ID_AND_SERIALIZED_EVENT_IDX ON EVENT_PUBLICATION (LISTENER_ID, SERIALIZED_EVENT); CREATE INDEX IF NOT EXISTS EVENT_PUBLICATION_BY_COMPLETION_DATE_IDX ON EVENT_PUBLICATION (COMPLETION_DATE); +CREATE TABLE IF NOT EXISTS EVENT_FAILED_ATTEMPT_INFO +( + EVENT_ID UUID NOT NULL, + FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, + SERIALIZED_REASON VARCHAR(4000) NOT NULL, + REASON_TYPE VARCHAR(512) NOT NULL, + CONSTRAINT FK_FAILED_EVENT_INFO_EVENT + FOREIGN KEY (EVENT_ID) + REFERENCES EVENT_PUBLICATION(ID) + ON DELETE CASCADE + +); \ No newline at end of file diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mariadb.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mariadb.sql index 370dec719..15811c005 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mariadb.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mariadb.sql @@ -9,3 +9,15 @@ CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION PRIMARY KEY (ID), INDEX EVENT_PUBLICATION_BY_COMPLETION_DATE_IDX (COMPLETION_DATE) ); +CREATE TABLE IF NOT EXISTS EVENT_FAILED_ATTEMPT_INFO +( + EVENT_ID UUID NOT NULL, + FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, + SERIALIZED_REASON VARCHAR(4000) NOT NULL, + REASON_TYPE VARCHAR(512) NOT NULL, + CONSTRAINT FK_FAILED_EVENT_INFO_EVENT + FOREIGN KEY (EVENT_ID) + REFERENCES EVENT_PUBLICATION(ID) + ON DELETE CASCADE + +); diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mysql.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mysql.sql index 370dec719..4f8ff8aad 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mysql.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mysql.sql @@ -9,3 +9,15 @@ CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION PRIMARY KEY (ID), INDEX EVENT_PUBLICATION_BY_COMPLETION_DATE_IDX (COMPLETION_DATE) ); +CREATE TABLE IF NOT EXISTS EVENT_FAILED_ATTEMPT_INFO +( + EVENT_ID UUID NOT NULL, + FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, + SERIALIZED_REASON VARCHAR(4000) NOT NULL, + REASON_TYPE VARCHAR(512) NOT NULL, + CONSTRAINT FK_FAILED_EVENT_INFO_EVENT + FOREIGN KEY (EVENT_ID) + REFERENCES EVENT_PUBLICATION(ID) + ON DELETE CASCADE + +); \ No newline at end of file diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-oracle.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-oracle.sql index 1258783f4..4e84a41aa 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-oracle.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-oracle.sql @@ -10,3 +10,15 @@ CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION ( ); CREATE INDEX IF NOT EXISTS EVENT_PUBLICATION_BY_LISTENER_ID_AND_SERIALIZED_EVENT_IDX ON EVENT_PUBLICATION (LISTENER_ID, SERIALIZED_EVENT); CREATE INDEX IF NOT EXISTS EVENT_PUBLICATION_BY_COMPLETION_DATE_IDX ON EVENT_PUBLICATION (COMPLETION_DATE); +CREATE TABLE IF NOT EXISTS EVENT_FAILED_ATTEMPT_INFO +( + EVENT_ID UUID NOT NULL, + FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, + SERIALIZED_REASON VARCHAR(4000) NOT NULL, + REASON_TYPE VARCHAR(512) NOT NULL, + CONSTRAINT FK_FAILED_EVENT_INFO_EVENT + FOREIGN KEY (EVENT_ID) + REFERENCES EVENT_PUBLICATION(ID) + ON DELETE CASCADE + +); \ No newline at end of file diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-postgresql.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-postgresql.sql index df0b9f20c..1dab7f7d6 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-postgresql.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-postgresql.sql @@ -10,3 +10,15 @@ CREATE TABLE IF NOT EXISTS event_publication ); CREATE INDEX IF NOT EXISTS event_publication_serialized_event_hash_idx ON event_publication USING hash(serialized_event); CREATE INDEX IF NOT EXISTS event_publication_by_completion_date_idx ON event_publication (completion_date); +CREATE TABLE IF NOT EXISTS EVENT_FAILED_ATTEMPT_INFO +( + EVENT_ID UUID NOT NULL, + FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, + SERIALIZED_REASON VARCHAR(4000) NOT NULL, + REASON_TYPE VARCHAR(512) NOT NULL, + CONSTRAINT FK_FAILED_EVENT_INFO_EVENT + FOREIGN KEY (EVENT_ID) + REFERENCES EVENT_PUBLICATION(ID) + ON DELETE CASCADE + +); \ No newline at end of file diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-sqlserver.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-sqlserver.sql index d0e84e344..646682d53 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-sqlserver.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-sqlserver.sql @@ -10,3 +10,15 @@ CREATE TABLE EVENT_PUBLICATION PRIMARY KEY (ID), INDEX EVENT_PUBLICATION_BY_COMPLETION_DATE_IDX (COMPLETION_DATE) ); +CREATE TABLE IF NOT EXISTS EVENT_FAILED_ATTEMPT_INFO +( + EVENT_ID UUID NOT NULL, + FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, + SERIALIZED_REASON VARCHAR(4000) NOT NULL, + REASON_TYPE VARCHAR(512) NOT NULL, + CONSTRAINT FK_FAILED_EVENT_INFO_EVENT + FOREIGN KEY (EVENT_ID) + REFERENCES EVENT_PUBLICATION(ID) + ON DELETE CASCADE + +); \ No newline at end of file diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java index b9483bc6b..f4a9397c9 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java @@ -456,7 +456,7 @@ String table() { } String failedInfoTable() { - return "EVENT_FAILED_EVENT_INFO"; + return "EVENT_FAILED_ATTEMPT_INFO"; } String archiveTable() { From 863bbd8f3cd25812a6ffc2aefc0d5517a5edad8e Mon Sep 17 00:00:00 2001 From: "mihaita.tinta" Date: Thu, 4 Dec 2025 16:12:31 +0200 Subject: [PATCH 04/11] fix oracle Signed-off-by: mihaita.tinta --- .../events/jdbc/JdbcEventPublicationRepository.java | 2 +- .../src/main/resources/schema-oracle.sql | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java index 9496612ab..116467254 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java @@ -314,7 +314,7 @@ public void markFailed(UUID identifier, Instant failedDate, Throwable exception) var databaseId = uuidToDatabase(identifier); var reason = serializer.serialize(exception); - this.operations.update(sqlStatementInsertFailed, databaseId, failedDate, + this.operations.update(sqlStatementInsertFailed, databaseId, Timestamp.from(failedDate), reason, exception.getClass().getName()); } diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-oracle.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-oracle.sql index 4e84a41aa..58de0ebff 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-oracle.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-oracle.sql @@ -12,10 +12,10 @@ CREATE INDEX IF NOT EXISTS EVENT_PUBLICATION_BY_LISTENER_ID_AND_SERIALIZED_EVENT CREATE INDEX IF NOT EXISTS EVENT_PUBLICATION_BY_COMPLETION_DATE_IDX ON EVENT_PUBLICATION (COMPLETION_DATE); CREATE TABLE IF NOT EXISTS EVENT_FAILED_ATTEMPT_INFO ( - EVENT_ID UUID NOT NULL, - FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, - SERIALIZED_REASON VARCHAR(4000) NOT NULL, - REASON_TYPE VARCHAR(512) NOT NULL, + EVENT_ID VARCHAR2(36) NOT NULL, + FAILED_DATE TIMESTAMP(6) WITH TIME ZONE default SYSTIMESTAMP NOT NULL, + SERIALIZED_REASON VARCHAR2(4000) NOT NULL, + REASON_TYPE VARCHAR2(512) NOT NULL, CONSTRAINT FK_FAILED_EVENT_INFO_EVENT FOREIGN KEY (EVENT_ID) REFERENCES EVENT_PUBLICATION(ID) From b823d72cdb0dd7047b8c1c951025249a7c8ff494 Mon Sep 17 00:00:00 2001 From: "mihaita.tinta" Date: Thu, 4 Dec 2025 16:28:54 +0200 Subject: [PATCH 05/11] fix schemas Signed-off-by: mihaita.tinta --- .../events/jdbc/JdbcEventPublicationRepository.java | 4 ++-- .../src/main/resources/schema-mariadb.sql | 4 ++-- .../src/main/resources/schema-mysql.sql | 4 ++-- .../src/main/resources/schema-postgresql.sql | 6 +++--- .../src/main/resources/schema-sqlserver.sql | 7 ++++--- ...dbcEventPublicationRepositoryIntegrationTests.java | 11 ++++++----- 6 files changed, 19 insertions(+), 17 deletions(-) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java index 116467254..99d940b60 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java @@ -81,7 +81,7 @@ class JdbcEventPublicationRepository implements EventPublicationRepository, Bean FROM %s e LEFT JOIN %s f on f.EVENT_ID = e.ID WHERE COMPLETION_DATE IS NULL - ORDER BY PUBLICATION_DATE ASC + ORDER BY e.PUBLICATION_DATE ASC, f.FAILED_DATE """; private static final String SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE = """ @@ -92,7 +92,7 @@ class JdbcEventPublicationRepository implements EventPublicationRepository, Bean WHERE e.COMPLETION_DATE IS NULL AND e.PUBLICATION_DATE < ? - ORDER BY e.PUBLICATION_DATE ASC + ORDER BY e.PUBLICATION_DATE ASC, f.FAILED_DATE """; private static final String SQL_STATEMENT_UPDATE_BY_EVENT_AND_LISTENER_ID = """ diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mariadb.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mariadb.sql index 15811c005..d099b1612 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mariadb.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mariadb.sql @@ -11,8 +11,8 @@ CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION ); CREATE TABLE IF NOT EXISTS EVENT_FAILED_ATTEMPT_INFO ( - EVENT_ID UUID NOT NULL, - FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, + EVENT_ID VARCHAR(36) NOT NULL, + FAILED_DATE TIMESTAMP(6) NOT NULL, SERIALIZED_REASON VARCHAR(4000) NOT NULL, REASON_TYPE VARCHAR(512) NOT NULL, CONSTRAINT FK_FAILED_EVENT_INFO_EVENT diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mysql.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mysql.sql index 4f8ff8aad..ab12617b5 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mysql.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-mysql.sql @@ -11,8 +11,8 @@ CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION ); CREATE TABLE IF NOT EXISTS EVENT_FAILED_ATTEMPT_INFO ( - EVENT_ID UUID NOT NULL, - FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, + EVENT_ID VARCHAR(36) NOT NULL, + FAILED_DATE TIMESTAMP(6) NOT NULL, SERIALIZED_REASON VARCHAR(4000) NOT NULL, REASON_TYPE VARCHAR(512) NOT NULL, CONSTRAINT FK_FAILED_EVENT_INFO_EVENT diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-postgresql.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-postgresql.sql index 1dab7f7d6..8ee24f560 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-postgresql.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-postgresql.sql @@ -13,9 +13,9 @@ CREATE INDEX IF NOT EXISTS event_publication_by_completion_date_idx ON event_pub CREATE TABLE IF NOT EXISTS EVENT_FAILED_ATTEMPT_INFO ( EVENT_ID UUID NOT NULL, - FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, - SERIALIZED_REASON VARCHAR(4000) NOT NULL, - REASON_TYPE VARCHAR(512) NOT NULL, + FAILED_DATE TIMESTAMP WITH TIME ZONE NOT NULL1, + SERIALIZED_REASON TEXT NOT NULL, + REASON_TYPE TEXT NOT NULL, CONSTRAINT FK_FAILED_EVENT_INFO_EVENT FOREIGN KEY (EVENT_ID) REFERENCES EVENT_PUBLICATION(ID) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-sqlserver.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-sqlserver.sql index 646682d53..a65948f0f 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-sqlserver.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-sqlserver.sql @@ -10,10 +10,11 @@ CREATE TABLE EVENT_PUBLICATION PRIMARY KEY (ID), INDEX EVENT_PUBLICATION_BY_COMPLETION_DATE_IDX (COMPLETION_DATE) ); -CREATE TABLE IF NOT EXISTS EVENT_FAILED_ATTEMPT_INFO +IF NOT EXISTS(SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'EVENT_FAILED_ATTEMPT_INFO') +CREATE TABLE EVENT_FAILED_ATTEMPT_INFO ( - EVENT_ID UUID NOT NULL, - FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, + EVENT_ID VARCHAR(36) NOT NULL, + FAILED_DATE DATETIME2(6) NOT NULL, SERIALIZED_REASON VARCHAR(4000) NOT NULL, REASON_TYPE VARCHAR(512) NOT NULL, CONSTRAINT FK_FAILED_EVENT_INFO_EVENT diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java index f4a9397c9..bb097134f 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java @@ -432,18 +432,19 @@ void findsPublicationsThatFailedOnce() { void findsPublicationsThatFailedTwice() { var first = createPublication(new TestEvent("first")); - Instant now = Instant.now(); + Instant firstTry = Instant.now().minusSeconds(10); + Instant secondTry = Instant.now().minusSeconds(5); IllegalStateException reason1 = new IllegalStateException("failed once"); IllegalStateException reason2 = new IllegalStateException("failed second time"); - var entry1 = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(now, reason1); - var entry2 = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(now, reason2); + var entry1 = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(firstTry, reason1); + var entry2 = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(secondTry, reason2); doReturn(reason1.toString()).when(serializer).serialize(reason1); doReturn(reason2.toString()).when(serializer).serialize(reason2); doReturn(reason1).when(serializer).deserialize(reason1.toString(), reason1.getClass()); doReturn(reason2).when(serializer).deserialize(reason2.toString(), reason2.getClass()); - repository.markFailed(first.getIdentifier(), now, reason1); - repository.markFailed(first.getIdentifier(), now, reason2); + repository.markFailed(first.getIdentifier(), firstTry, reason1); + repository.markFailed(first.getIdentifier(), secondTry, reason2); assertThat(repository.findIncompletePublications()) .extracting(TargetEventPublication::getFailedAttempts) From 74a779a4d60e54e0d62eac22ef307ab3d112f918 Mon Sep 17 00:00:00 2001 From: "mihaita.tinta" Date: Thu, 20 Nov 2025 17:22:04 +0200 Subject: [PATCH 06/11] wip failed attempt info Signed-off-by: mihaita.tinta --- .../jdbc/JdbcEventPublicationRepository.java | 1299 ++++++++--------- .../src/main/resources/schema-h2.sql | 5 +- ...PublicationRepositoryIntegrationTests.java | 74 +- 3 files changed, 675 insertions(+), 703 deletions(-) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java index 99d940b60..447f8e2ed 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java @@ -15,6 +15,20 @@ */ package org.springframework.modulith.events.jdbc; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.BeanClassLoaderAware; @@ -30,20 +44,6 @@ import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Timestamp; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import java.util.function.Supplier; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - /** * JDBC-based repository to store {@link TargetEventPublication}s. * @@ -55,658 +55,621 @@ */ class JdbcEventPublicationRepository implements EventPublicationRepository, BeanClassLoaderAware { - private static final Logger LOGGER = LoggerFactory.getLogger(JdbcEventPublicationRepository.class); - - private static final String SQL_STATEMENT_INSERT = """ - INSERT INTO %s (ID, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT) - VALUES (?, ?, ?, ?, ?) - """; - private static final String SQL_STATEMENT_INSERT_FAILURE = """ - INSERT INTO %s (EVENT_ID, FAILED_DATE, SERIALIZED_REASON, REASON_TYPE) - VALUES (?, ?, ?, ?) - """; - - private static final String SQL_STATEMENT_FIND_COMPLETED = """ - SELECT e.ID, e.COMPLETION_DATE, e.EVENT_TYPE, e.LISTENER_ID, e.PUBLICATION_DATE, e.SERIALIZED_EVENT, - f.SERIALIZED_REASON, f.REASON_TYPE, f.FAILED_DATE - FROM %s e - LEFT JOIN %s f on f.EVENT_ID = e.ID - WHERE e.COMPLETION_DATE IS NOT NULL - ORDER BY e.PUBLICATION_DATE ASC - """; - - private static final String SQL_STATEMENT_FIND_UNCOMPLETED = """ - SELECT e.ID, e.COMPLETION_DATE, e.EVENT_TYPE, e.LISTENER_ID, e.PUBLICATION_DATE, e.SERIALIZED_EVENT, - f.SERIALIZED_REASON, f.REASON_TYPE, f.FAILED_DATE - FROM %s e - LEFT JOIN %s f on f.EVENT_ID = e.ID - WHERE COMPLETION_DATE IS NULL - ORDER BY e.PUBLICATION_DATE ASC, f.FAILED_DATE - """; - - private static final String SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE = """ - SELECT e.ID, e.COMPLETION_DATE, e.EVENT_TYPE, e.LISTENER_ID, e.PUBLICATION_DATE, e.SERIALIZED_EVENT, - f.SERIALIZED_REASON, f.REASON_TYPE, f.FAILED_DATE - FROM %s e - LEFT JOIN %s f on f.EVENT_ID = e.ID - WHERE - e.COMPLETION_DATE IS NULL - AND e.PUBLICATION_DATE < ? - ORDER BY e.PUBLICATION_DATE ASC, f.FAILED_DATE - """; - - private static final String SQL_STATEMENT_UPDATE_BY_EVENT_AND_LISTENER_ID = """ - UPDATE %s - SET COMPLETION_DATE = ? - WHERE - LISTENER_ID = ? - AND COMPLETION_DATE IS NULL - AND SERIALIZED_EVENT = ? - """; - - private static final String SQL_STATEMENT_UPDATE_BY_ID = """ - UPDATE %s - SET COMPLETION_DATE = ? - WHERE - ID = ? - """; - private static final String SQL_STATEMENT_UPDATE_FAILED_BY_ID = """ - UPDATE %s - SET FAILED_EVENT_INFO = ? - WHERE - ID = ? - """; - - private static final String SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID = """ - SELECT e.*, f.SERIALIZED_REASON, f.REASON_TYPE, f.FAILED_DATE - FROM %s e - LEFT JOIN %s f on f.EVENT_ID = e.ID - WHERE - e.SERIALIZED_EVENT = ? - AND e.LISTENER_ID = ? - AND e.COMPLETION_DATE IS NULL - ORDER BY e.PUBLICATION_DATE - """; - - private static final String SQL_STATEMENT_DELETE = """ - DELETE - FROM %s - WHERE - ID IN - """; - - private static final String SQL_STATEMENT_DELETE_BY_EVENT_AND_LISTENER_ID = """ - DELETE FROM %s - WHERE - LISTENER_ID = ? - AND SERIALIZED_EVENT = ? - """; - - private static final String SQL_STATEMENT_DELETE_BY_ID = """ - DELETE - FROM %s - WHERE - ID = ? - """; - - private static final String SQL_STATEMENT_DELETE_COMPLETED = """ - DELETE - FROM %s - WHERE - COMPLETION_DATE IS NOT NULL - """; - - private static final String SQL_STATEMENT_DELETE_COMPLETED_BEFORE = """ - DELETE - FROM %s - WHERE - COMPLETION_DATE < ? - """; - - private static final String SQL_STATEMENT_COPY_TO_ARCHIVE_BY_ID = """ - -- Only copy if no entry in target table - INSERT INTO %s (ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, COMPLETION_DATE) - SELECT ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, ? - FROM %s - WHERE ID = ? - AND NOT EXISTS (SELECT 1 FROM %s WHERE ID = EVENT_PUBLICATION.ID) - """; - - private static final String SQL_STATEMENT_COPY_TO_ARCHIVE_BY_EVENT_AND_LISTENER_ID = """ - -- Only copy if no entry in target table - INSERT INTO %s (ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, COMPLETION_DATE) - SELECT ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, ? - FROM %s - WHERE LISTENER_ID = ? - AND SERIALIZED_EVENT = ? - AND NOT EXISTS (SELECT 1 FROM %s WHERE ID = EVENT_PUBLICATION.ID) - """; - - private static final int DELETE_BATCH_SIZE = 100; - - private final JdbcOperations operations; - private final EventSerializer serializer; - private final JdbcRepositorySettings settings; - - private ClassLoader classLoader; - - private final String sqlStatementInsert, - sqlStatementInsertFailed, - sqlStatementFindCompleted, - sqlStatementFindUncompleted, - sqlStatementFindUncompletedBefore, - sqlStatementUpdateByEventAndListenerId, - sqlStatementUpdateById, - sqlStatementFindByEventAndListenerId, - sqlStatementDelete, - sqlStatementDeleteByEventAndListenerId, - sqlStatementDeleteById, - sqlStatementDeleteCompleted, - sqlStatementDeleteCompletedBefore, - sqlStatementCopyToArchive, - sqlStatementCopyToArchiveByEventAndListenerId; - - /** - * Creates a new {@link JdbcEventPublicationRepository} for the given {@link JdbcOperations}, {@link EventSerializer}, - * {@link DatabaseType} and {@link JdbcConfigurationProperties}. - * - * @param operations must not be {@literal null}. - * @param serializer must not be {@literal null}. - * @param settings must not be {@literal null}. - */ - public JdbcEventPublicationRepository(JdbcOperations operations, EventSerializer serializer, - JdbcRepositorySettings settings) { - - Assert.notNull(operations, "JdbcOperations must not be null!"); - Assert.notNull(serializer, "EventSerializer must not be null!"); - Assert.notNull(settings, "DatabaseType must not be null!"); - - this.operations = operations; - this.serializer = serializer; - this.settings = settings; - - var schema = settings.getSchema(); - var table = ObjectUtils.isEmpty(schema) ? "EVENT_PUBLICATION" : schema + ".EVENT_PUBLICATION"; - var failedAttemptInfoTable = ObjectUtils.isEmpty(schema) ? "EVENT_FAILED_ATTEMPT_INFO" : schema + ".EVENT_FAILED_ATTEMPT_INFO"; - var completedTable = settings.isArchiveCompletion() ? table + "_ARCHIVE" : table; - - this.sqlStatementInsert = SQL_STATEMENT_INSERT.formatted(table); - this.sqlStatementInsertFailed = SQL_STATEMENT_INSERT_FAILURE.formatted(failedAttemptInfoTable); - this.sqlStatementFindCompleted = SQL_STATEMENT_FIND_COMPLETED.formatted(completedTable, failedAttemptInfoTable); - this.sqlStatementFindUncompleted = SQL_STATEMENT_FIND_UNCOMPLETED.formatted(table, failedAttemptInfoTable); - this.sqlStatementFindUncompletedBefore = SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE.formatted(table, failedAttemptInfoTable); - this.sqlStatementUpdateByEventAndListenerId = SQL_STATEMENT_UPDATE_BY_EVENT_AND_LISTENER_ID.formatted(table); - this.sqlStatementUpdateById = SQL_STATEMENT_UPDATE_BY_ID.formatted(table); - this.sqlStatementFindByEventAndListenerId = SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID.formatted(table, failedAttemptInfoTable); - this.sqlStatementDelete = SQL_STATEMENT_DELETE.formatted(table); - this.sqlStatementDeleteByEventAndListenerId = SQL_STATEMENT_DELETE_BY_EVENT_AND_LISTENER_ID.formatted(table); - this.sqlStatementDeleteById = SQL_STATEMENT_DELETE_BY_ID.formatted(table); - this.sqlStatementDeleteCompleted = SQL_STATEMENT_DELETE_COMPLETED.formatted(completedTable); - this.sqlStatementDeleteCompletedBefore = SQL_STATEMENT_DELETE_COMPLETED_BEFORE.formatted(completedTable); - this.sqlStatementCopyToArchive = SQL_STATEMENT_COPY_TO_ARCHIVE_BY_ID.formatted(completedTable, table, completedTable); - this.sqlStatementCopyToArchiveByEventAndListenerId = SQL_STATEMENT_COPY_TO_ARCHIVE_BY_EVENT_AND_LISTENER_ID.formatted(completedTable, table, completedTable); - } - - /* - * (non-Javadoc) - * @see org.springframework.beans.factory.BeanClassLoaderAware#setBeanClassLoader(java.lang.ClassLoader) - */ - @Override - public void setBeanClassLoader(ClassLoader classLoader) { - this.classLoader = classLoader; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublicationRepository#create(org.springframework.modulith.events.EventPublication) - */ - @Override - @Transactional - public TargetEventPublication create(TargetEventPublication publication) { - - var serializedEvent = serializeEvent(publication.getEvent()); - - operations.update( // - sqlStatementInsert, // - uuidToDatabase(publication.getIdentifier()), // - publication.getEvent().getClass().getName(), // - publication.getTargetIdentifier().getValue(), // - Timestamp.from(publication.getPublicationDate()), // - serializedEvent); - - return publication; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublicationRepository#markCompleted(java.lang.Object, org.springframework.modulith.events.PublicationTargetIdentifier, java.time.Instant) - */ - @Override - @Transactional - public void markCompleted(Object event, PublicationTargetIdentifier identifier, Instant completionDate) { - - var targetIdentifier = identifier.getValue(); - var serializedEvent = serializer.serialize(event); - - if (settings.isDeleteCompletion()) { - - operations.update(sqlStatementDeleteByEventAndListenerId, targetIdentifier, serializedEvent); - - } else if (settings.isArchiveCompletion()) { - - operations.update(sqlStatementCopyToArchiveByEventAndListenerId, // - Timestamp.from(completionDate), // - targetIdentifier, // - serializedEvent); - operations.update(sqlStatementDeleteByEventAndListenerId, targetIdentifier, serializedEvent); - - } else { - - operations.update(sqlStatementUpdateByEventAndListenerId, // - Timestamp.from(completionDate), // - targetIdentifier, // - serializedEvent); - } - } - - @Override - public void markFailed(UUID identifier, Instant failedDate, Throwable exception) { - - var databaseId = uuidToDatabase(identifier); - var reason = serializer.serialize(exception); - this.operations.update(sqlStatementInsertFailed, databaseId, Timestamp.from(failedDate), - reason, exception.getClass().getName()); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#markCompleted(java.util.UUID, java.time.Instant) - */ - @Override - @Transactional - public void markCompleted(UUID identifier, Instant completionDate) { - - var databaseId = uuidToDatabase(identifier); - var timestamp = Timestamp.from(completionDate); - - if (settings.isDeleteCompletion()) { - operations.update(sqlStatementDeleteById, databaseId); - - } else if (settings.isArchiveCompletion()) { - operations.update(sqlStatementCopyToArchive, timestamp, databaseId); - operations.update(sqlStatementDeleteById, databaseId); - - } else { - operations.update(sqlStatementUpdateById, timestamp, databaseId); - } - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#findIncompletePublicationsByEventAndTargetIdentifier(java.lang.Object, org.springframework.modulith.events.core.PublicationTargetIdentifier) - */ - @Override - @Transactional(readOnly = true) - public Optional findIncompletePublicationsByEventAndTargetIdentifier( // - Object event, PublicationTargetIdentifier targetIdentifier) { - - var result = operations.query(sqlStatementFindByEventAndListenerId, // - this::resultSetToPublications, // - serializeEvent(event), // - targetIdentifier.getValue()); - - return result == null ? Optional.empty() : result.stream().findFirst(); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#findCompletedPublications() - */ - @Override - public List findCompletedPublications() { - - var result = operations.query(sqlStatementFindCompleted, this::resultSetToPublications); - - return result == null ? Collections.emptyList() : result; - } - - @Override - @Transactional(readOnly = true) - @SuppressWarnings("null") - public List findIncompletePublications() { - return operations.query(sqlStatementFindUncompleted, this::resultSetToPublications); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#findIncompletePublicationsPublishedBefore(java.time.Instant) - */ - @Override - public List findIncompletePublicationsPublishedBefore(Instant instant) { - - var result = operations.query(sqlStatementFindUncompletedBefore, - this::resultSetToPublications, Timestamp.from(instant)); - - return result == null ? Collections.emptyList() : result; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#deletePublications(java.util.List) - */ - @Override - public void deletePublications(List identifiers) { - - var dbIdentifiers = identifiers.stream().map(this::uuidToDatabase).toList(); - - batch(dbIdentifiers, DELETE_BATCH_SIZE) - .forEach(it -> operations.update(sqlStatementDelete.concat(toParameterPlaceholders(it.length)), it)); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#deleteCompletedPublications() - */ - @Override - public void deleteCompletedPublications() { - operations.execute(sqlStatementDeleteCompleted); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublicationRepository#deleteCompletedPublicationsBefore(java.time.Instant) - */ - @Override - public void deleteCompletedPublicationsBefore(Instant instant) { - - Assert.notNull(instant, "Instant must not be null!"); - - operations.update(sqlStatementDeleteCompletedBefore, Timestamp.from(instant)); - } - - private String serializeEvent(Object event) { - return serializer.serialize(event).toString(); - } - - /** - * Effectively a {@link ResultSetExtractor} to drop {@link TargetEventPublication}s that cannot be deserialized. - * - * @param resultSet must not be {@literal null}. - * @return will never be {@literal null}. - * @throws SQLException - */ - private List resultSetToPublications(ResultSet resultSet) throws SQLException { - - List result = new ArrayList<>(); - - TargetEventPublication lastPublication = null; - while (resultSet.next()) { - - var publication = resultSetToPublication(resultSet); - - if (publication != null) { - - if (lastPublication == null || !lastPublication.getIdentifier().equals(publication.id)) { - lastPublication = new JdbcEventPublication(publication.id(), - publication.publicationDate, - publication.listenerId, - publication.event, - publication.completionDate, - new ArrayList<>() - ); - result.add(lastPublication); - } - - if (publication.failedAttempt.isPresent()) { - lastPublication.getFailedAttempts().add(publication.failedAttempt.get()); - } - } - } - return result; - } - - /** - * Effectively a {@link RowMapper} to turn a single row into an {@link ResultSetJdbcEventPublication}. - * - * @param rs must not be {@literal null}. - * @return can be {@literal null}. - * @throws SQLException - */ - @Nullable - private ResultSetJdbcEventPublication resultSetToPublication(ResultSet rs) throws SQLException { - - var id = getUuidFromResultSet(rs); - var eventClass = loadClass(id, rs.getString("EVENT_TYPE")); - - if (eventClass == null) { - return null; - } - - var completionDate = rs.getTimestamp("COMPLETION_DATE"); - var publicationDate = rs.getTimestamp("PUBLICATION_DATE").toInstant(); - var listenerId = rs.getString("LISTENER_ID"); - var serializedEvent = rs.getString("SERIALIZED_EVENT"); - - var failedAttemptReasonType = rs.getString("REASON_TYPE"); - Optional attempt = Optional.empty(); - if (failedAttemptReasonType != null) { - var attemptClass = loadClass(id, failedAttemptReasonType); - if (attemptClass != null) { - var failedAttemptReason = rs.getString("SERIALIZED_REASON"); - var failedAttemptDate = rs.getTimestamp("FAILED_DATE").toInstant(); - attempt = Optional.of(new JdbcFailedAttemptInfo(failedAttemptDate, - serializer.deserialize(failedAttemptReason, (Class) attemptClass) - )); - } - } - return new ResultSetJdbcEventPublication(id, publicationDate, listenerId, - () -> serializer.deserialize(serializedEvent, eventClass), - completionDate == null ? null : completionDate.toInstant(), - attempt);// TODO add value from resultset - } - - private Object uuidToDatabase(UUID id) { - return settings.getDatabaseType().uuidToDatabase(id); - } - - private UUID getUuidFromResultSet(ResultSet rs) throws SQLException { - return settings.getDatabaseType().databaseToUUID(rs.getObject("ID")); - } - - @Nullable - private Class loadClass(UUID id, String className) { - - try { - return ClassUtils.forName(className, classLoader); - } catch (ClassNotFoundException e) { - LOGGER.warn("Event '{}' of unknown type '{}' found", id, className); - return null; - } - } - - private static List batch(List input, int batchSize) { - - var inputSize = input.size(); - - return IntStream.range(0, (inputSize + batchSize - 1) / batchSize) - .mapToObj(i -> input.subList(i * batchSize, Math.min((i + 1) * batchSize, inputSize))) - .map(List::toArray) - .toList(); - } - - private static String toParameterPlaceholders(int length) { - - return IntStream.range(0, length) - .mapToObj(__ -> "?") - .collect(Collectors.joining(", ", "(", ")")); - } - - private static class JdbcEventPublication implements TargetEventPublication { - - private final UUID id; - private final Instant publicationDate; - private final String listenerId; - private final Supplier eventSupplier; - - private @Nullable Instant completionDate; - private @Nullable Object event; - private List failedAttempts; - - /** - * @param id must not be {@literal null}. - * @param publicationDate must not be {@literal null}. - * @param listenerId must not be {@literal null} or empty. - * @param event must not be {@literal null}.. - * @param completionDate can be {@literal null}. - * @param failedAttempts can be {@literal null}. - */ - public JdbcEventPublication(UUID id, Instant publicationDate, String listenerId, Supplier event, - @Nullable Instant completionDate, List failedAttempts) { - - Assert.notNull(id, "Id must not be null!"); - Assert.notNull(publicationDate, "Publication date must not be null!"); - Assert.hasText(listenerId, "Listener id must not be null or empty!"); - Assert.notNull(event, "Event must not be null!"); - Assert.notNull(failedAttempts, "Failed attempts must not be null!"); - - this.id = id; - this.publicationDate = publicationDate; - this.listenerId = listenerId; - this.eventSupplier = event; - this.completionDate = completionDate; - this.failedAttempts = failedAttempts; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublication#getPublicationIdentifier() - */ - @Override - public UUID getIdentifier() { - return id; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublication#getEvent() - */ - @Override - @SuppressWarnings("null") - public Object getEvent() { - - if (event == null) { - this.event = eventSupplier.get(); - } - - return event; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublication#getTargetIdentifier() - */ - @Override - public PublicationTargetIdentifier getTargetIdentifier() { - return PublicationTargetIdentifier.of(listenerId); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublication#getPublicationDate() - */ - @Override - public Instant getPublicationDate() { - return publicationDate; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.CompletableEventPublication#getCompletionDate() - */ - @Override - public Optional getCompletionDate() { - return Optional.ofNullable(completionDate); - } - - @Override - public List getFailedAttempts() { - return failedAttempts; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.CompletableEventPublication#isPublicationCompleted() - */ - @Override - public boolean isPublicationCompleted() { - return completionDate != null; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.Completable#markCompleted(java.time.Instant) - */ - @Override - public void markCompleted(Instant instant) { - this.completionDate = instant; - } - - @Override - public void markFailed(Instant instant, Throwable exception) { - - if (failedAttempts == null) { - failedAttempts = new ArrayList<>(); - } - failedAttempts.add(new JdbcFailedAttemptInfo(instant, exception)); - } - - /* - * (non-Javadoc) - * @see java.lang.Object#equals(java.lang.Object) - */ - @Override - public boolean equals(@Nullable Object obj) { - - if (this == obj) { - return true; - } - - if (!(obj instanceof JdbcEventPublication that)) { - return false; - } - - return Objects.equals(completionDate, that.completionDate) // - && Objects.equals(id, that.id) // - && Objects.equals(listenerId, that.listenerId) // - && Objects.equals(publicationDate, that.publicationDate) // - && Objects.equals(getEvent(), that.getEvent()); - } - - /* - * (non-Javadoc) - * @see java.lang.Object#hashCode() - */ - @Override - public int hashCode() { - return Objects.hash(completionDate, id, listenerId, publicationDate, getEvent()); - } - } - - record ResultSetJdbcEventPublication(UUID id, Instant publicationDate, String listenerId, Supplier event, - @Nullable Instant completionDate, Optional failedAttempt) { - - } - - record JdbcFailedAttemptInfo(Instant publicationDate, Throwable failureReason) implements FailedAttemptInfo { - - @Override - public Instant getPublicationDate() { - return publicationDate; - } - - @Override - public Throwable getFailureReason() { - return failureReason; - } - } + private static final Logger LOGGER = LoggerFactory.getLogger(JdbcEventPublicationRepository.class); + + private static final String SQL_STATEMENT_INSERT = """ + INSERT INTO %s (ID, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT) + VALUES (?, ?, ?, ?, ?) + """; + private static final String SQL_STATEMENT_INSERT_FAILURE = """ + INSERT INTO %s (EVENT_ID, FAILED_DATE, REASON) + VALUES (?, ?, ?) + """; + + private static final String SQL_STATEMENT_FIND_COMPLETED = """ + SELECT ID, COMPLETION_DATE, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT, FAILED_EVENT_INFO + FROM %s + WHERE COMPLETION_DATE IS NOT NULL + ORDER BY PUBLICATION_DATE ASC + """; + + private static final String SQL_STATEMENT_FIND_UNCOMPLETED = """ + SELECT e.ID, e.COMPLETION_DATE, e.EVENT_TYPE, e.LISTENER_ID, e.PUBLICATION_DATE, e.SERIALIZED_EVENT, f.REASON + FROM %s e + INNER JOIN %s f on f.EVENT_ID = e.ID + WHERE COMPLETION_DATE IS NULL + ORDER BY PUBLICATION_DATE ASC + """; + + private static final String SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE = """ + SELECT ID, COMPLETION_DATE, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT, FAILED_EVENT_INFO + FROM %s + WHERE + COMPLETION_DATE IS NULL + AND PUBLICATION_DATE < ? + ORDER BY PUBLICATION_DATE ASC + """; + + private static final String SQL_STATEMENT_UPDATE_BY_EVENT_AND_LISTENER_ID = """ + UPDATE %s + SET COMPLETION_DATE = ? + WHERE + LISTENER_ID = ? + AND COMPLETION_DATE IS NULL + AND SERIALIZED_EVENT = ? + """; + + private static final String SQL_STATEMENT_UPDATE_BY_ID = """ + UPDATE %s + SET COMPLETION_DATE = ? + WHERE + ID = ? + """; + private static final String SQL_STATEMENT_UPDATE_FAILED_BY_ID = """ + UPDATE %s + SET FAILED_EVENT_INFO = ? + WHERE + ID = ? + """; + + private static final String SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID = """ + SELECT * + FROM %s + WHERE + SERIALIZED_EVENT = ? + AND LISTENER_ID = ? + AND COMPLETION_DATE IS NULL + ORDER BY PUBLICATION_DATE + """; + + private static final String SQL_STATEMENT_DELETE = """ + DELETE + FROM %s + WHERE + ID IN + """; + + private static final String SQL_STATEMENT_DELETE_BY_EVENT_AND_LISTENER_ID = """ + DELETE FROM %s + WHERE + LISTENER_ID = ? + AND SERIALIZED_EVENT = ? + """; + + private static final String SQL_STATEMENT_DELETE_BY_ID = """ + DELETE + FROM %s + WHERE + ID = ? + """; + + private static final String SQL_STATEMENT_DELETE_COMPLETED = """ + DELETE + FROM %s + WHERE + COMPLETION_DATE IS NOT NULL + """; + + private static final String SQL_STATEMENT_DELETE_COMPLETED_BEFORE = """ + DELETE + FROM %s + WHERE + COMPLETION_DATE < ? + """; + + private static final String SQL_STATEMENT_COPY_TO_ARCHIVE_BY_ID = """ + -- Only copy if no entry in target table + INSERT INTO %s (ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, COMPLETION_DATE) + SELECT ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, ? + FROM %s + WHERE ID = ? + AND NOT EXISTS (SELECT 1 FROM %s WHERE ID = EVENT_PUBLICATION.ID) + """; + + private static final String SQL_STATEMENT_COPY_TO_ARCHIVE_BY_EVENT_AND_LISTENER_ID = """ + -- Only copy if no entry in target table + INSERT INTO %s (ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, COMPLETION_DATE) + SELECT ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, ? + FROM %s + WHERE LISTENER_ID = ? + AND SERIALIZED_EVENT = ? + AND NOT EXISTS (SELECT 1 FROM %s WHERE ID = EVENT_PUBLICATION.ID) + """; + + private static final int DELETE_BATCH_SIZE = 100; + + private final JdbcOperations operations; + private final EventSerializer serializer; + private final JdbcRepositorySettings settings; + + private ClassLoader classLoader; + + private final String sqlStatementInsert, + sqlStatementInsertFailed, + sqlStatementFindCompleted, + sqlStatementFindUncompleted, + sqlStatementFindUncompletedBefore, + sqlStatementUpdateByEventAndListenerId, + sqlStatementUpdateById, + sqlStatementFindByEventAndListenerId, + sqlStatementDelete, + sqlStatementDeleteByEventAndListenerId, + sqlStatementDeleteById, + sqlStatementDeleteCompleted, + sqlStatementDeleteCompletedBefore, + sqlStatementCopyToArchive, + sqlStatementCopyToArchiveByEventAndListenerId; + + /** + * Creates a new {@link JdbcEventPublicationRepository} for the given {@link JdbcOperations}, {@link EventSerializer}, + * {@link DatabaseType} and {@link JdbcConfigurationProperties}. + * + * @param operations must not be {@literal null}. + * @param serializer must not be {@literal null}. + * @param settings must not be {@literal null}. + */ + public JdbcEventPublicationRepository(JdbcOperations operations, EventSerializer serializer, + JdbcRepositorySettings settings) { + + Assert.notNull(operations, "JdbcOperations must not be null!"); + Assert.notNull(serializer, "EventSerializer must not be null!"); + Assert.notNull(settings, "DatabaseType must not be null!"); + + this.operations = operations; + this.serializer = serializer; + this.settings = settings; + + var schema = settings.getSchema(); + var table = ObjectUtils.isEmpty(schema) ? "EVENT_PUBLICATION" : schema + ".EVENT_PUBLICATION"; + var failedEventInfoTable = ObjectUtils.isEmpty(schema) ? "EVENT_FAILED_EVENT_INFO" : schema + ".EVENT_FAILED_EVENT_INFO"; + var completedTable = settings.isArchiveCompletion() ? table + "_ARCHIVE" : table; + + this.sqlStatementInsert = SQL_STATEMENT_INSERT.formatted(table); + this.sqlStatementInsertFailed = SQL_STATEMENT_INSERT_FAILURE.formatted(failedEventInfoTable); + this.sqlStatementFindCompleted = SQL_STATEMENT_FIND_COMPLETED.formatted(completedTable); + this.sqlStatementFindUncompleted = SQL_STATEMENT_FIND_UNCOMPLETED.formatted(table, failedEventInfoTable); + this.sqlStatementFindUncompletedBefore = SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE.formatted(table); + this.sqlStatementUpdateByEventAndListenerId = SQL_STATEMENT_UPDATE_BY_EVENT_AND_LISTENER_ID.formatted(table); + this.sqlStatementUpdateById = SQL_STATEMENT_UPDATE_BY_ID.formatted(table); + this.sqlStatementFindByEventAndListenerId = SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID.formatted(table); + this.sqlStatementDelete = SQL_STATEMENT_DELETE.formatted(table); + this.sqlStatementDeleteByEventAndListenerId = SQL_STATEMENT_DELETE_BY_EVENT_AND_LISTENER_ID.formatted(table); + this.sqlStatementDeleteById = SQL_STATEMENT_DELETE_BY_ID.formatted(table); + this.sqlStatementDeleteCompleted = SQL_STATEMENT_DELETE_COMPLETED.formatted(completedTable); + this.sqlStatementDeleteCompletedBefore = SQL_STATEMENT_DELETE_COMPLETED_BEFORE.formatted(completedTable); + this.sqlStatementCopyToArchive = SQL_STATEMENT_COPY_TO_ARCHIVE_BY_ID.formatted(completedTable, table, completedTable); + this.sqlStatementCopyToArchiveByEventAndListenerId = SQL_STATEMENT_COPY_TO_ARCHIVE_BY_EVENT_AND_LISTENER_ID.formatted(completedTable, table, completedTable); + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.BeanClassLoaderAware#setBeanClassLoader(java.lang.ClassLoader) + */ + @Override + public void setBeanClassLoader(ClassLoader classLoader) { + this.classLoader = classLoader; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublicationRepository#create(org.springframework.modulith.events.EventPublication) + */ + @Override + @Transactional + public TargetEventPublication create(TargetEventPublication publication) { + + var serializedEvent = serializeEvent(publication.getEvent()); + + operations.update( // + sqlStatementInsert, // + uuidToDatabase(publication.getIdentifier()), // + publication.getEvent().getClass().getName(), // + publication.getTargetIdentifier().getValue(), // + Timestamp.from(publication.getPublicationDate()), // + serializedEvent); + + return publication; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublicationRepository#markCompleted(java.lang.Object, org.springframework.modulith.events.PublicationTargetIdentifier, java.time.Instant) + */ + @Override + @Transactional + public void markCompleted(Object event, PublicationTargetIdentifier identifier, Instant completionDate) { + + var targetIdentifier = identifier.getValue(); + var serializedEvent = serializer.serialize(event); + + if (settings.isDeleteCompletion()) { + + operations.update(sqlStatementDeleteByEventAndListenerId, targetIdentifier, serializedEvent); + + } else if (settings.isArchiveCompletion()) { + + operations.update(sqlStatementCopyToArchiveByEventAndListenerId, // + Timestamp.from(completionDate), // + targetIdentifier, // + serializedEvent); + operations.update(sqlStatementDeleteByEventAndListenerId, targetIdentifier, serializedEvent); + + } else { + + operations.update(sqlStatementUpdateByEventAndListenerId, // + Timestamp.from(completionDate), // + targetIdentifier, // + serializedEvent); + } + } + + @Override + public void markFailed(UUID identifier, Instant failedDate, Throwable exception) { + + var databaseId = uuidToDatabase(identifier); + var reason = serializer.serialize(new JdbcFailedAttemptInfo(failedDate, exception)); + this.operations.update(sqlStatementInsertFailed, databaseId, failedDate, reason); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#markCompleted(java.util.UUID, java.time.Instant) + */ + @Override + @Transactional + public void markCompleted(UUID identifier, Instant completionDate) { + + var databaseId = uuidToDatabase(identifier); + var timestamp = Timestamp.from(completionDate); + + if (settings.isDeleteCompletion()) { + operations.update(sqlStatementDeleteById, databaseId); + + } else if (settings.isArchiveCompletion()) { + operations.update(sqlStatementCopyToArchive, timestamp, databaseId); + operations.update(sqlStatementDeleteById, databaseId); + + } else { + operations.update(sqlStatementUpdateById, timestamp, databaseId); + } + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#findIncompletePublicationsByEventAndTargetIdentifier(java.lang.Object, org.springframework.modulith.events.core.PublicationTargetIdentifier) + */ + @Override + @Transactional(readOnly = true) + public Optional findIncompletePublicationsByEventAndTargetIdentifier( // + Object event, PublicationTargetIdentifier targetIdentifier) { + + var result = operations.query(sqlStatementFindByEventAndListenerId, // + this::resultSetToPublications, // + serializeEvent(event), // + targetIdentifier.getValue()); + + return result == null ? Optional.empty() : result.stream().findFirst(); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#findCompletedPublications() + */ + @Override + public List findCompletedPublications() { + + var result = operations.query(sqlStatementFindCompleted, this::resultSetToPublications); + + return result == null ? Collections.emptyList() : result; + } + + @Override + @Transactional(readOnly = true) + @SuppressWarnings("null") + public List findIncompletePublications() { + return operations.query(sqlStatementFindUncompleted, this::resultSetToPublications); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#findIncompletePublicationsPublishedBefore(java.time.Instant) + */ + @Override + public List findIncompletePublicationsPublishedBefore(Instant instant) { + + var result = operations.query(sqlStatementFindUncompletedBefore, + this::resultSetToPublications, Timestamp.from(instant)); + + return result == null ? Collections.emptyList() : result; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#deletePublications(java.util.List) + */ + @Override + public void deletePublications(List identifiers) { + + var dbIdentifiers = identifiers.stream().map(this::uuidToDatabase).toList(); + + batch(dbIdentifiers, DELETE_BATCH_SIZE) + .forEach(it -> operations.update(sqlStatementDelete.concat(toParameterPlaceholders(it.length)), it)); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#deleteCompletedPublications() + */ + @Override + public void deleteCompletedPublications() { + operations.execute(sqlStatementDeleteCompleted); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublicationRepository#deleteCompletedPublicationsBefore(java.time.Instant) + */ + @Override + public void deleteCompletedPublicationsBefore(Instant instant) { + + Assert.notNull(instant, "Instant must not be null!"); + + operations.update(sqlStatementDeleteCompletedBefore, Timestamp.from(instant)); + } + + private String serializeEvent(Object event) { + return serializer.serialize(event).toString(); + } + + /** + * Effectively a {@link ResultSetExtractor} to drop {@link TargetEventPublication}s that cannot be deserialized. + * + * @param resultSet must not be {@literal null}. + * @return will never be {@literal null}. + * @throws SQLException + */ + private List resultSetToPublications(ResultSet resultSet) throws SQLException { + + List result = new ArrayList<>(); + + while (resultSet.next()) { + + var publication = resultSetToPublication(resultSet); + + if (publication != null) { + result.add(publication); + } + } + + return result; + } + + /** + * Effectively a {@link RowMapper} to turn a single row into an {@link TargetEventPublication}. + * + * @param rs must not be {@literal null}. + * @return can be {@literal null}. + * @throws SQLException + */ + @Nullable + private TargetEventPublication resultSetToPublication(ResultSet rs) throws SQLException { + + var id = getUuidFromResultSet(rs); + var eventClass = loadClass(id, rs.getString("EVENT_TYPE")); + + if (eventClass == null) { + return null; + } + + var completionDate = rs.getTimestamp("COMPLETION_DATE"); + var publicationDate = rs.getTimestamp("PUBLICATION_DATE").toInstant(); + var listenerId = rs.getString("LISTENER_ID"); + var serializedEvent = rs.getString("SERIALIZED_EVENT"); + var failedEventInfo = rs.getString("REASON"); + + return new JdbcEventPublication(id, publicationDate, listenerId, + () -> serializer.deserialize(serializedEvent, eventClass), + completionDate == null ? null : completionDate.toInstant(), + List.of(serializer.deserialize(failedEventInfo, JdbcFailedAttemptInfo.class)));// TODO add value from resultset + } + + private Object uuidToDatabase(UUID id) { + return settings.getDatabaseType().uuidToDatabase(id); + } + + private UUID getUuidFromResultSet(ResultSet rs) throws SQLException { + return settings.getDatabaseType().databaseToUUID(rs.getObject("ID")); + } + + @Nullable + private Class loadClass(UUID id, String className) { + + try { + return ClassUtils.forName(className, classLoader); + } catch (ClassNotFoundException e) { + LOGGER.warn("Event '{}' of unknown type '{}' found", id, className); + return null; + } + } + + private static List batch(List input, int batchSize) { + + var inputSize = input.size(); + + return IntStream.range(0, (inputSize + batchSize - 1) / batchSize) + .mapToObj(i -> input.subList(i * batchSize, Math.min((i + 1) * batchSize, inputSize))) + .map(List::toArray) + .toList(); + } + + private static String toParameterPlaceholders(int length) { + + return IntStream.range(0, length) + .mapToObj(__ -> "?") + .collect(Collectors.joining(", ", "(", ")")); + } + + private static class JdbcEventPublication implements TargetEventPublication { + + private final UUID id; + private final Instant publicationDate; + private final String listenerId; + private final Supplier eventSupplier; + + private @Nullable Instant completionDate; + private @Nullable Object event; + private List failedAttempts; + + /** + * @param id must not be {@literal null}. + * @param publicationDate must not be {@literal null}. + * @param listenerId must not be {@literal null} or empty. + * @param event must not be {@literal null}.. + * @param completionDate can be {@literal null}. + * @param failedAttempts can be {@literal null}. + */ + public JdbcEventPublication(UUID id, Instant publicationDate, String listenerId, Supplier event, + @Nullable Instant completionDate, List failedAttempts) { + + Assert.notNull(id, "Id must not be null!"); + Assert.notNull(publicationDate, "Publication date must not be null!"); + Assert.hasText(listenerId, "Listener id must not be null or empty!"); + Assert.notNull(event, "Event must not be null!"); + Assert.notNull(failedAttempts, "Failed attempts must not be null!"); + + this.id = id; + this.publicationDate = publicationDate; + this.listenerId = listenerId; + this.eventSupplier = event; + this.completionDate = completionDate; + this.failedAttempts = failedAttempts; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublication#getPublicationIdentifier() + */ + @Override + public UUID getIdentifier() { + return id; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublication#getEvent() + */ + @Override + @SuppressWarnings("null") + public Object getEvent() { + + if (event == null) { + this.event = eventSupplier.get(); + } + + return event; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublication#getTargetIdentifier() + */ + @Override + public PublicationTargetIdentifier getTargetIdentifier() { + return PublicationTargetIdentifier.of(listenerId); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublication#getPublicationDate() + */ + @Override + public Instant getPublicationDate() { + return publicationDate; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.CompletableEventPublication#getCompletionDate() + */ + @Override + public Optional getCompletionDate() { + return Optional.ofNullable(completionDate); + } + + @Override + public List getFailedAttempts() { + return failedAttempts; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.CompletableEventPublication#isPublicationCompleted() + */ + @Override + public boolean isPublicationCompleted() { + return completionDate != null; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.Completable#markCompleted(java.time.Instant) + */ + @Override + public void markCompleted(Instant instant) { + this.completionDate = instant; + } + + @Override + public void markFailed(Instant instant, Throwable exception) { + + if (failedAttempts == null) { + failedAttempts = new ArrayList<>(); + } + failedAttempts.add(new JdbcFailedAttemptInfo(instant, exception)); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ + @Override + public boolean equals(@Nullable Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof JdbcEventPublication that)) { + return false; + } + + return Objects.equals(completionDate, that.completionDate) // + && Objects.equals(id, that.id) // + && Objects.equals(listenerId, that.listenerId) // + && Objects.equals(publicationDate, that.publicationDate) // + && Objects.equals(getEvent(), that.getEvent()); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + return Objects.hash(completionDate, id, listenerId, publicationDate, getEvent()); + } + } + + record JdbcFailedAttemptInfo(Instant publicationDate, Throwable failureReason) implements FailedAttemptInfo { + + @Override + public Instant getPublicationDate() { + return publicationDate; + } + + @Override + public Throwable getFailureReason() { + return failureReason; + } + } } diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql index 30b86988a..52f2f2926 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql @@ -10,12 +10,11 @@ CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION ); CREATE INDEX IF NOT EXISTS EVENT_PUBLICATION_BY_LISTENER_ID_AND_SERIALIZED_EVENT_IDX ON EVENT_PUBLICATION (LISTENER_ID, SERIALIZED_EVENT); CREATE INDEX IF NOT EXISTS EVENT_PUBLICATION_BY_COMPLETION_DATE_IDX ON EVENT_PUBLICATION (COMPLETION_DATE); -CREATE TABLE IF NOT EXISTS EVENT_FAILED_ATTEMPT_INFO +CREATE TABLE IF NOT EXISTS EVENT_FAILED_EVENT_INFO ( EVENT_ID UUID NOT NULL, FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, - SERIALIZED_REASON VARCHAR(4000) NOT NULL, - REASON_TYPE VARCHAR(512) NOT NULL, + REASON VARCHAR(4000) NOT NULL, CONSTRAINT FK_FAILED_EVENT_INFO_EVENT FOREIGN KEY (EVENT_ID) REFERENCES EVENT_PUBLICATION(ID) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java index bb097134f..6ec9d37ad 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java @@ -82,10 +82,11 @@ static abstract class TestBase { @BeforeEach void cleanUp() { - operations.execute("DELETE FROM " + table()); + operations.execute("TRUNCATE TABLE " + failedInfoTable()); + operations.execute("TRUNCATE TABLE " + table()); if (properties.isArchiveCompletion()) { - operations.execute("DELETE FROM " + archiveTable()); + operations.execute("TRUNCATE TABLE " + archiveTable()); } } @@ -416,8 +417,8 @@ void findsPublicationsThatFailedOnce() { Instant now = Instant.now(); IllegalStateException reason = new IllegalStateException("failed once"); var entry = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(now, reason); - doReturn(reason.toString()).when(serializer).serialize(reason); - doReturn(reason).when(serializer).deserialize(reason.toString(), reason.getClass()); + doReturn(entry.toString()).when(serializer).serialize(entry); + doReturn(entry).when(serializer).deserialize(entry.toString(), entry.getClass()); repository.markFailed(first.getIdentifier(), now, reason); @@ -427,37 +428,12 @@ void findsPublicationsThatFailedOnce() { } - @Test - // GH-294 - void findsPublicationsThatFailedTwice() { - - var first = createPublication(new TestEvent("first")); - Instant firstTry = Instant.now().minusSeconds(10); - Instant secondTry = Instant.now().minusSeconds(5); - IllegalStateException reason1 = new IllegalStateException("failed once"); - IllegalStateException reason2 = new IllegalStateException("failed second time"); - var entry1 = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(firstTry, reason1); - var entry2 = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(secondTry, reason2); - doReturn(reason1.toString()).when(serializer).serialize(reason1); - doReturn(reason2.toString()).when(serializer).serialize(reason2); - doReturn(reason1).when(serializer).deserialize(reason1.toString(), reason1.getClass()); - doReturn(reason2).when(serializer).deserialize(reason2.toString(), reason2.getClass()); - - repository.markFailed(first.getIdentifier(), firstTry, reason1); - repository.markFailed(first.getIdentifier(), secondTry, reason2); - - assertThat(repository.findIncompletePublications()) - .extracting(TargetEventPublication::getFailedAttempts) - .containsExactly(List.of(entry1, entry2)); - - } - String table() { return "EVENT_PUBLICATION"; } String failedInfoTable() { - return "EVENT_FAILED_ATTEMPT_INFO"; + return "EVENT_FAILED_EVENT_INFO"; } String archiveTable() { @@ -539,31 +515,65 @@ class H2WithNoDefinedSchemaName extends WithNoDefinedSchemaName { // issue: https://github.com/h2database/h2database/issues/2065 @BeforeEach void cleanUp() { - super.cleanUp(); operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); } @AfterEach void after() { - super.cleanUp(); operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); } } @WithH2 class H2WithDefinedSchemaName extends WithDefinedSchemaName { + @BeforeEach + void cleanUp() { + operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); + } + + @AfterEach + void after() { + operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); + } } @WithH2 class H2WithEmptySchemaName extends WithEmptySchemaName { + @BeforeEach + void cleanUp() { + operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); + } + + @AfterEach + void after() { + operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); + } } @WithH2 class H2WithDeleteCompletion extends WithDeleteCompletion { + @BeforeEach + void cleanUp() { + operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); + } + + @AfterEach + void after() { + operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); + } } @WithH2 class H2WithArchiveCompletion extends WithArchiveCompletion { + @BeforeEach + void cleanUp() { + operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); + } + + @AfterEach + void after() { + operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); + } } // Postgres From 6b4dca26565c190776d8cfaf6e0f795588d12ab8 Mon Sep 17 00:00:00 2001 From: "mihaita.tinta" Date: Thu, 4 Dec 2025 12:52:16 +0200 Subject: [PATCH 07/11] fix tests Signed-off-by: mihaita.tinta --- .../jdbc/JdbcEventPublicationRepository.java | 1299 +++++++++-------- .../src/main/resources/schema-h2.sql | 3 +- ...PublicationRepositoryIntegrationTests.java | 71 +- 3 files changed, 700 insertions(+), 673 deletions(-) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java index 447f8e2ed..9d0b6bc6b 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java @@ -15,20 +15,6 @@ */ package org.springframework.modulith.events.jdbc; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Timestamp; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import java.util.function.Supplier; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.BeanClassLoaderAware; @@ -44,6 +30,20 @@ import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + /** * JDBC-based repository to store {@link TargetEventPublication}s. * @@ -55,621 +55,658 @@ */ class JdbcEventPublicationRepository implements EventPublicationRepository, BeanClassLoaderAware { - private static final Logger LOGGER = LoggerFactory.getLogger(JdbcEventPublicationRepository.class); - - private static final String SQL_STATEMENT_INSERT = """ - INSERT INTO %s (ID, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT) - VALUES (?, ?, ?, ?, ?) - """; - private static final String SQL_STATEMENT_INSERT_FAILURE = """ - INSERT INTO %s (EVENT_ID, FAILED_DATE, REASON) - VALUES (?, ?, ?) - """; - - private static final String SQL_STATEMENT_FIND_COMPLETED = """ - SELECT ID, COMPLETION_DATE, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT, FAILED_EVENT_INFO - FROM %s - WHERE COMPLETION_DATE IS NOT NULL - ORDER BY PUBLICATION_DATE ASC - """; - - private static final String SQL_STATEMENT_FIND_UNCOMPLETED = """ - SELECT e.ID, e.COMPLETION_DATE, e.EVENT_TYPE, e.LISTENER_ID, e.PUBLICATION_DATE, e.SERIALIZED_EVENT, f.REASON - FROM %s e - INNER JOIN %s f on f.EVENT_ID = e.ID - WHERE COMPLETION_DATE IS NULL - ORDER BY PUBLICATION_DATE ASC - """; - - private static final String SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE = """ - SELECT ID, COMPLETION_DATE, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT, FAILED_EVENT_INFO - FROM %s - WHERE - COMPLETION_DATE IS NULL - AND PUBLICATION_DATE < ? - ORDER BY PUBLICATION_DATE ASC - """; - - private static final String SQL_STATEMENT_UPDATE_BY_EVENT_AND_LISTENER_ID = """ - UPDATE %s - SET COMPLETION_DATE = ? - WHERE - LISTENER_ID = ? - AND COMPLETION_DATE IS NULL - AND SERIALIZED_EVENT = ? - """; - - private static final String SQL_STATEMENT_UPDATE_BY_ID = """ - UPDATE %s - SET COMPLETION_DATE = ? - WHERE - ID = ? - """; - private static final String SQL_STATEMENT_UPDATE_FAILED_BY_ID = """ - UPDATE %s - SET FAILED_EVENT_INFO = ? - WHERE - ID = ? - """; - - private static final String SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID = """ - SELECT * - FROM %s - WHERE - SERIALIZED_EVENT = ? - AND LISTENER_ID = ? - AND COMPLETION_DATE IS NULL - ORDER BY PUBLICATION_DATE - """; - - private static final String SQL_STATEMENT_DELETE = """ - DELETE - FROM %s - WHERE - ID IN - """; - - private static final String SQL_STATEMENT_DELETE_BY_EVENT_AND_LISTENER_ID = """ - DELETE FROM %s - WHERE - LISTENER_ID = ? - AND SERIALIZED_EVENT = ? - """; - - private static final String SQL_STATEMENT_DELETE_BY_ID = """ - DELETE - FROM %s - WHERE - ID = ? - """; - - private static final String SQL_STATEMENT_DELETE_COMPLETED = """ - DELETE - FROM %s - WHERE - COMPLETION_DATE IS NOT NULL - """; - - private static final String SQL_STATEMENT_DELETE_COMPLETED_BEFORE = """ - DELETE - FROM %s - WHERE - COMPLETION_DATE < ? - """; - - private static final String SQL_STATEMENT_COPY_TO_ARCHIVE_BY_ID = """ - -- Only copy if no entry in target table - INSERT INTO %s (ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, COMPLETION_DATE) - SELECT ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, ? - FROM %s - WHERE ID = ? - AND NOT EXISTS (SELECT 1 FROM %s WHERE ID = EVENT_PUBLICATION.ID) - """; - - private static final String SQL_STATEMENT_COPY_TO_ARCHIVE_BY_EVENT_AND_LISTENER_ID = """ - -- Only copy if no entry in target table - INSERT INTO %s (ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, COMPLETION_DATE) - SELECT ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, ? - FROM %s - WHERE LISTENER_ID = ? - AND SERIALIZED_EVENT = ? - AND NOT EXISTS (SELECT 1 FROM %s WHERE ID = EVENT_PUBLICATION.ID) - """; - - private static final int DELETE_BATCH_SIZE = 100; - - private final JdbcOperations operations; - private final EventSerializer serializer; - private final JdbcRepositorySettings settings; - - private ClassLoader classLoader; - - private final String sqlStatementInsert, - sqlStatementInsertFailed, - sqlStatementFindCompleted, - sqlStatementFindUncompleted, - sqlStatementFindUncompletedBefore, - sqlStatementUpdateByEventAndListenerId, - sqlStatementUpdateById, - sqlStatementFindByEventAndListenerId, - sqlStatementDelete, - sqlStatementDeleteByEventAndListenerId, - sqlStatementDeleteById, - sqlStatementDeleteCompleted, - sqlStatementDeleteCompletedBefore, - sqlStatementCopyToArchive, - sqlStatementCopyToArchiveByEventAndListenerId; - - /** - * Creates a new {@link JdbcEventPublicationRepository} for the given {@link JdbcOperations}, {@link EventSerializer}, - * {@link DatabaseType} and {@link JdbcConfigurationProperties}. - * - * @param operations must not be {@literal null}. - * @param serializer must not be {@literal null}. - * @param settings must not be {@literal null}. - */ - public JdbcEventPublicationRepository(JdbcOperations operations, EventSerializer serializer, - JdbcRepositorySettings settings) { - - Assert.notNull(operations, "JdbcOperations must not be null!"); - Assert.notNull(serializer, "EventSerializer must not be null!"); - Assert.notNull(settings, "DatabaseType must not be null!"); - - this.operations = operations; - this.serializer = serializer; - this.settings = settings; - - var schema = settings.getSchema(); - var table = ObjectUtils.isEmpty(schema) ? "EVENT_PUBLICATION" : schema + ".EVENT_PUBLICATION"; - var failedEventInfoTable = ObjectUtils.isEmpty(schema) ? "EVENT_FAILED_EVENT_INFO" : schema + ".EVENT_FAILED_EVENT_INFO"; - var completedTable = settings.isArchiveCompletion() ? table + "_ARCHIVE" : table; - - this.sqlStatementInsert = SQL_STATEMENT_INSERT.formatted(table); - this.sqlStatementInsertFailed = SQL_STATEMENT_INSERT_FAILURE.formatted(failedEventInfoTable); - this.sqlStatementFindCompleted = SQL_STATEMENT_FIND_COMPLETED.formatted(completedTable); - this.sqlStatementFindUncompleted = SQL_STATEMENT_FIND_UNCOMPLETED.formatted(table, failedEventInfoTable); - this.sqlStatementFindUncompletedBefore = SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE.formatted(table); - this.sqlStatementUpdateByEventAndListenerId = SQL_STATEMENT_UPDATE_BY_EVENT_AND_LISTENER_ID.formatted(table); - this.sqlStatementUpdateById = SQL_STATEMENT_UPDATE_BY_ID.formatted(table); - this.sqlStatementFindByEventAndListenerId = SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID.formatted(table); - this.sqlStatementDelete = SQL_STATEMENT_DELETE.formatted(table); - this.sqlStatementDeleteByEventAndListenerId = SQL_STATEMENT_DELETE_BY_EVENT_AND_LISTENER_ID.formatted(table); - this.sqlStatementDeleteById = SQL_STATEMENT_DELETE_BY_ID.formatted(table); - this.sqlStatementDeleteCompleted = SQL_STATEMENT_DELETE_COMPLETED.formatted(completedTable); - this.sqlStatementDeleteCompletedBefore = SQL_STATEMENT_DELETE_COMPLETED_BEFORE.formatted(completedTable); - this.sqlStatementCopyToArchive = SQL_STATEMENT_COPY_TO_ARCHIVE_BY_ID.formatted(completedTable, table, completedTable); - this.sqlStatementCopyToArchiveByEventAndListenerId = SQL_STATEMENT_COPY_TO_ARCHIVE_BY_EVENT_AND_LISTENER_ID.formatted(completedTable, table, completedTable); - } - - /* - * (non-Javadoc) - * @see org.springframework.beans.factory.BeanClassLoaderAware#setBeanClassLoader(java.lang.ClassLoader) - */ - @Override - public void setBeanClassLoader(ClassLoader classLoader) { - this.classLoader = classLoader; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublicationRepository#create(org.springframework.modulith.events.EventPublication) - */ - @Override - @Transactional - public TargetEventPublication create(TargetEventPublication publication) { - - var serializedEvent = serializeEvent(publication.getEvent()); - - operations.update( // - sqlStatementInsert, // - uuidToDatabase(publication.getIdentifier()), // - publication.getEvent().getClass().getName(), // - publication.getTargetIdentifier().getValue(), // - Timestamp.from(publication.getPublicationDate()), // - serializedEvent); - - return publication; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublicationRepository#markCompleted(java.lang.Object, org.springframework.modulith.events.PublicationTargetIdentifier, java.time.Instant) - */ - @Override - @Transactional - public void markCompleted(Object event, PublicationTargetIdentifier identifier, Instant completionDate) { - - var targetIdentifier = identifier.getValue(); - var serializedEvent = serializer.serialize(event); - - if (settings.isDeleteCompletion()) { - - operations.update(sqlStatementDeleteByEventAndListenerId, targetIdentifier, serializedEvent); - - } else if (settings.isArchiveCompletion()) { - - operations.update(sqlStatementCopyToArchiveByEventAndListenerId, // - Timestamp.from(completionDate), // - targetIdentifier, // - serializedEvent); - operations.update(sqlStatementDeleteByEventAndListenerId, targetIdentifier, serializedEvent); - - } else { - - operations.update(sqlStatementUpdateByEventAndListenerId, // - Timestamp.from(completionDate), // - targetIdentifier, // - serializedEvent); - } - } - - @Override - public void markFailed(UUID identifier, Instant failedDate, Throwable exception) { - - var databaseId = uuidToDatabase(identifier); - var reason = serializer.serialize(new JdbcFailedAttemptInfo(failedDate, exception)); - this.operations.update(sqlStatementInsertFailed, databaseId, failedDate, reason); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#markCompleted(java.util.UUID, java.time.Instant) - */ - @Override - @Transactional - public void markCompleted(UUID identifier, Instant completionDate) { - - var databaseId = uuidToDatabase(identifier); - var timestamp = Timestamp.from(completionDate); - - if (settings.isDeleteCompletion()) { - operations.update(sqlStatementDeleteById, databaseId); - - } else if (settings.isArchiveCompletion()) { - operations.update(sqlStatementCopyToArchive, timestamp, databaseId); - operations.update(sqlStatementDeleteById, databaseId); - - } else { - operations.update(sqlStatementUpdateById, timestamp, databaseId); - } - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#findIncompletePublicationsByEventAndTargetIdentifier(java.lang.Object, org.springframework.modulith.events.core.PublicationTargetIdentifier) - */ - @Override - @Transactional(readOnly = true) - public Optional findIncompletePublicationsByEventAndTargetIdentifier( // - Object event, PublicationTargetIdentifier targetIdentifier) { - - var result = operations.query(sqlStatementFindByEventAndListenerId, // - this::resultSetToPublications, // - serializeEvent(event), // - targetIdentifier.getValue()); - - return result == null ? Optional.empty() : result.stream().findFirst(); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#findCompletedPublications() - */ - @Override - public List findCompletedPublications() { - - var result = operations.query(sqlStatementFindCompleted, this::resultSetToPublications); - - return result == null ? Collections.emptyList() : result; - } - - @Override - @Transactional(readOnly = true) - @SuppressWarnings("null") - public List findIncompletePublications() { - return operations.query(sqlStatementFindUncompleted, this::resultSetToPublications); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#findIncompletePublicationsPublishedBefore(java.time.Instant) - */ - @Override - public List findIncompletePublicationsPublishedBefore(Instant instant) { - - var result = operations.query(sqlStatementFindUncompletedBefore, - this::resultSetToPublications, Timestamp.from(instant)); - - return result == null ? Collections.emptyList() : result; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#deletePublications(java.util.List) - */ - @Override - public void deletePublications(List identifiers) { - - var dbIdentifiers = identifiers.stream().map(this::uuidToDatabase).toList(); - - batch(dbIdentifiers, DELETE_BATCH_SIZE) - .forEach(it -> operations.update(sqlStatementDelete.concat(toParameterPlaceholders(it.length)), it)); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.core.EventPublicationRepository#deleteCompletedPublications() - */ - @Override - public void deleteCompletedPublications() { - operations.execute(sqlStatementDeleteCompleted); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublicationRepository#deleteCompletedPublicationsBefore(java.time.Instant) - */ - @Override - public void deleteCompletedPublicationsBefore(Instant instant) { - - Assert.notNull(instant, "Instant must not be null!"); - - operations.update(sqlStatementDeleteCompletedBefore, Timestamp.from(instant)); - } - - private String serializeEvent(Object event) { - return serializer.serialize(event).toString(); - } - - /** - * Effectively a {@link ResultSetExtractor} to drop {@link TargetEventPublication}s that cannot be deserialized. - * - * @param resultSet must not be {@literal null}. - * @return will never be {@literal null}. - * @throws SQLException - */ - private List resultSetToPublications(ResultSet resultSet) throws SQLException { - - List result = new ArrayList<>(); - - while (resultSet.next()) { - - var publication = resultSetToPublication(resultSet); - - if (publication != null) { - result.add(publication); - } - } - - return result; - } - - /** - * Effectively a {@link RowMapper} to turn a single row into an {@link TargetEventPublication}. - * - * @param rs must not be {@literal null}. - * @return can be {@literal null}. - * @throws SQLException - */ - @Nullable - private TargetEventPublication resultSetToPublication(ResultSet rs) throws SQLException { - - var id = getUuidFromResultSet(rs); - var eventClass = loadClass(id, rs.getString("EVENT_TYPE")); - - if (eventClass == null) { - return null; - } - - var completionDate = rs.getTimestamp("COMPLETION_DATE"); - var publicationDate = rs.getTimestamp("PUBLICATION_DATE").toInstant(); - var listenerId = rs.getString("LISTENER_ID"); - var serializedEvent = rs.getString("SERIALIZED_EVENT"); - var failedEventInfo = rs.getString("REASON"); - - return new JdbcEventPublication(id, publicationDate, listenerId, - () -> serializer.deserialize(serializedEvent, eventClass), - completionDate == null ? null : completionDate.toInstant(), - List.of(serializer.deserialize(failedEventInfo, JdbcFailedAttemptInfo.class)));// TODO add value from resultset - } - - private Object uuidToDatabase(UUID id) { - return settings.getDatabaseType().uuidToDatabase(id); - } - - private UUID getUuidFromResultSet(ResultSet rs) throws SQLException { - return settings.getDatabaseType().databaseToUUID(rs.getObject("ID")); - } - - @Nullable - private Class loadClass(UUID id, String className) { - - try { - return ClassUtils.forName(className, classLoader); - } catch (ClassNotFoundException e) { - LOGGER.warn("Event '{}' of unknown type '{}' found", id, className); - return null; - } - } - - private static List batch(List input, int batchSize) { - - var inputSize = input.size(); - - return IntStream.range(0, (inputSize + batchSize - 1) / batchSize) - .mapToObj(i -> input.subList(i * batchSize, Math.min((i + 1) * batchSize, inputSize))) - .map(List::toArray) - .toList(); - } - - private static String toParameterPlaceholders(int length) { - - return IntStream.range(0, length) - .mapToObj(__ -> "?") - .collect(Collectors.joining(", ", "(", ")")); - } - - private static class JdbcEventPublication implements TargetEventPublication { - - private final UUID id; - private final Instant publicationDate; - private final String listenerId; - private final Supplier eventSupplier; - - private @Nullable Instant completionDate; - private @Nullable Object event; - private List failedAttempts; - - /** - * @param id must not be {@literal null}. - * @param publicationDate must not be {@literal null}. - * @param listenerId must not be {@literal null} or empty. - * @param event must not be {@literal null}.. - * @param completionDate can be {@literal null}. - * @param failedAttempts can be {@literal null}. - */ - public JdbcEventPublication(UUID id, Instant publicationDate, String listenerId, Supplier event, - @Nullable Instant completionDate, List failedAttempts) { - - Assert.notNull(id, "Id must not be null!"); - Assert.notNull(publicationDate, "Publication date must not be null!"); - Assert.hasText(listenerId, "Listener id must not be null or empty!"); - Assert.notNull(event, "Event must not be null!"); - Assert.notNull(failedAttempts, "Failed attempts must not be null!"); - - this.id = id; - this.publicationDate = publicationDate; - this.listenerId = listenerId; - this.eventSupplier = event; - this.completionDate = completionDate; - this.failedAttempts = failedAttempts; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublication#getPublicationIdentifier() - */ - @Override - public UUID getIdentifier() { - return id; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublication#getEvent() - */ - @Override - @SuppressWarnings("null") - public Object getEvent() { - - if (event == null) { - this.event = eventSupplier.get(); - } - - return event; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublication#getTargetIdentifier() - */ - @Override - public PublicationTargetIdentifier getTargetIdentifier() { - return PublicationTargetIdentifier.of(listenerId); - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.EventPublication#getPublicationDate() - */ - @Override - public Instant getPublicationDate() { - return publicationDate; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.CompletableEventPublication#getCompletionDate() - */ - @Override - public Optional getCompletionDate() { - return Optional.ofNullable(completionDate); - } - - @Override - public List getFailedAttempts() { - return failedAttempts; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.CompletableEventPublication#isPublicationCompleted() - */ - @Override - public boolean isPublicationCompleted() { - return completionDate != null; - } - - /* - * (non-Javadoc) - * @see org.springframework.modulith.events.Completable#markCompleted(java.time.Instant) - */ - @Override - public void markCompleted(Instant instant) { - this.completionDate = instant; - } - - @Override - public void markFailed(Instant instant, Throwable exception) { - - if (failedAttempts == null) { - failedAttempts = new ArrayList<>(); - } - failedAttempts.add(new JdbcFailedAttemptInfo(instant, exception)); - } - - /* - * (non-Javadoc) - * @see java.lang.Object#equals(java.lang.Object) - */ - @Override - public boolean equals(@Nullable Object obj) { - - if (this == obj) { - return true; - } - - if (!(obj instanceof JdbcEventPublication that)) { - return false; - } - - return Objects.equals(completionDate, that.completionDate) // - && Objects.equals(id, that.id) // - && Objects.equals(listenerId, that.listenerId) // - && Objects.equals(publicationDate, that.publicationDate) // - && Objects.equals(getEvent(), that.getEvent()); - } - - /* - * (non-Javadoc) - * @see java.lang.Object#hashCode() - */ - @Override - public int hashCode() { - return Objects.hash(completionDate, id, listenerId, publicationDate, getEvent()); - } - } - - record JdbcFailedAttemptInfo(Instant publicationDate, Throwable failureReason) implements FailedAttemptInfo { - - @Override - public Instant getPublicationDate() { - return publicationDate; - } - - @Override - public Throwable getFailureReason() { - return failureReason; - } - } + private static final Logger LOGGER = LoggerFactory.getLogger(JdbcEventPublicationRepository.class); + + private static final String SQL_STATEMENT_INSERT = """ + INSERT INTO %s (ID, EVENT_TYPE, LISTENER_ID, PUBLICATION_DATE, SERIALIZED_EVENT) + VALUES (?, ?, ?, ?, ?) + """; + private static final String SQL_STATEMENT_INSERT_FAILURE = """ + INSERT INTO %s (EVENT_ID, FAILED_DATE, SERIALIZED_REASON, REASON_TYPE) + VALUES (?, ?, ?, ?) + """; + + private static final String SQL_STATEMENT_FIND_COMPLETED = """ + SELECT e.ID, e.COMPLETION_DATE, e.EVENT_TYPE, e.LISTENER_ID, e.PUBLICATION_DATE, e.SERIALIZED_EVENT, + f.SERIALIZED_REASON, f.REASON_TYPE, f.FAILED_DATE + FROM %s e + LEFT JOIN %s f on f.EVENT_ID = e.ID + WHERE e.COMPLETION_DATE IS NOT NULL + ORDER BY e.PUBLICATION_DATE ASC + """; + + private static final String SQL_STATEMENT_FIND_UNCOMPLETED = """ + SELECT e.ID, e.COMPLETION_DATE, e.EVENT_TYPE, e.LISTENER_ID, e.PUBLICATION_DATE, e.SERIALIZED_EVENT, + f.SERIALIZED_REASON, f.REASON_TYPE, f.FAILED_DATE + FROM %s e + LEFT JOIN %s f on f.EVENT_ID = e.ID + WHERE COMPLETION_DATE IS NULL + ORDER BY PUBLICATION_DATE ASC + """; + + private static final String SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE = """ + SELECT e.ID, e.COMPLETION_DATE, e.EVENT_TYPE, e.LISTENER_ID, e.PUBLICATION_DATE, e.SERIALIZED_EVENT, + f.SERIALIZED_REASON, f.REASON_TYPE, f.FAILED_DATE + FROM %s e + LEFT JOIN %s f on f.EVENT_ID = e.ID + WHERE + e.COMPLETION_DATE IS NULL + AND e.PUBLICATION_DATE < ? + ORDER BY e.PUBLICATION_DATE ASC + """; + + private static final String SQL_STATEMENT_UPDATE_BY_EVENT_AND_LISTENER_ID = """ + UPDATE %s + SET COMPLETION_DATE = ? + WHERE + LISTENER_ID = ? + AND COMPLETION_DATE IS NULL + AND SERIALIZED_EVENT = ? + """; + + private static final String SQL_STATEMENT_UPDATE_BY_ID = """ + UPDATE %s + SET COMPLETION_DATE = ? + WHERE + ID = ? + """; + private static final String SQL_STATEMENT_UPDATE_FAILED_BY_ID = """ + UPDATE %s + SET FAILED_EVENT_INFO = ? + WHERE + ID = ? + """; + + private static final String SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID = """ + SELECT e.*, f.SERIALIZED_REASON, f.REASON_TYPE, f.FAILED_DATE + FROM %s e + LEFT JOIN %s f on f.EVENT_ID = e.ID + WHERE + e.SERIALIZED_EVENT = ? + AND e.LISTENER_ID = ? + AND e.COMPLETION_DATE IS NULL + ORDER BY e.PUBLICATION_DATE + """; + + private static final String SQL_STATEMENT_DELETE = """ + DELETE + FROM %s + WHERE + ID IN + """; + + private static final String SQL_STATEMENT_DELETE_BY_EVENT_AND_LISTENER_ID = """ + DELETE FROM %s + WHERE + LISTENER_ID = ? + AND SERIALIZED_EVENT = ? + """; + + private static final String SQL_STATEMENT_DELETE_BY_ID = """ + DELETE + FROM %s + WHERE + ID = ? + """; + + private static final String SQL_STATEMENT_DELETE_COMPLETED = """ + DELETE + FROM %s + WHERE + COMPLETION_DATE IS NOT NULL + """; + + private static final String SQL_STATEMENT_DELETE_COMPLETED_BEFORE = """ + DELETE + FROM %s + WHERE + COMPLETION_DATE < ? + """; + + private static final String SQL_STATEMENT_COPY_TO_ARCHIVE_BY_ID = """ + -- Only copy if no entry in target table + INSERT INTO %s (ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, COMPLETION_DATE) + SELECT ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, ? + FROM %s + WHERE ID = ? + AND NOT EXISTS (SELECT 1 FROM %s WHERE ID = EVENT_PUBLICATION.ID) + """; + + private static final String SQL_STATEMENT_COPY_TO_ARCHIVE_BY_EVENT_AND_LISTENER_ID = """ + -- Only copy if no entry in target table + INSERT INTO %s (ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, COMPLETION_DATE) + SELECT ID, LISTENER_ID, EVENT_TYPE, SERIALIZED_EVENT, PUBLICATION_DATE, ? + FROM %s + WHERE LISTENER_ID = ? + AND SERIALIZED_EVENT = ? + AND NOT EXISTS (SELECT 1 FROM %s WHERE ID = EVENT_PUBLICATION.ID) + """; + + private static final int DELETE_BATCH_SIZE = 100; + + private final JdbcOperations operations; + private final EventSerializer serializer; + private final JdbcRepositorySettings settings; + + private ClassLoader classLoader; + + private final String sqlStatementInsert, + sqlStatementInsertFailed, + sqlStatementFindCompleted, + sqlStatementFindUncompleted, + sqlStatementFindUncompletedBefore, + sqlStatementUpdateByEventAndListenerId, + sqlStatementUpdateById, + sqlStatementFindByEventAndListenerId, + sqlStatementDelete, + sqlStatementDeleteByEventAndListenerId, + sqlStatementDeleteById, + sqlStatementDeleteCompleted, + sqlStatementDeleteCompletedBefore, + sqlStatementCopyToArchive, + sqlStatementCopyToArchiveByEventAndListenerId; + + /** + * Creates a new {@link JdbcEventPublicationRepository} for the given {@link JdbcOperations}, {@link EventSerializer}, + * {@link DatabaseType} and {@link JdbcConfigurationProperties}. + * + * @param operations must not be {@literal null}. + * @param serializer must not be {@literal null}. + * @param settings must not be {@literal null}. + */ + public JdbcEventPublicationRepository(JdbcOperations operations, EventSerializer serializer, + JdbcRepositorySettings settings) { + + Assert.notNull(operations, "JdbcOperations must not be null!"); + Assert.notNull(serializer, "EventSerializer must not be null!"); + Assert.notNull(settings, "DatabaseType must not be null!"); + + this.operations = operations; + this.serializer = serializer; + this.settings = settings; + + var schema = settings.getSchema(); + var table = ObjectUtils.isEmpty(schema) ? "EVENT_PUBLICATION" : schema + ".EVENT_PUBLICATION"; + var failedAttemptInfoTable = ObjectUtils.isEmpty(schema) ? "EVENT_FAILED_EVENT_INFO" : schema + ".EVENT_FAILED_EVENT_INFO"; + var completedTable = settings.isArchiveCompletion() ? table + "_ARCHIVE" : table; + + this.sqlStatementInsert = SQL_STATEMENT_INSERT.formatted(table); + this.sqlStatementInsertFailed = SQL_STATEMENT_INSERT_FAILURE.formatted(failedAttemptInfoTable); + this.sqlStatementFindCompleted = SQL_STATEMENT_FIND_COMPLETED.formatted(completedTable, failedAttemptInfoTable); + this.sqlStatementFindUncompleted = SQL_STATEMENT_FIND_UNCOMPLETED.formatted(table, failedAttemptInfoTable); + this.sqlStatementFindUncompletedBefore = SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE.formatted(table, failedAttemptInfoTable); + this.sqlStatementUpdateByEventAndListenerId = SQL_STATEMENT_UPDATE_BY_EVENT_AND_LISTENER_ID.formatted(table); + this.sqlStatementUpdateById = SQL_STATEMENT_UPDATE_BY_ID.formatted(table); + this.sqlStatementFindByEventAndListenerId = SQL_STATEMENT_FIND_BY_EVENT_AND_LISTENER_ID.formatted(table, failedAttemptInfoTable); + this.sqlStatementDelete = SQL_STATEMENT_DELETE.formatted(table); + this.sqlStatementDeleteByEventAndListenerId = SQL_STATEMENT_DELETE_BY_EVENT_AND_LISTENER_ID.formatted(table); + this.sqlStatementDeleteById = SQL_STATEMENT_DELETE_BY_ID.formatted(table); + this.sqlStatementDeleteCompleted = SQL_STATEMENT_DELETE_COMPLETED.formatted(completedTable); + this.sqlStatementDeleteCompletedBefore = SQL_STATEMENT_DELETE_COMPLETED_BEFORE.formatted(completedTable); + this.sqlStatementCopyToArchive = SQL_STATEMENT_COPY_TO_ARCHIVE_BY_ID.formatted(completedTable, table, completedTable); + this.sqlStatementCopyToArchiveByEventAndListenerId = SQL_STATEMENT_COPY_TO_ARCHIVE_BY_EVENT_AND_LISTENER_ID.formatted(completedTable, table, completedTable); + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.BeanClassLoaderAware#setBeanClassLoader(java.lang.ClassLoader) + */ + @Override + public void setBeanClassLoader(ClassLoader classLoader) { + this.classLoader = classLoader; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublicationRepository#create(org.springframework.modulith.events.EventPublication) + */ + @Override + @Transactional + public TargetEventPublication create(TargetEventPublication publication) { + + var serializedEvent = serializeEvent(publication.getEvent()); + + operations.update( // + sqlStatementInsert, // + uuidToDatabase(publication.getIdentifier()), // + publication.getEvent().getClass().getName(), // + publication.getTargetIdentifier().getValue(), // + Timestamp.from(publication.getPublicationDate()), // + serializedEvent); + + return publication; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublicationRepository#markCompleted(java.lang.Object, org.springframework.modulith.events.PublicationTargetIdentifier, java.time.Instant) + */ + @Override + @Transactional + public void markCompleted(Object event, PublicationTargetIdentifier identifier, Instant completionDate) { + + var targetIdentifier = identifier.getValue(); + var serializedEvent = serializer.serialize(event); + + if (settings.isDeleteCompletion()) { + + operations.update(sqlStatementDeleteByEventAndListenerId, targetIdentifier, serializedEvent); + + } else if (settings.isArchiveCompletion()) { + + operations.update(sqlStatementCopyToArchiveByEventAndListenerId, // + Timestamp.from(completionDate), // + targetIdentifier, // + serializedEvent); + operations.update(sqlStatementDeleteByEventAndListenerId, targetIdentifier, serializedEvent); + + } else { + + operations.update(sqlStatementUpdateByEventAndListenerId, // + Timestamp.from(completionDate), // + targetIdentifier, // + serializedEvent); + } + } + + @Override + public void markFailed(UUID identifier, Instant failedDate, Throwable exception) { + + var databaseId = uuidToDatabase(identifier); + var reason = serializer.serialize(exception); + this.operations.update(sqlStatementInsertFailed, databaseId, failedDate, + reason, exception.getClass().getName()); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#markCompleted(java.util.UUID, java.time.Instant) + */ + @Override + @Transactional + public void markCompleted(UUID identifier, Instant completionDate) { + + var databaseId = uuidToDatabase(identifier); + var timestamp = Timestamp.from(completionDate); + + if (settings.isDeleteCompletion()) { + operations.update(sqlStatementDeleteById, databaseId); + + } else if (settings.isArchiveCompletion()) { + operations.update(sqlStatementCopyToArchive, timestamp, databaseId); + operations.update(sqlStatementDeleteById, databaseId); + + } else { + operations.update(sqlStatementUpdateById, timestamp, databaseId); + } + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#findIncompletePublicationsByEventAndTargetIdentifier(java.lang.Object, org.springframework.modulith.events.core.PublicationTargetIdentifier) + */ + @Override + @Transactional(readOnly = true) + public Optional findIncompletePublicationsByEventAndTargetIdentifier( // + Object event, PublicationTargetIdentifier targetIdentifier) { + + var result = operations.query(sqlStatementFindByEventAndListenerId, // + this::resultSetToPublications, // + serializeEvent(event), // + targetIdentifier.getValue()); + + return result == null ? Optional.empty() : result.stream().findFirst(); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#findCompletedPublications() + */ + @Override + public List findCompletedPublications() { + + var result = operations.query(sqlStatementFindCompleted, this::resultSetToPublications); + + return result == null ? Collections.emptyList() : result; + } + + @Override + @Transactional(readOnly = true) + @SuppressWarnings("null") + public List findIncompletePublications() { + return operations.query(sqlStatementFindUncompleted, this::resultSetToPublications); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#findIncompletePublicationsPublishedBefore(java.time.Instant) + */ + @Override + public List findIncompletePublicationsPublishedBefore(Instant instant) { + + var result = operations.query(sqlStatementFindUncompletedBefore, + this::resultSetToPublications, Timestamp.from(instant)); + + return result == null ? Collections.emptyList() : result; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#deletePublications(java.util.List) + */ + @Override + public void deletePublications(List identifiers) { + + var dbIdentifiers = identifiers.stream().map(this::uuidToDatabase).toList(); + + batch(dbIdentifiers, DELETE_BATCH_SIZE) + .forEach(it -> operations.update(sqlStatementDelete.concat(toParameterPlaceholders(it.length)), it)); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.core.EventPublicationRepository#deleteCompletedPublications() + */ + @Override + public void deleteCompletedPublications() { + operations.execute(sqlStatementDeleteCompleted); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublicationRepository#deleteCompletedPublicationsBefore(java.time.Instant) + */ + @Override + public void deleteCompletedPublicationsBefore(Instant instant) { + + Assert.notNull(instant, "Instant must not be null!"); + + operations.update(sqlStatementDeleteCompletedBefore, Timestamp.from(instant)); + } + + private String serializeEvent(Object event) { + return serializer.serialize(event).toString(); + } + + /** + * Effectively a {@link ResultSetExtractor} to drop {@link TargetEventPublication}s that cannot be deserialized. + * + * @param resultSet must not be {@literal null}. + * @return will never be {@literal null}. + * @throws SQLException + */ + private List resultSetToPublications(ResultSet resultSet) throws SQLException { + + List result = new ArrayList<>(); + + TargetEventPublication lastPublication = null; + while (resultSet.next()) { + + var publication = resultSetToPublication(resultSet); + + if (publication != null) { + + if (lastPublication == null || !lastPublication.getIdentifier().equals(publication.id)) { + lastPublication = new JdbcEventPublication(publication.id(), + publication.publicationDate, + publication.listenerId, + publication.event, + publication.completionDate, + new ArrayList<>() + ); + result.add(lastPublication); + } + + if (publication.failedAttempt.isPresent()) { + lastPublication.getFailedAttempts().add(publication.failedAttempt.get()); + } + } + } + return result; + } + + /** + * Effectively a {@link RowMapper} to turn a single row into an {@link ResultSetJdbcEventPublication}. + * + * @param rs must not be {@literal null}. + * @return can be {@literal null}. + * @throws SQLException + */ + @Nullable + private ResultSetJdbcEventPublication resultSetToPublication(ResultSet rs) throws SQLException { + + var id = getUuidFromResultSet(rs); + var eventClass = loadClass(id, rs.getString("EVENT_TYPE")); + + if (eventClass == null) { + return null; + } + + var completionDate = rs.getTimestamp("COMPLETION_DATE"); + var publicationDate = rs.getTimestamp("PUBLICATION_DATE").toInstant(); + var listenerId = rs.getString("LISTENER_ID"); + var serializedEvent = rs.getString("SERIALIZED_EVENT"); + + var failedAttemptReasonType = rs.getString("REASON_TYPE"); + Optional attempt = Optional.empty(); + if (failedAttemptReasonType != null) { + var attemptClass = loadClass(id, failedAttemptReasonType); + if (attemptClass != null) { + var failedAttemptReason = rs.getString("SERIALIZED_REASON"); + var failedAttemptDate = rs.getTimestamp("FAILED_DATE").toInstant(); + attempt = Optional.of(new JdbcFailedAttemptInfo(failedAttemptDate, + serializer.deserialize(failedAttemptReason, (Class) attemptClass) + )); + } + } + return new ResultSetJdbcEventPublication(id, publicationDate, listenerId, + () -> serializer.deserialize(serializedEvent, eventClass), + completionDate == null ? null : completionDate.toInstant(), + attempt);// TODO add value from resultset + } + + private Object uuidToDatabase(UUID id) { + return settings.getDatabaseType().uuidToDatabase(id); + } + + private UUID getUuidFromResultSet(ResultSet rs) throws SQLException { + return settings.getDatabaseType().databaseToUUID(rs.getObject("ID")); + } + + @Nullable + private Class loadClass(UUID id, String className) { + + try { + return ClassUtils.forName(className, classLoader); + } catch (ClassNotFoundException e) { + LOGGER.warn("Event '{}' of unknown type '{}' found", id, className); + return null; + } + } + + private static List batch(List input, int batchSize) { + + var inputSize = input.size(); + + return IntStream.range(0, (inputSize + batchSize - 1) / batchSize) + .mapToObj(i -> input.subList(i * batchSize, Math.min((i + 1) * batchSize, inputSize))) + .map(List::toArray) + .toList(); + } + + private static String toParameterPlaceholders(int length) { + + return IntStream.range(0, length) + .mapToObj(__ -> "?") + .collect(Collectors.joining(", ", "(", ")")); + } + + private static class JdbcEventPublication implements TargetEventPublication { + + private final UUID id; + private final Instant publicationDate; + private final String listenerId; + private final Supplier eventSupplier; + + private @Nullable Instant completionDate; + private @Nullable Object event; + private List failedAttempts; + + /** + * @param id must not be {@literal null}. + * @param publicationDate must not be {@literal null}. + * @param listenerId must not be {@literal null} or empty. + * @param event must not be {@literal null}.. + * @param completionDate can be {@literal null}. + * @param failedAttempts can be {@literal null}. + */ + public JdbcEventPublication(UUID id, Instant publicationDate, String listenerId, Supplier event, + @Nullable Instant completionDate, List failedAttempts) { + + Assert.notNull(id, "Id must not be null!"); + Assert.notNull(publicationDate, "Publication date must not be null!"); + Assert.hasText(listenerId, "Listener id must not be null or empty!"); + Assert.notNull(event, "Event must not be null!"); + Assert.notNull(failedAttempts, "Failed attempts must not be null!"); + + this.id = id; + this.publicationDate = publicationDate; + this.listenerId = listenerId; + this.eventSupplier = event; + this.completionDate = completionDate; + this.failedAttempts = failedAttempts; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublication#getPublicationIdentifier() + */ + @Override + public UUID getIdentifier() { + return id; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublication#getEvent() + */ + @Override + @SuppressWarnings("null") + public Object getEvent() { + + if (event == null) { + this.event = eventSupplier.get(); + } + + return event; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublication#getTargetIdentifier() + */ + @Override + public PublicationTargetIdentifier getTargetIdentifier() { + return PublicationTargetIdentifier.of(listenerId); + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.EventPublication#getPublicationDate() + */ + @Override + public Instant getPublicationDate() { + return publicationDate; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.CompletableEventPublication#getCompletionDate() + */ + @Override + public Optional getCompletionDate() { + return Optional.ofNullable(completionDate); + } + + @Override + public List getFailedAttempts() { + return failedAttempts; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.CompletableEventPublication#isPublicationCompleted() + */ + @Override + public boolean isPublicationCompleted() { + return completionDate != null; + } + + /* + * (non-Javadoc) + * @see org.springframework.modulith.events.Completable#markCompleted(java.time.Instant) + */ + @Override + public void markCompleted(Instant instant) { + this.completionDate = instant; + } + + @Override + public void markFailed(Instant instant, Throwable exception) { + + if (failedAttempts == null) { + failedAttempts = new ArrayList<>(); + } + failedAttempts.add(new JdbcFailedAttemptInfo(instant, exception)); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ + @Override + public boolean equals(@Nullable Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof JdbcEventPublication that)) { + return false; + } + + return Objects.equals(completionDate, that.completionDate) // + && Objects.equals(id, that.id) // + && Objects.equals(listenerId, that.listenerId) // + && Objects.equals(publicationDate, that.publicationDate) // + && Objects.equals(getEvent(), that.getEvent()); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + return Objects.hash(completionDate, id, listenerId, publicationDate, getEvent()); + } + } + + record ResultSetJdbcEventPublication(UUID id, Instant publicationDate, String listenerId, Supplier event, + @Nullable Instant completionDate, Optional failedAttempt) { + + } + + record JdbcFailedAttemptInfo(Instant publicationDate, Throwable failureReason) implements FailedAttemptInfo { + + @Override + public Instant getPublicationDate() { + return publicationDate; + } + + @Override + public Throwable getFailureReason() { + return failureReason; + } + } } diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql index 52f2f2926..52a163d13 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql @@ -14,7 +14,8 @@ CREATE TABLE IF NOT EXISTS EVENT_FAILED_EVENT_INFO ( EVENT_ID UUID NOT NULL, FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, - REASON VARCHAR(4000) NOT NULL, + SERIALIZED_REASON VARCHAR(4000) NOT NULL, + REASON_TYPE VARCHAR(512) NOT NULL, CONSTRAINT FK_FAILED_EVENT_INFO_EVENT FOREIGN KEY (EVENT_ID) REFERENCES EVENT_PUBLICATION(ID) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java index 6ec9d37ad..b9483bc6b 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java @@ -82,11 +82,10 @@ static abstract class TestBase { @BeforeEach void cleanUp() { - operations.execute("TRUNCATE TABLE " + failedInfoTable()); - operations.execute("TRUNCATE TABLE " + table()); + operations.execute("DELETE FROM " + table()); if (properties.isArchiveCompletion()) { - operations.execute("TRUNCATE TABLE " + archiveTable()); + operations.execute("DELETE FROM " + archiveTable()); } } @@ -417,8 +416,8 @@ void findsPublicationsThatFailedOnce() { Instant now = Instant.now(); IllegalStateException reason = new IllegalStateException("failed once"); var entry = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(now, reason); - doReturn(entry.toString()).when(serializer).serialize(entry); - doReturn(entry).when(serializer).deserialize(entry.toString(), entry.getClass()); + doReturn(reason.toString()).when(serializer).serialize(reason); + doReturn(reason).when(serializer).deserialize(reason.toString(), reason.getClass()); repository.markFailed(first.getIdentifier(), now, reason); @@ -428,6 +427,30 @@ void findsPublicationsThatFailedOnce() { } + @Test + // GH-294 + void findsPublicationsThatFailedTwice() { + + var first = createPublication(new TestEvent("first")); + Instant now = Instant.now(); + IllegalStateException reason1 = new IllegalStateException("failed once"); + IllegalStateException reason2 = new IllegalStateException("failed second time"); + var entry1 = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(now, reason1); + var entry2 = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(now, reason2); + doReturn(reason1.toString()).when(serializer).serialize(reason1); + doReturn(reason2.toString()).when(serializer).serialize(reason2); + doReturn(reason1).when(serializer).deserialize(reason1.toString(), reason1.getClass()); + doReturn(reason2).when(serializer).deserialize(reason2.toString(), reason2.getClass()); + + repository.markFailed(first.getIdentifier(), now, reason1); + repository.markFailed(first.getIdentifier(), now, reason2); + + assertThat(repository.findIncompletePublications()) + .extracting(TargetEventPublication::getFailedAttempts) + .containsExactly(List.of(entry1, entry2)); + + } + String table() { return "EVENT_PUBLICATION"; } @@ -515,65 +538,31 @@ class H2WithNoDefinedSchemaName extends WithNoDefinedSchemaName { // issue: https://github.com/h2database/h2database/issues/2065 @BeforeEach void cleanUp() { + super.cleanUp(); operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); } @AfterEach void after() { + super.cleanUp(); operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); } } @WithH2 class H2WithDefinedSchemaName extends WithDefinedSchemaName { - @BeforeEach - void cleanUp() { - operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); - } - - @AfterEach - void after() { - operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); - } } @WithH2 class H2WithEmptySchemaName extends WithEmptySchemaName { - @BeforeEach - void cleanUp() { - operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); - } - - @AfterEach - void after() { - operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); - } } @WithH2 class H2WithDeleteCompletion extends WithDeleteCompletion { - @BeforeEach - void cleanUp() { - operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); - } - - @AfterEach - void after() { - operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); - } } @WithH2 class H2WithArchiveCompletion extends WithArchiveCompletion { - @BeforeEach - void cleanUp() { - operations.execute("SET REFERENTIAL_INTEGRITY FALSE;"); - } - - @AfterEach - void after() { - operations.execute("SET REFERENTIAL_INTEGRITY TRUE;"); - } } // Postgres From 33d48751765dc54cb305305ec81f5bc0a14bac89 Mon Sep 17 00:00:00 2001 From: "mihaita.tinta" Date: Thu, 4 Dec 2025 15:55:10 +0200 Subject: [PATCH 08/11] update schemas Signed-off-by: mihaita.tinta --- .../modulith/events/jdbc/JdbcEventPublicationRepository.java | 2 +- .../src/main/resources/schema-h2.sql | 2 +- .../jdbc/JdbcEventPublicationRepositoryIntegrationTests.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java index 9d0b6bc6b..9496612ab 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java @@ -227,7 +227,7 @@ public JdbcEventPublicationRepository(JdbcOperations operations, EventSerializer var schema = settings.getSchema(); var table = ObjectUtils.isEmpty(schema) ? "EVENT_PUBLICATION" : schema + ".EVENT_PUBLICATION"; - var failedAttemptInfoTable = ObjectUtils.isEmpty(schema) ? "EVENT_FAILED_EVENT_INFO" : schema + ".EVENT_FAILED_EVENT_INFO"; + var failedAttemptInfoTable = ObjectUtils.isEmpty(schema) ? "EVENT_FAILED_ATTEMPT_INFO" : schema + ".EVENT_FAILED_ATTEMPT_INFO"; var completedTable = settings.isArchiveCompletion() ? table + "_ARCHIVE" : table; this.sqlStatementInsert = SQL_STATEMENT_INSERT.formatted(table); diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql index 52a163d13..30b86988a 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-h2.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION ); CREATE INDEX IF NOT EXISTS EVENT_PUBLICATION_BY_LISTENER_ID_AND_SERIALIZED_EVENT_IDX ON EVENT_PUBLICATION (LISTENER_ID, SERIALIZED_EVENT); CREATE INDEX IF NOT EXISTS EVENT_PUBLICATION_BY_COMPLETION_DATE_IDX ON EVENT_PUBLICATION (COMPLETION_DATE); -CREATE TABLE IF NOT EXISTS EVENT_FAILED_EVENT_INFO +CREATE TABLE IF NOT EXISTS EVENT_FAILED_ATTEMPT_INFO ( EVENT_ID UUID NOT NULL, FAILED_DATE TIMESTAMP(9) WITH TIME ZONE, diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java index b9483bc6b..f4a9397c9 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java @@ -456,7 +456,7 @@ String table() { } String failedInfoTable() { - return "EVENT_FAILED_EVENT_INFO"; + return "EVENT_FAILED_ATTEMPT_INFO"; } String archiveTable() { From 7811d1ea831171862ae7391111cc461c194af8d4 Mon Sep 17 00:00:00 2001 From: "mihaita.tinta" Date: Thu, 4 Dec 2025 16:12:31 +0200 Subject: [PATCH 09/11] fix oracle Signed-off-by: mihaita.tinta --- .../modulith/events/jdbc/JdbcEventPublicationRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java index 9496612ab..116467254 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java @@ -314,7 +314,7 @@ public void markFailed(UUID identifier, Instant failedDate, Throwable exception) var databaseId = uuidToDatabase(identifier); var reason = serializer.serialize(exception); - this.operations.update(sqlStatementInsertFailed, databaseId, failedDate, + this.operations.update(sqlStatementInsertFailed, databaseId, Timestamp.from(failedDate), reason, exception.getClass().getName()); } From 701848e7b8e1b6e15d06b096823d08f6e65e2eff Mon Sep 17 00:00:00 2001 From: "mihaita.tinta" Date: Thu, 4 Dec 2025 16:28:54 +0200 Subject: [PATCH 10/11] fix schemas Signed-off-by: mihaita.tinta --- .../events/jdbc/JdbcEventPublicationRepository.java | 4 ++-- ...dbcEventPublicationRepositoryIntegrationTests.java | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java index 116467254..99d940b60 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepository.java @@ -81,7 +81,7 @@ class JdbcEventPublicationRepository implements EventPublicationRepository, Bean FROM %s e LEFT JOIN %s f on f.EVENT_ID = e.ID WHERE COMPLETION_DATE IS NULL - ORDER BY PUBLICATION_DATE ASC + ORDER BY e.PUBLICATION_DATE ASC, f.FAILED_DATE """; private static final String SQL_STATEMENT_FIND_UNCOMPLETED_BEFORE = """ @@ -92,7 +92,7 @@ class JdbcEventPublicationRepository implements EventPublicationRepository, Bean WHERE e.COMPLETION_DATE IS NULL AND e.PUBLICATION_DATE < ? - ORDER BY e.PUBLICATION_DATE ASC + ORDER BY e.PUBLICATION_DATE ASC, f.FAILED_DATE """; private static final String SQL_STATEMENT_UPDATE_BY_EVENT_AND_LISTENER_ID = """ diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java index f4a9397c9..bb097134f 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/test/java/org/springframework/modulith/events/jdbc/JdbcEventPublicationRepositoryIntegrationTests.java @@ -432,18 +432,19 @@ void findsPublicationsThatFailedOnce() { void findsPublicationsThatFailedTwice() { var first = createPublication(new TestEvent("first")); - Instant now = Instant.now(); + Instant firstTry = Instant.now().minusSeconds(10); + Instant secondTry = Instant.now().minusSeconds(5); IllegalStateException reason1 = new IllegalStateException("failed once"); IllegalStateException reason2 = new IllegalStateException("failed second time"); - var entry1 = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(now, reason1); - var entry2 = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(now, reason2); + var entry1 = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(firstTry, reason1); + var entry2 = new JdbcEventPublicationRepository.JdbcFailedAttemptInfo(secondTry, reason2); doReturn(reason1.toString()).when(serializer).serialize(reason1); doReturn(reason2.toString()).when(serializer).serialize(reason2); doReturn(reason1).when(serializer).deserialize(reason1.toString(), reason1.getClass()); doReturn(reason2).when(serializer).deserialize(reason2.toString(), reason2.getClass()); - repository.markFailed(first.getIdentifier(), now, reason1); - repository.markFailed(first.getIdentifier(), now, reason2); + repository.markFailed(first.getIdentifier(), firstTry, reason1); + repository.markFailed(first.getIdentifier(), secondTry, reason2); assertThat(repository.findIncompletePublications()) .extracting(TargetEventPublication::getFailedAttempts) From 65d2fd686e0620187ae72d7c700a44286735f8fc Mon Sep 17 00:00:00 2001 From: "mihaita.tinta" Date: Thu, 4 Dec 2025 16:40:38 +0200 Subject: [PATCH 11/11] fix typo Signed-off-by: mihaita.tinta --- .../src/main/resources/schema-postgresql.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-postgresql.sql b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-postgresql.sql index 8ee24f560..c3a20d707 100644 --- a/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-postgresql.sql +++ b/spring-modulith-events/spring-modulith-events-jdbc/src/main/resources/schema-postgresql.sql @@ -13,7 +13,7 @@ CREATE INDEX IF NOT EXISTS event_publication_by_completion_date_idx ON event_pub CREATE TABLE IF NOT EXISTS EVENT_FAILED_ATTEMPT_INFO ( EVENT_ID UUID NOT NULL, - FAILED_DATE TIMESTAMP WITH TIME ZONE NOT NULL1, + FAILED_DATE TIMESTAMP WITH TIME ZONE NOT NULL, SERIALIZED_REASON TEXT NOT NULL, REASON_TYPE TEXT NOT NULL, CONSTRAINT FK_FAILED_EVENT_INFO_EVENT