From 5b20255fab1d91a08e92894ed179b8c70dc04c42 Mon Sep 17 00:00:00 2001 From: John-Paul Cunliffe Date: Sun, 17 May 2026 20:37:06 +0200 Subject: [PATCH] GH-1688 - Add seconds and hours to moment granularity Signed-off-by: John-Paul Cunliffe --- .../modulith/moments/MinuteHasPassed.java | 92 +++++++++++++++++ .../modulith/moments/SecondHasPassed.java | 92 +++++++++++++++++ .../modulith/moments/support/Moments.java | 68 +++++++++++-- .../moments/support/MomentsProperties.java | 35 ++++++- .../moments/MinuteHasPassedUnitTests.java | 53 ++++++++++ .../moments/SecondHasPassedUnitTests.java | 53 ++++++++++ .../moments/support/MomentsUnitTests.java | 98 +++++++++++++++++++ .../antora/modules/ROOT/pages/moments.adoc | 4 +- 8 files changed, 484 insertions(+), 11 deletions(-) create mode 100644 spring-modulith-moments/src/main/java/org/springframework/modulith/moments/MinuteHasPassed.java create mode 100644 spring-modulith-moments/src/main/java/org/springframework/modulith/moments/SecondHasPassed.java create mode 100644 spring-modulith-moments/src/test/java/org/springframework/modulith/moments/MinuteHasPassedUnitTests.java create mode 100644 spring-modulith-moments/src/test/java/org/springframework/modulith/moments/SecondHasPassedUnitTests.java diff --git a/spring-modulith-moments/src/main/java/org/springframework/modulith/moments/MinuteHasPassed.java b/spring-modulith-moments/src/main/java/org/springframework/modulith/moments/MinuteHasPassed.java new file mode 100644 index 000000000..711559f9c --- /dev/null +++ b/spring-modulith-moments/src/main/java/org/springframework/modulith/moments/MinuteHasPassed.java @@ -0,0 +1,92 @@ +/* + * Copyright 2022-2026 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.moments; + +import java.time.LocalDateTime; +import java.util.Objects; + +import org.jmolecules.event.types.DomainEvent; +import org.springframework.util.Assert; + +/** + * A {@link DomainEvent} published on each minute. + * + * @author John Cunliffe + */ +public class MinuteHasPassed implements DomainEvent { + + /** + * The minute that has just passed. + */ + private final LocalDateTime time; + + /** + * Creates a new {@link MinuteHasPassed} for the given {@link LocalDateTime}. + * + * @param time must not be {@literal null}. + */ + private MinuteHasPassed(LocalDateTime time) { + + Assert.notNull(time, "LocalDateTime must not be null!"); + + this.time = time; + } + + /** + * Creates a new {@link MinuteHasPassed} for the given {@link LocalDateTime}. + * + * @param time must not be {@literal null}. + */ + public static MinuteHasPassed of(LocalDateTime time) { + return new MinuteHasPassed(time); + } + + /** + * The minute that has just passed. + * + * @return will never be {@literal null}. + */ + public LocalDateTime getTime() { + return time; + } + + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof MinuteHasPassed that)) { + return false; + } + + return Objects.equals(time, that.time); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + return Objects.hash(time); + } +} diff --git a/spring-modulith-moments/src/main/java/org/springframework/modulith/moments/SecondHasPassed.java b/spring-modulith-moments/src/main/java/org/springframework/modulith/moments/SecondHasPassed.java new file mode 100644 index 000000000..943caf9db --- /dev/null +++ b/spring-modulith-moments/src/main/java/org/springframework/modulith/moments/SecondHasPassed.java @@ -0,0 +1,92 @@ +/* + * Copyright 2022-2026 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.moments; + +import java.time.LocalDateTime; +import java.util.Objects; + +import org.jmolecules.event.types.DomainEvent; +import org.springframework.util.Assert; + +/** + * A {@link DomainEvent} published on each second. + * + * @author John Cunliffe + */ +public class SecondHasPassed implements DomainEvent { + + /** + * The second that has just passed. + */ + private final LocalDateTime time; + + /** + * Creates a new {@link SecondHasPassed} for the given {@link LocalDateTime}. + * + * @param time must not be {@literal null}. + */ + private SecondHasPassed(LocalDateTime time) { + + Assert.notNull(time, "LocalDateTime must not be null!"); + + this.time = time; + } + + /** + * Creates a new {@link SecondHasPassed} for the given {@link LocalDateTime}. + * + * @param time must not be {@literal null}. + */ + public static SecondHasPassed of(LocalDateTime time) { + return new SecondHasPassed(time); + } + + /** + * The second that has just passed. + * + * @return will never be {@literal null}. + */ + public LocalDateTime getTime() { + return time; + } + + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof SecondHasPassed that)) { + return false; + } + + return Objects.equals(time, that.time); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + return Objects.hash(time); + } +} diff --git a/spring-modulith-moments/src/main/java/org/springframework/modulith/moments/support/Moments.java b/spring-modulith-moments/src/main/java/org/springframework/modulith/moments/support/Moments.java index ddec87cbe..1299460ab 100644 --- a/spring-modulith-moments/src/main/java/org/springframework/modulith/moments/support/Moments.java +++ b/spring-modulith-moments/src/main/java/org/springframework/modulith/moments/support/Moments.java @@ -30,8 +30,10 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.modulith.moments.DayHasPassed; import org.springframework.modulith.moments.HourHasPassed; +import org.springframework.modulith.moments.MinuteHasPassed; import org.springframework.modulith.moments.MonthHasPassed; import org.springframework.modulith.moments.QuarterHasPassed; +import org.springframework.modulith.moments.SecondHasPassed; import org.springframework.modulith.moments.ShiftedQuarter; import org.springframework.modulith.moments.WeekHasPassed; import org.springframework.modulith.moments.YearHasPassed; @@ -42,6 +44,7 @@ * Core component to publish passage-of-time events. * * @author Oliver Drotbohm + * @author John Cunliffe */ public class Moments implements Now { @@ -72,6 +75,28 @@ public Moments(Clock clock, ApplicationEventPublisher events, MomentsProperties this.properties = properties; } + /** + * Triggers event publication every second. + */ + @Scheduled(cron = "* * * * * *") + void everySecond() { + + if (properties.isSecondly()) { + emitSecondEventFor(now().minusSeconds(1).truncatedTo(ChronoUnit.SECONDS)); + } + } + + /** + * Triggers event publication every minute. + */ + @Scheduled(cron = "0 * * * * *") + void everyMinute() { + + if (properties.isMinutely()) { + emitMinuteEventFor(now().minusMinutes(1).truncatedTo(ChronoUnit.MINUTES)); + } + } + /** * Triggers event publication every hour. */ @@ -102,15 +127,36 @@ Moments shiftBy(Duration duration) { return this; } - LocalDateTime current = before.truncatedTo(ChronoUnit.HOURS); + boolean secondly = properties.isSecondly(); + boolean minutely = properties.isMinutely(); boolean hourly = properties.isHourly(); - while (current.isBefore(after.truncatedTo(ChronoUnit.HOURS))) { - - LocalDateTime next = hourly ? current.plusHours(1) : current.plusDays(1); - - if (hourly) { - emitEventsFor(next); + ChronoUnit step = secondly ? ChronoUnit.SECONDS + : minutely ? ChronoUnit.MINUTES + : ChronoUnit.HOURS; + + LocalDateTime current = before.truncatedTo(step); + LocalDateTime stop = after.truncatedTo(step); + + while (current.isBefore(stop)) { + + LocalDateTime next = current.plus(1, step); + + if (secondly) { + emitSecondEventFor(next.minusSeconds(1).truncatedTo(ChronoUnit.SECONDS)); + if (current.getMinute() != next.getMinute()) { + emitMinuteEventFor(current.truncatedTo(ChronoUnit.MINUTES)); + } + if (current.getHour() != next.getHour()) { + emitEventsFor(current.truncatedTo(ChronoUnit.HOURS)); + } + } else if (minutely) { + emitMinuteEventFor(next.minusMinutes(1).truncatedTo(ChronoUnit.MINUTES)); + if (current.getHour() != next.getHour()) { + emitEventsFor(current.truncatedTo(ChronoUnit.HOURS)); + } + } else if (hourly) { + emitEventsFor(current); } if (current.toLocalDate().isBefore(next.toLocalDate())) { @@ -161,6 +207,14 @@ private void emitEventsFor(LocalDateTime time) { events.publishEvent(HourHasPassed.of(time.truncatedTo(ChronoUnit.HOURS))); } + private void emitMinuteEventFor(LocalDateTime time) { + events.publishEvent(MinuteHasPassed.of(time.truncatedTo(ChronoUnit.MINUTES))); + } + + private void emitSecondEventFor(LocalDateTime time) { + events.publishEvent(SecondHasPassed.of(time.truncatedTo(ChronoUnit.SECONDS))); + } + private void emitEventsFor(LocalDate date) { // Day has passed diff --git a/spring-modulith-moments/src/main/java/org/springframework/modulith/moments/support/MomentsProperties.java b/spring-modulith-moments/src/main/java/org/springframework/modulith/moments/support/MomentsProperties.java index c4cb8f17a..52fc7b868 100644 --- a/spring-modulith-moments/src/main/java/org/springframework/modulith/moments/support/MomentsProperties.java +++ b/spring-modulith-moments/src/main/java/org/springframework/modulith/moments/support/MomentsProperties.java @@ -35,6 +35,7 @@ * Configuration properties for {@link Moments}. * * @author Oliver Drotbohm + * @author John Cunliffe */ @ConfigurationProperties(prefix = "spring.modulith.moments") public class MomentsProperties { @@ -135,7 +136,21 @@ public boolean isEnableTimeMachine() { * Returns whether to create hourly events. */ boolean isHourly() { - return Granularity.HOURS.equals(granularity); + return granularity.isAtLeastAsFineAs(Granularity.HOURS); + } + + /** + * Returns whether to create minutely events. + */ + boolean isMinutely() { + return granularity.isAtLeastAsFineAs(Granularity.MINUTES); + } + + /** + * Returns whether to create per-second events. + */ + boolean isSecondly() { + return granularity.isAtLeastAsFineAs(Granularity.SECONDS); } /** @@ -164,12 +179,24 @@ MomentsProperties withLocale(Locale locale) { } /** - * The granularity of events to publish. + * The granularity of events to publish. Ordered finest-first: an entry is "at least as fine as" any + * later entry, so {@code SECONDS} fires every supported event, {@code DAYS} fires only the daily and + * coarser ones. * * @author Oliver Drotbohm */ static enum Granularity { + /** + * Publish per-second events. Will include minute, hourly and daily events. + */ + SECONDS, + + /** + * Publish per-minute events. Will include hourly and daily events. + */ + MINUTES, + /** * Publish hourly events. Will include daily events. */ @@ -179,6 +206,10 @@ static enum Granularity { * Publish daily events only. */ DAYS; + + boolean isAtLeastAsFineAs(Granularity other) { + return this.ordinal() <= other.ordinal(); + } } private static class ShiftedQuarters { diff --git a/spring-modulith-moments/src/test/java/org/springframework/modulith/moments/MinuteHasPassedUnitTests.java b/spring-modulith-moments/src/test/java/org/springframework/modulith/moments/MinuteHasPassedUnitTests.java new file mode 100644 index 000000000..bdc2db6de --- /dev/null +++ b/spring-modulith-moments/src/test/java/org/springframework/modulith/moments/MinuteHasPassedUnitTests.java @@ -0,0 +1,53 @@ +/* + * Copyright 2022-2026 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.moments; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link MinuteHasPassed}. + */ +class MinuteHasPassedUnitTests { + + @Test + void exposesProvidedTime() { + + LocalDateTime time = LocalDateTime.of(2026, 5, 1, 12, 30); + + assertThat(MinuteHasPassed.of(time).getTime()).isEqualTo(time); + } + + @Test + void rejectsNullTime() { + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> MinuteHasPassed.of(null)); + } + + @Test + void instancesWithSameTimeAreEqual() { + + LocalDateTime time = LocalDateTime.of(2026, 5, 1, 12, 30); + + assertThat(MinuteHasPassed.of(time)) + .isEqualTo(MinuteHasPassed.of(time)) + .hasSameHashCodeAs(MinuteHasPassed.of(time)); + } +} diff --git a/spring-modulith-moments/src/test/java/org/springframework/modulith/moments/SecondHasPassedUnitTests.java b/spring-modulith-moments/src/test/java/org/springframework/modulith/moments/SecondHasPassedUnitTests.java new file mode 100644 index 000000000..6c7946cd0 --- /dev/null +++ b/spring-modulith-moments/src/test/java/org/springframework/modulith/moments/SecondHasPassedUnitTests.java @@ -0,0 +1,53 @@ +/* + * Copyright 2022-2026 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.moments; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link SecondHasPassed}. + */ +class SecondHasPassedUnitTests { + + @Test + void exposesProvidedTime() { + + LocalDateTime time = LocalDateTime.of(2026, 5, 1, 12, 30, 45); + + assertThat(SecondHasPassed.of(time).getTime()).isEqualTo(time); + } + + @Test + void rejectsNullTime() { + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> SecondHasPassed.of(null)); + } + + @Test + void instancesWithSameTimeAreEqual() { + + LocalDateTime time = LocalDateTime.of(2026, 5, 1, 12, 30, 45); + + assertThat(SecondHasPassed.of(time)) + .isEqualTo(SecondHasPassed.of(time)) + .hasSameHashCodeAs(SecondHasPassed.of(time)); + } +} diff --git a/spring-modulith-moments/src/test/java/org/springframework/modulith/moments/support/MomentsUnitTests.java b/spring-modulith-moments/src/test/java/org/springframework/modulith/moments/support/MomentsUnitTests.java index 65526e487..101bd5d00 100644 --- a/spring-modulith-moments/src/test/java/org/springframework/modulith/moments/support/MomentsUnitTests.java +++ b/spring-modulith-moments/src/test/java/org/springframework/modulith/moments/support/MomentsUnitTests.java @@ -21,6 +21,7 @@ import java.time.Clock; import java.time.Duration; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.Year; @@ -34,8 +35,10 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.modulith.moments.DayHasPassed; import org.springframework.modulith.moments.HourHasPassed; +import org.springframework.modulith.moments.MinuteHasPassed; import org.springframework.modulith.moments.MonthHasPassed; import org.springframework.modulith.moments.QuarterHasPassed; +import org.springframework.modulith.moments.SecondHasPassed; import org.springframework.modulith.moments.ShiftedQuarter; import org.springframework.modulith.moments.WeekHasPassed; import org.springframework.modulith.moments.YearHasPassed; @@ -45,6 +48,7 @@ * Unit tests for {@link Moments}. * * @author Oliver Drotbohm + * @author John Cunliffe */ class MomentsUnitTests { @@ -55,6 +59,8 @@ class MomentsUnitTests { Moments hourly = new Moments(clock, events, MomentsProperties.DEFAULTS); Moments daily = new Moments(clock, events, MomentsProperties.DEFAULTS.withGranularity(Granularity.DAYS)); + Moments minutely = new Moments(clock, events, MomentsProperties.DEFAULTS.withGranularity(Granularity.MINUTES)); + Moments secondly = new Moments(clock, events, MomentsProperties.DEFAULTS.withGranularity(Granularity.SECONDS)); @Test void emitsHourlyEventOnTimeShift() { @@ -210,4 +216,96 @@ private Duration getNumberOfDaysForThreeMonth(LocalDate date) { return Duration.ofDays(days); } + + @Test + void emitsMinutelyEventOnTimeShift() { + + minutely.shiftBy(Duration.ofMinutes(5)); + + verify(events, times(5)).publishEvent(any(MinuteHasPassed.class)); + verify(events, never()).publishEvent(any(SecondHasPassed.class)); + } + + @Test + void minutelyShiftAcrossHourEmitsHourEvent() { + + minutely.shiftBy(Duration.ofMinutes(61)); + + verify(events, times(61)).publishEvent(any(MinuteHasPassed.class)); + verify(events, atLeastOnce()).publishEvent(any(HourHasPassed.class)); + } + + @Test + void emitsSecondlyEventOnTimeShift() { + + secondly.shiftBy(Duration.ofSeconds(3)); + + verify(events, times(3)).publishEvent(any(SecondHasPassed.class)); + } + + @Test + void secondlyShiftAcrossMinuteEmitsMinuteEvent() { + + secondly.shiftBy(Duration.ofSeconds(61)); + + verify(events, times(61)).publishEvent(any(SecondHasPassed.class)); + verify(events, atLeastOnce()).publishEvent(any(MinuteHasPassed.class)); + } + + @Test + void hourlyDoesNotEmitMinuteOrSecondEvents() { + + hourly.shiftBy(Duration.ofHours(2)); + + verify(events, never()).publishEvent(any(MinuteHasPassed.class)); + verify(events, never()).publishEvent(any(SecondHasPassed.class)); + } + + @Test + void everyMinuteEmitsOnlyWhenGranularityIsMinutesOrFiner() { + + minutely.everyMinute(); + verify(events, times(1)).publishEvent(any(MinuteHasPassed.class)); + + reset(events); + + hourly.everyMinute(); + verify(events, never()).publishEvent(any(MinuteHasPassed.class)); + } + + @Test + void everySecondEmitsOnlyWhenGranularityIsSeconds() { + + secondly.everySecond(); + verify(events, times(1)).publishEvent(any(SecondHasPassed.class)); + + reset(events); + + minutely.everySecond(); + verify(events, never()).publishEvent(any(SecondHasPassed.class)); + } + + @Test // GH-1688 + void hourlyShiftEmitsHourHasPassedForThePassedHour() { + + var clock = Clock.fixed(Instant.parse("2026-05-17T10:00:00Z"), ZoneOffset.UTC); + var moments = new Moments(clock, events, MomentsProperties.DEFAULTS); + + moments.shiftBy(Duration.ofHours(1)); + + verify(events).publishEvent(HourHasPassed.of(LocalDateTime.of(2026, 5, 17, 10, 0))); + } + + @Test // GH-1688 + void shiftEmitsEachEventForThePeriodThatActuallyPassed() { + + var clock = Clock.fixed(Instant.parse("2026-05-17T10:59:59Z"), ZoneOffset.UTC); + var moments = new Moments(clock, events, MomentsProperties.DEFAULTS.withGranularity(Granularity.SECONDS)); + + moments.shiftBy(Duration.ofSeconds(1)); + + verify(events).publishEvent(SecondHasPassed.of(LocalDateTime.of(2026, 5, 17, 10, 59, 59))); + verify(events).publishEvent(MinuteHasPassed.of(LocalDateTime.of(2026, 5, 17, 10, 59))); + verify(events).publishEvent(HourHasPassed.of(LocalDateTime.of(2026, 5, 17, 10, 0))); + } } diff --git a/src/docs/antora/modules/ROOT/pages/moments.adoc b/src/docs/antora/modules/ROOT/pages/moments.adoc index 0bed6843f..314f4b378 100644 --- a/src/docs/antora/modules/ROOT/pages/moments.adoc +++ b/src/docs/antora/modules/ROOT/pages/moments.adoc @@ -30,7 +30,7 @@ dependencies { The dependency added to the project's classpath causes the following things in your application: -* Application code can refer to `HourHasPassed`, `DayHasPassed`, `WeekHasPassed`, `MonthHasPassed`, `QuarterHasPassed`, `YearHasPassed` types in Spring event listeners to get notified if a certain amount of time has passed. +* Application code can refer to `SecondHasPassed`, `MinuteHasPassed`, `HourHasPassed`, `DayHasPassed`, `WeekHasPassed`, `MonthHasPassed`, `QuarterHasPassed`, `YearHasPassed` types in Spring event listeners to get notified if a certain amount of time has passed. * A bean of type `org.springframework.modulith.Moments` is available in the `ApplicationContext` that contains the logic to trigger these events. * If `spring.modulith.moments.enable-time-machine` is set to `true`, that instance will be a `org.springframework.modulith.TimeMachine` which allows to "shift" time and by that triggers all intermediate events, which is useful to integration test functionality that is triggered by the events. @@ -73,7 +73,7 @@ Moments exposes the following application properties for advanced customization: |=== |Property|Default value|Description |`spring.modulith.moments.enable-time-machine`|false|If set to `true`, the `Moments` instance will be a `TimeMachine`, that exposes API to shift time forward. Useful for integration tests that expect functionality triggered by the Passage of Time Events. -|`spring.modulith.moments.granularity`|hours|The minimum granularity of events to be fired. Alternative value `days` to avoid hourly events. +|`spring.modulith.moments.granularity`|hours|The minimum granularity of events to be fired. One of `seconds`, `minutes`, `hours`, `days`. Finer-grained values include all coarser-grained events. |`spring.modulith.moments.locale`|`Locale.getDefault()`|The `Locale` to use when determining week boundaries. |`spring.modulith.moments.quarter-start-month`|`Months.JANUARY`|The month at which quarters start. |`spring.modulith.moments.zone-id`|`ZoneOffset#UTC`|The `ZoneId` to determine times which are attached to the events published.