Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,21 @@
*/
package org.apache.logging.log4j.core.util;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.text.SimpleDateFormat;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.TimeZone;
import org.assertj.core.presentation.Representation;
import org.assertj.core.presentation.StandardRepresentation;
import org.junit.jupiter.api.Test;

/**
Expand Down Expand Up @@ -171,4 +180,120 @@ void testTimeBeforeMilliseconds() throws Exception {
final Date expected = new GregorianCalendar(2015, 10, 1, 0, 0, 0).getTime();
assertEquals(expected, fireDate, "Dates not equal.");
}
}

/**
* Test that the next valid time after a fallback at 2:00 am from Daylight Saving Time
*/
@Test
void daylightSavingChangeAtTwoAm() throws Exception {
ZoneId zoneId = ZoneId.of("Australia/Sydney");
Representation representation = new ZoneOffsetRepresentation(ZoneOffset.ofHours(11));
// The beginning of the day when daylight saving time ends in Australia in 2025 (switch from UTC+11 to UTC+10).
Instant april5 =
ZonedDateTime.of(2025, 4, 4, 13, 0, 0, 0, ZoneOffset.UTC).toInstant();
Instant april6 = april5.plus(24, ChronoUnit.HOURS);
Instant april7 = april6.plus(25, ChronoUnit.HOURS);

final CronExpression expression = new CronExpression("0 0 0 * * ?");
expression.setTimeZone(TimeZone.getTimeZone(zoneId));
// Check the next valid time after 23:59:59.999 on the day before DST ends.
Date currentTime = Date.from(april6.minusMillis(1));
Instant previousTime = expression.getPrevFireTime(currentTime).toInstant();
assertThat(previousTime).withRepresentation(representation).isEqualTo(april5);
Instant nextTime = expression.getNextValidTimeAfter(currentTime).toInstant();
assertThat(nextTime).withRepresentation(representation).isEqualTo(april6);
// Check the next valid time after 00:00:00.001 on the day DST ends.
currentTime = Date.from(april6.plusMillis(1));
previousTime = expression.getPrevFireTime(currentTime).toInstant();
assertThat(previousTime).withRepresentation(representation).isEqualTo(april6);
nextTime = expression.getNextValidTimeAfter(currentTime).toInstant();
assertThat(nextTime).withRepresentation(representation).isEqualTo(april7);
}

/**
* Test that the next valid time after a fallback at 0:00 am from Daylight Saving Time
*/
@Test
void daylightSavingChangeAtMidnight() throws Exception {
ZoneId zoneId = ZoneId.of("America/Santiago");
Representation representation = new ZoneOffsetRepresentation(ZoneOffset.ofHours(-3));
// The beginning of the day when daylight saving time ends in Chile in 2025 (switch from UTC-3 to UTC-4).
Instant april5 =
ZonedDateTime.of(2025, 4, 5, 3, 0, 0, 0, ZoneOffset.UTC).toInstant();
// Midnight according to Daylight Saving Time.
Instant april6Dst = april5.plus(24, ChronoUnit.HOURS);
// Midnight according to Standard Time.
Instant april6 = april6Dst.plus(1, ChronoUnit.HOURS);
Instant april7 = april6.plus(24, ChronoUnit.HOURS);

final CronExpression expression = new CronExpression("0 0 0 * * ?");
expression.setTimeZone(TimeZone.getTimeZone(zoneId));
// Check the next valid time after 23:59:59.999 DST (22:59.59.999 standard) on the day before DST ends.
Date currentTime = Date.from(april6Dst.minusMillis(1));
Instant previousTime = expression.getPrevFireTime(currentTime).toInstant();
assertThat(previousTime).withRepresentation(representation).isEqualTo(april5);
Instant nextTime = expression.getNextValidTimeAfter(currentTime).toInstant();
assertThat(nextTime).withRepresentation(representation).isEqualTo(april6);
// Check the next valid time after 23:59:59.999 on the day before DST ends.
currentTime = Date.from(april6.minusMillis(1));
previousTime = expression.getPrevFireTime(currentTime).toInstant();
assertThat(previousTime).withRepresentation(representation).isEqualTo(april5);
nextTime = expression.getNextValidTimeAfter(currentTime).toInstant();
assertThat(nextTime).withRepresentation(representation).isEqualTo(april6);
// Check the next valid time after 00:00:00.001 on the day DST ends.
currentTime = Date.from(april6.plusMillis(1));
previousTime = expression.getPrevFireTime(currentTime).toInstant();
assertThat(previousTime).withRepresentation(representation).isEqualTo(april6);
nextTime = expression.getNextValidTimeAfter(currentTime).toInstant();
assertThat(nextTime).withRepresentation(representation).isEqualTo(april7);
}

/**
* Test that the next valid time after a spring forward (23-hour day) is correct.
* Sydney spring-forward: Oct 5 2025 02:00 UTC+10 → 03:00 UTC+11. Day is only 23 hours long.
*/
@Test
void daylightSavingSpringForward() throws Exception {
ZoneId zoneId = ZoneId.of("Australia/Sydney");
Representation representation = new ZoneOffsetRepresentation(ZoneOffset.ofHours(10));
// Midnight UTC+10 on Oct 5 (the spring-forward day; clocks jump at 2AM → day is 23h).
Instant oct5 = ZonedDateTime.of(2025, 10, 4, 14, 0, 0, 0, ZoneOffset.UTC).toInstant();
// Next midnight is only 23 hours later (UTC+11 offset after the jump).
Instant oct6 = oct5.plus(23, ChronoUnit.HOURS);
Instant oct7 = oct6.plus(24, ChronoUnit.HOURS);

final CronExpression expression = new CronExpression("0 0 0 * * ?");
expression.setTimeZone(TimeZone.getTimeZone(zoneId));

// Check the next valid time after 23:59:59.999 on the spring-forward day.
Date currentTime = Date.from(oct6.minusMillis(1));
Instant previousTime = expression.getPrevFireTime(currentTime).toInstant();
assertThat(previousTime).withRepresentation(representation).isEqualTo(oct5);
Instant nextTime = expression.getNextValidTimeAfter(currentTime).toInstant();
assertThat(nextTime).withRepresentation(representation).isEqualTo(oct6);

// Check the next valid time after 00:00:00.001 on the day after spring-forward.
currentTime = Date.from(oct6.plusMillis(1));
previousTime = expression.getPrevFireTime(currentTime).toInstant();
assertThat(previousTime).withRepresentation(representation).isEqualTo(oct6);
nextTime = expression.getNextValidTimeAfter(currentTime).toInstant();
assertThat(nextTime).withRepresentation(representation).isEqualTo(oct7);
}

private static class ZoneOffsetRepresentation extends StandardRepresentation {

private final ZoneOffset zoneOffset;

private ZoneOffsetRepresentation(final ZoneOffset zoneOffset) {
this.zoneOffset = zoneOffset;
}

@Override
public String toStringOf(final Object object) {
if (object instanceof Instant) {
return ZonedDateTime.ofInstant((Instant) object, zoneOffset).toString();
}
return super.toStringOf(object);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,11 @@ protected Date getTimeBefore(final Date targetDate) {
}

public Date getPrevFireTime(final Date targetDate) {
// Cron expressions have second precision. If the input has a millisecond fraction,
// include fire times at that same wall-clock second by shifting into the next second.
if (targetDate.getTime() % 1000 != 0) {
return getTimeBefore(new Date(targetDate.getTime() + 999));
}
return getTimeBefore(targetDate);
}

Expand All @@ -1610,7 +1615,9 @@ private long findMinIncrement() {
} else if (hours.first() == ALL_SPEC_INT) {
return 3600000;
}
return 86400000;
// DST spring-forward days can be 23 hours, so using 24 hours here can skip
// a valid previous fire time around midnight transitions.
return 23L * 60L * 60L * 1000L;
}

private int minInSet(final TreeSet<Integer> set) {
Expand Down