GH-806 - Add archive support for JPA.

Co-authored-by: Oliver Drotbohm <oliver.drotbohm@broadcom.com>
This commit is contained in:
Cora Iberkleid
2024-10-24 09:29:00 -04:00
committed by Oliver Drotbohm
parent dc1148624b
commit 6a85fb3350
4 changed files with 344 additions and 209 deletions

View File

@@ -0,0 +1,34 @@
package org.springframework.modulith.events.jpa;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
/**
* JPA entity to represent archived event publications.
*
* @author Oliver Drotbohm
*/
@Entity
@Table(name = "EVENT_PUBLICATION_ARCHIVE")
class ArchivedJpaEventPublication extends JpaEventPublication {
/**
* Creates a new {@link ArchivedJpaEventPublication} for the given publication date, listener id, serialized event and
* event type.
*
* @param id
* @param publicationDate must not be {@literal null}.
* @param listenerId must not be {@literal null} or empty.
* @param serializedEvent must not be {@literal null} or empty.
* @param eventType must not be {@literal null}.
*/
public ArchivedJpaEventPublication(UUID id, Instant publicationDate, String listenerId, String serializedEvent,
Class<?> eventType) {
super(id, publicationDate, listenerId, serializedEvent, eventType);
}
public ArchivedJpaEventPublication() {}
}

View File

@@ -15,10 +15,7 @@
*/
package org.springframework.modulith.events.jpa;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@@ -34,6 +31,7 @@ import org.springframework.util.Assert;
*/
@Entity
@Table(name = "EVENT_PUBLICATION")
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
class JpaEventPublication {
final @Id @Column(length = 16) UUID id;
@@ -68,7 +66,7 @@ class JpaEventPublication {
this.eventType = eventType;
}
JpaEventPublication() {
protected JpaEventPublication() {
this.id = null;
this.publicationDate = null;
@@ -77,9 +75,11 @@ class JpaEventPublication {
this.eventType = null;
}
JpaEventPublication markCompleted() {
ArchivedJpaEventPublication archive(Instant instant) {
this.completionDate = Instant.now();
return this;
var result = new ArchivedJpaEventPublication(id, publicationDate, listenerId, serializedEvent, eventType);
result.completionDate = instant;
return result;
}
}

View File

@@ -38,6 +38,7 @@ import org.springframework.util.Assert;
* @author Oliver Drotbohm
* @author Dmitry Belyaev
* @author Björn Kieling
* @author Cora Iberkleid
*/
@Transactional
class JpaEventPublicationRepository implements EventPublicationRepository {
@@ -53,7 +54,7 @@ class JpaEventPublicationRepository implements EventPublicationRepository {
private static String COMPLETE = """
select p
from JpaEventPublication p
from %s p
where
p.completionDate is not null
order by
@@ -113,14 +114,14 @@ class JpaEventPublicationRepository implements EventPublicationRepository {
private static final String DELETE_COMPLETED = """
delete
from JpaEventPublication p
from %s p
where
p.completionDate is not null
""";
private static final String DELETE_COMPLETED_BEFORE = """
delete
from JpaEventPublication p
from %s p
where
p.completionDate < ?1
""";
@@ -131,6 +132,8 @@ class JpaEventPublicationRepository implements EventPublicationRepository {
private final EventSerializer serializer;
private final CompletionMode completionMode;
private final String getCompleted, deleteCompleted, deleteCompletedBefore;
/**
* Creates a new {@link JpaEventPublicationRepository} for the given {@link EntityManager} and
* {@link EventSerializer}.
@@ -148,7 +151,15 @@ class JpaEventPublicationRepository implements EventPublicationRepository {
this.entityManager = entityManager;
this.serializer = serializer;
this.completionMode = completionMode;
}
var archiveEntityName = completionMode == CompletionMode.ARCHIVE
? ArchivedJpaEventPublication.class.getSimpleName()
: JpaEventPublication.class.getSimpleName();
this.getCompleted = COMPLETE.formatted(archiveEntityName);
this.deleteCompleted = DELETE_COMPLETED.formatted(archiveEntityName);
this.deleteCompletedBefore = DELETE_COMPLETED_BEFORE.formatted(archiveEntityName);
}
/*
* (non-Javadoc)
@@ -179,6 +190,18 @@ class JpaEventPublicationRepository implements EventPublicationRepository {
.setParameter(2, identifierValue)
.executeUpdate();
} else if (completionMode == CompletionMode.ARCHIVE) {
var publication = entityManager.createQuery(BY_EVENT_AND_LISTENER_ID, JpaEventPublication.class)
.setParameter(1, serializedEvent)
.setParameter(2, identifierValue)
.getSingleResult();
var archived = publication.archive(completionDate);
entityManager.remove(publication);
entityManager.persist(archived);
} else {
entityManager.createQuery(MARK_COMPLETED_BY_EVENT_AND_LISTENER_ID)
@@ -202,6 +225,15 @@ class JpaEventPublicationRepository implements EventPublicationRepository {
.setParameter(1, identifier)
.executeUpdate();
} else if (completionMode == CompletionMode.ARCHIVE) {
var publication = entityManager.find(JpaEventPublication.class, identifier);
var archived = publication.archive(completionDate);
entityManager.remove(publication);
entityManager.persist(archived);
} else {
entityManager.createQuery(MARK_COMPLETED_BY_ID)
@@ -260,7 +292,11 @@ class JpaEventPublicationRepository implements EventPublicationRepository {
@Override
public List<TargetEventPublication> findCompletedPublications() {
return entityManager.createQuery(COMPLETE, JpaEventPublication.class)
var type = completionMode == CompletionMode.ARCHIVE
? ArchivedJpaEventPublication.class
: JpaEventPublication.class;
return entityManager.createQuery(getCompleted, type)
.getResultList()
.stream()
.map(this::entityToDomain)
@@ -285,7 +321,7 @@ class JpaEventPublicationRepository implements EventPublicationRepository {
*/
@Override
public void deleteCompletedPublications() {
entityManager.createQuery(DELETE_COMPLETED).executeUpdate();
entityManager.createQuery(deleteCompleted).executeUpdate();
}
/*
@@ -297,7 +333,7 @@ class JpaEventPublicationRepository implements EventPublicationRepository {
Assert.notNull(instant, "Instant must not be null!");
entityManager.createQuery(DELETE_COMPLETED_BEFORE)
entityManager.createQuery(deleteCompletedBefore)
.setParameter(1, instant)
.executeUpdate();
}
@@ -341,6 +377,7 @@ class JpaEventPublicationRepository implements EventPublicationRepository {
private final JpaEventPublication publication;
private final EventSerializer serializer;
private Object deserializedEvent;
/**
* Creates a new {@link JpaEventPublicationAdapter} for the given {@link JpaEventPublication} and
@@ -373,7 +410,12 @@ class JpaEventPublicationRepository implements EventPublicationRepository {
*/
@Override
public Object getEvent() {
return serializer.deserialize(publication.serializedEvent, publication.eventType);
if (deserializedEvent == null) {
this.deserializedEvent = serializer.deserialize(publication.serializedEvent, publication.eventType);
}
return deserializedEvent;
}
/*

View File

@@ -61,9 +61,8 @@ import org.springframework.transaction.annotation.Transactional;
* @author Oliver Drotbohm
* @author Dmitry Belyaev
* @author Björn Kieling
* @author Cora Iberkleid
*/
@SpringBootTest
@Transactional
class JpaEventPublicationRepositoryIntegrationTests {
private static final PublicationTargetIdentifier TARGET_IDENTIFIER = PublicationTargetIdentifier.of("listener");
@@ -112,218 +111,278 @@ class JpaEventPublicationRepositoryIntegrationTests {
}
}
@Autowired JpaEventPublicationRepository repository;
@Autowired EntityManager em;
@Autowired Environment environment;
@SpringBootTest
@Transactional
@ContextConfiguration(classes = TestConfig.class)
static abstract class TestBase {
CompletionMode completionMode;
@Autowired JpaEventPublicationRepository repository;
@Autowired EntityManager em;
@Autowired Environment environment;
@BeforeEach
void init() {
this.completionMode = environment.getProperty(CompletionMode.PROPERTY, CompletionMode.class);
}
CompletionMode completionMode;
@AfterEach
public void flush() {
em.flush();
}
@BeforeEach
void init() {
this.completionMode = environment.getProperty(CompletionMode.PROPERTY, CompletionMode.class);
}
@Test
void persistsJpaEventPublication() {
@AfterEach
public void flush() {
em.flush();
}
var testEvent = new TestEvent("abc");
var serializedEvent = "{\"eventId\":\"abc\"}";
@Test
void persistsJpaEventPublication() {
when(eventSerializer.serialize(testEvent)).thenReturn(serializedEvent);
when(eventSerializer.deserialize(serializedEvent, TestEvent.class)).thenReturn(testEvent);
var testEvent = new TestEvent("abc");
var serializedEvent = "{\"eventId\":\"abc\"}";
var publication = repository.create(TargetEventPublication.of(testEvent, TARGET_IDENTIFIER));
when(eventSerializer.serialize(testEvent)).thenReturn(serializedEvent);
when(eventSerializer.deserialize(serializedEvent, TestEvent.class)).thenReturn(testEvent);
var eventPublications = repository.findIncompletePublications();
var publication = repository.create(TargetEventPublication.of(testEvent, TARGET_IDENTIFIER));
assertThat(eventPublications).hasSize(1);
assertThat(eventPublications.get(0).getEvent()).isEqualTo(publication.getEvent());
assertThat(eventPublications.get(0).getTargetIdentifier()).isEqualTo(publication.getTargetIdentifier());
assertThat(repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER))
.isPresent();
var eventPublications = repository.findIncompletePublications();
repository.markCompleted(publication, Instant.now());
assertThat(eventPublications).hasSize(1);
assertThat(eventPublications.get(0).getEvent()).isEqualTo(publication.getEvent());
assertThat(eventPublications.get(0).getTargetIdentifier()).isEqualTo(publication.getTargetIdentifier());
assertThat(repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER))
.isPresent();
assertThat(repository.findIncompletePublications()).isEmpty();
}
repository.markCompleted(publication, Instant.now());
@Test // GH-25
void shouldTolerateEmptyResult() {
var testEvent = new TestEvent("id");
var serializedEvent = "{\"eventId\":\"id\"}";
when(eventSerializer.serialize(testEvent)).thenReturn(serializedEvent);
assertThat(repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER)).isEmpty();
}
@Test // GH-25
void shouldNotReturnCompletedEvents() {
TestEvent testEvent = new TestEvent("abc");
String serializedEvent = "{\"eventId\":\"abc\"}";
when(eventSerializer.serialize(testEvent)).thenReturn(serializedEvent);
when(eventSerializer.deserialize(serializedEvent, TestEvent.class)).thenReturn(testEvent);
var publication = TargetEventPublication.of(testEvent, TARGET_IDENTIFIER);
repository.create(publication);
repository.markCompleted(publication, Instant.now());
var actual = repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER);
assertThat(actual).isEmpty();
}
@Test // GH-20
void shouldDeleteCompletedEvents() {
var testEvent1 = new TestEvent("abc");
var serializedEvent1 = "{\"eventId\":\"abc\"}";
var testEvent2 = new TestEvent("def");
var serializedEvent2 = "{\"eventId\":\"def\"}";
when(eventSerializer.serialize(testEvent1)).thenReturn(serializedEvent1);
when(eventSerializer.deserialize(serializedEvent1, TestEvent.class)).thenReturn(testEvent1);
when(eventSerializer.serialize(testEvent2)).thenReturn(serializedEvent2);
when(eventSerializer.deserialize(serializedEvent2, TestEvent.class)).thenReturn(testEvent2);
repository.create(TargetEventPublication.of(testEvent1, TARGET_IDENTIFIER));
repository.create(TargetEventPublication.of(testEvent2, TARGET_IDENTIFIER));
repository.markCompleted(testEvent1, TARGET_IDENTIFIER, Instant.now());
repository.deleteCompletedPublications();
assertThat(em.createQuery("select p from JpaEventPublication p", JpaEventPublication.class).getResultList())
.hasSize(1) //
.element(0).extracting(it -> it.serializedEvent).isEqualTo(serializedEvent2);
}
@Test // GH-133
void returnsOldestIncompletePublicationsFirst() {
var now = LocalDateTime.now();
savePublicationAt(now.withHour(3));
savePublicationAt(now.withHour(0));
savePublicationAt(now.withHour(1));
assertThat(repository.findIncompletePublications())
.isSortedAccordingTo(Comparator.comparing(TargetEventPublication::getPublicationDate));
}
@Test // GH-251
void shouldDeleteCompletedEventsBefore() {
assumeTrue(completionMode == CompletionMode.UPDATE);
var testEvent1 = new TestEvent("abc");
var serializedEvent1 = "{\"eventId\":\"abc\"}";
var testEvent2 = new TestEvent("def");
var serializedEvent2 = "{\"eventId\":\"def\"}";
when(eventSerializer.serialize(testEvent1)).thenReturn(serializedEvent1);
when(eventSerializer.deserialize(serializedEvent1, TestEvent.class)).thenReturn(testEvent1);
when(eventSerializer.serialize(testEvent2)).thenReturn(serializedEvent2);
when(eventSerializer.deserialize(serializedEvent2, TestEvent.class)).thenReturn(testEvent2);
repository.create(TargetEventPublication.of(testEvent1, TARGET_IDENTIFIER));
repository.create(TargetEventPublication.of(testEvent2, TARGET_IDENTIFIER));
var now = Instant.now();
repository.markCompleted(testEvent1, TARGET_IDENTIFIER, now.minusSeconds(30));
repository.markCompleted(testEvent2, TARGET_IDENTIFIER, now);
repository.deleteCompletedPublicationsBefore(now.minusSeconds(15));
assertThat(em.createQuery("select p from JpaEventPublication p", JpaEventPublication.class).getResultList())
.hasSize(1) //
.element(0).extracting(it -> it.serializedEvent).isEqualTo(serializedEvent2);
}
@Test // GH-294
void deletesPublicationsByIdentifier() {
var first = createPublication(new TestEvent("first"));
var second = createPublication(new TestEvent("second"));
repository.deletePublications(List.of(first.getIdentifier()));
assertThat(repository.findIncompletePublications())
.hasSize(1)
.element(0)
.matches(it -> it.getIdentifier().equals(second.getIdentifier()))
.matches(it -> it.getEvent().equals(second.getEvent()));
}
@Test // GH-294
void findsPublicationsOlderThanReference() throws Exception {
var first = createPublication(new TestEvent("first"));
Thread.sleep(100);
var now = Instant.now();
var second = createPublication(new TestEvent("second"));
assertThat(repository.findIncompletePublications())
.extracting(TargetEventPublication::getIdentifier)
.containsExactly(first.getIdentifier(), second.getIdentifier());
assertThat(repository.findIncompletePublicationsPublishedBefore(now))
.hasSize(1)
.element(0).extracting(TargetEventPublication::getIdentifier).isEqualTo(first.getIdentifier());
}
@Test // GH-451
void findsCompletedPublications() {
var event = new TestEvent("first");
var publication = createPublication(event);
repository.markCompleted(publication, Instant.now());
if (completionMode == CompletionMode.DELETE) {
assertThat(repository.findCompletedPublications()).isEmpty();
assertThat(repository.findIncompletePublications()).isEmpty();
}
} else {
@Test // GH-25
void shouldTolerateEmptyResult() {
assertThat(repository.findCompletedPublications())
var testEvent = new TestEvent("id");
var serializedEvent = "{\"eventId\":\"id\"}";
when(eventSerializer.serialize(testEvent)).thenReturn(serializedEvent);
assertThat(repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER)).isEmpty();
}
@Test // GH-25
void shouldNotReturnCompletedEvents() {
TestEvent testEvent = new TestEvent("abc");
String serializedEvent = "{\"eventId\":\"abc\"}";
when(eventSerializer.serialize(testEvent)).thenReturn(serializedEvent);
when(eventSerializer.deserialize(serializedEvent, TestEvent.class)).thenReturn(testEvent);
var publication = TargetEventPublication.of(testEvent, TARGET_IDENTIFIER);
repository.create(publication);
repository.markCompleted(publication, Instant.now());
var actual = repository.findIncompletePublicationsByEventAndTargetIdentifier(testEvent, TARGET_IDENTIFIER);
assertThat(actual).isEmpty();
}
@Test // GH-20
void shouldDeleteCompletedEvents() {
var testEvent1 = new TestEvent("abc");
var serializedEvent1 = "{\"eventId\":\"abc\"}";
var testEvent2 = new TestEvent("def");
var serializedEvent2 = "{\"eventId\":\"def\"}";
when(eventSerializer.serialize(testEvent1)).thenReturn(serializedEvent1);
when(eventSerializer.deserialize(serializedEvent1, TestEvent.class)).thenReturn(testEvent1);
when(eventSerializer.serialize(testEvent2)).thenReturn(serializedEvent2);
when(eventSerializer.deserialize(serializedEvent2, TestEvent.class)).thenReturn(testEvent2);
repository.create(TargetEventPublication.of(testEvent1, TARGET_IDENTIFIER));
repository.create(TargetEventPublication.of(testEvent2, TARGET_IDENTIFIER));
repository.markCompleted(testEvent1, TARGET_IDENTIFIER, Instant.now());
repository.deleteCompletedPublications();
assertThat(em.createQuery("select p from JpaEventPublication p", JpaEventPublication.class).getResultList())
.hasSize(1) //
.element(0).extracting(it -> it.serializedEvent).isEqualTo(serializedEvent2);
if (completionMode == CompletionMode.ARCHIVE) {
assertThat(em.createQuery("select p from ArchivedJpaEventPublication p", ArchivedJpaEventPublication.class).getResultList())
.hasSize(0);
}
}
@Test // GH-133
void returnsOldestIncompletePublicationsFirst() {
var now = LocalDateTime.now();
savePublicationAt(now.withHour(3));
savePublicationAt(now.withHour(0));
savePublicationAt(now.withHour(1));
assertThat(repository.findIncompletePublications())
.isSortedAccordingTo(Comparator.comparing(TargetEventPublication::getPublicationDate));
}
@Test // GH-251
void shouldDeleteCompletedEventsBefore() {
assumeFalse(completionMode == CompletionMode.DELETE);
var testEvent1 = new TestEvent("abc");
var serializedEvent1 = "{\"eventId\":\"abc\"}";
var testEvent2 = new TestEvent("def");
var serializedEvent2 = "{\"eventId\":\"def\"}";
when(eventSerializer.serialize(testEvent1)).thenReturn(serializedEvent1);
when(eventSerializer.deserialize(serializedEvent1, TestEvent.class)).thenReturn(testEvent1);
when(eventSerializer.serialize(testEvent2)).thenReturn(serializedEvent2);
when(eventSerializer.deserialize(serializedEvent2, TestEvent.class)).thenReturn(testEvent2);
repository.create(TargetEventPublication.of(testEvent1, TARGET_IDENTIFIER));
repository.create(TargetEventPublication.of(testEvent2, TARGET_IDENTIFIER));
var now = Instant.now();
repository.markCompleted(testEvent1, TARGET_IDENTIFIER, now.minusSeconds(30));
repository.markCompleted(testEvent2, TARGET_IDENTIFIER, now);
repository.deleteCompletedPublicationsBefore(now.minusSeconds(15));
var entityName = completionMode == CompletionMode.ARCHIVE
? ArchivedJpaEventPublication.class.getSimpleName()
: JpaEventPublication.class.getSimpleName();
var type = completionMode == CompletionMode.ARCHIVE
? ArchivedJpaEventPublication.class
: JpaEventPublication.class;
assertThat(em.createQuery("select p from " + entityName + " p", type).getResultList())
.hasSize(1) //
.element(0).extracting(it -> it.serializedEvent).isEqualTo(serializedEvent2);
}
@Test // GH-294
void deletesPublicationsByIdentifier() {
var first = createPublication(new TestEvent("first"));
var second = createPublication(new TestEvent("second"));
repository.deletePublications(List.of(first.getIdentifier()));
assertThat(repository.findIncompletePublications())
.hasSize(1)
.element(0)
.extracting(TargetEventPublication::getEvent)
.isEqualTo(event);
.matches(it -> it.getIdentifier().equals(second.getIdentifier()))
.matches(it -> it.getEvent().equals(second.getEvent()));
}
@Test // GH-294
void findsPublicationsOlderThanReference() throws Exception {
var first = createPublication(new TestEvent("first"));
Thread.sleep(100);
var now = Instant.now();
var second = createPublication(new TestEvent("second"));
assertThat(repository.findIncompletePublications())
.extracting(TargetEventPublication::getIdentifier)
.containsExactly(first.getIdentifier(), second.getIdentifier());
assertThat(repository.findIncompletePublicationsPublishedBefore(now))
.hasSize(1)
.element(0).extracting(TargetEventPublication::getIdentifier).isEqualTo(first.getIdentifier());
}
@Test // GH-451
void findsCompletedPublications() {
var event = new TestEvent("first");
var publication = createPublication(event);
repository.markCompleted(publication, Instant.now());
if (completionMode == CompletionMode.DELETE) {
assertThat(repository.findCompletedPublications()).isEmpty();
assertThat(repository.findIncompletePublications()).isEmpty();
} else {
assertThat(repository.findCompletedPublications())
.hasSize(1)
.element(0)
.extracting(TargetEventPublication::getEvent)
.isEqualTo(event);
}
}
@Test // GH 806
void archivesByEvent() {
assumeTrue(completionMode == CompletionMode.ARCHIVE);
var event = new TestEvent("abc");
var publication = createPublication(event);
repository.markCompleted(publication, Instant.now());
assertThat(repository.findCompletedPublications())
.hasSize(1);
assertThat(em.createQuery("select p from JpaEventPublication p", JpaEventPublication.class).getResultList())
.hasSize(0);
}
@Test // GH 806
void archivesById() {
assumeTrue(completionMode == CompletionMode.ARCHIVE);
var event = new TestEvent("abc");
var publication = createPublication(event);
repository.markCompleted(publication.getIdentifier(), Instant.now());
assertThat(repository.findCompletedPublications())
.hasSize(1);
assertThat(em.createQuery("select p from JpaEventPublication p", JpaEventPublication.class).getResultList())
.hasSize(0);
}
private TargetEventPublication createPublication(Object event) {
var token = event.toString();
doReturn(token).when(eventSerializer).serialize(event);
doReturn(event).when(eventSerializer).deserialize(token, event.getClass());
return repository.create(TargetEventPublication.of(event, TARGET_IDENTIFIER));
}
private void savePublicationAt(LocalDateTime date) {
em.persist(new JpaEventPublication(UUID.randomUUID(), date.toInstant(ZoneOffset.UTC), "", "", Object.class));
}
private record TestEvent(String eventId) {
}
}
@Nested
@ContextConfiguration(classes = TestConfig.class)
class WithUpdateCompletionTests extends TestBase {}
@Nested
@TestPropertySource(properties = CompletionMode.PROPERTY + "=DELETE")
static class WithDeleteCompletionTests extends JpaEventPublicationRepositoryIntegrationTests {
class WithDeleteCompletionTests extends TestBase {}
}
private TargetEventPublication createPublication(Object event) {
var token = event.toString();
doReturn(token).when(eventSerializer).serialize(event);
doReturn(event).when(eventSerializer).deserialize(token, event.getClass());
return repository.create(TargetEventPublication.of(event, TARGET_IDENTIFIER));
}
private void savePublicationAt(LocalDateTime date) {
em.persist(new JpaEventPublication(UUID.randomUUID(), date.toInstant(ZoneOffset.UTC), "", "", Object.class));
}
private record TestEvent(String eventId) {}
@Nested
@TestPropertySource(properties = CompletionMode.PROPERTY + "=ARCHIVE")
class WithArchiveCompletionTests extends TestBase {}
}