This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git
The following commit(s) were added to refs/heads/master by this push:
new f5b2932ae DurationFormatUtils.formatPeriod walks the calendar one year
per iteration, Long.MAX_VALUE endMillis forces ~292 million Calendar round
trips on one thread (f014).
f5b2932ae is described below
commit f5b2932aeae64c699d25a95188e1c2959d82ea3a
Author: Gary Gregory <[email protected]>
AuthorDate: Sat Sep 5 13:38:33 2026 -0400
DurationFormatUtils.formatPeriod walks the calendar one year per
iteration, Long.MAX_VALUE endMillis forces ~292 million Calendar round
trips on one thread (f014).
---
src/changes/changes.xml | 1 +
.../commons/lang3/time/DurationFormatUtils.java | 56 ++++++++++-----
.../lang3/time/DurationFormatUtilsTest.java | 81 ++++++++++++++++++++++
3 files changed, 120 insertions(+), 18 deletions(-)
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 2eb0909c8..8229b62b3 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -263,6 +263,7 @@ java.lang.NullPointerException: Cannot invoke
<action type="fix" dev="ggregory" due-to="Gary
Gregory">Memoizer: default caches the first failure forever, has no size bound
or eviction, and runs the user computation inside the ConcurrentHashMap bin
lock (blocking unrelated keys, deadlocking reentrant use) (f011).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">StringEscapeUtils.escapeEcmaScript() misses backtick/template-literal
(`, ${) and inline-script parser-state sequences (<!--, <script) - claim
'Deals correctly with quotes' is falsified by ES6 (f012).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">StrSubstitutor (deprecated) recursive expansion has a cycle check but
no fan-out, depth, or size bound (f013).</action>
+ <action type="fix" dev="ggregory" due-to="Gary
Gregory">DurationFormatUtils.formatPeriod walks the calendar one year per
iteration, Long.MAX_VALUE endMillis forces ~292 million Calendar round trips on
one thread (f014).</action>
<!-- ADD -->
<action type="add" dev="ggregory" due-to="Gary
Gregory">Add JavaVersion.JAVA_27.</action>
<action type="add" dev="ggregory" due-to="Gary
Gregory">Add SystemUtils.IS_JAVA_27.</action>
diff --git
a/src/main/java/org/apache/commons/lang3/time/DurationFormatUtils.java
b/src/main/java/org/apache/commons/lang3/time/DurationFormatUtils.java
index 02c5fd946..c544ff61a 100644
--- a/src/main/java/org/apache/commons/lang3/time/DurationFormatUtils.java
+++ b/src/main/java/org/apache/commons/lang3/time/DurationFormatUtils.java
@@ -541,7 +541,9 @@ public static String formatPeriod(final long startMillis,
final long endMillis,
long seconds = end.get(Calendar.SECOND) - start.get(Calendar.SECOND);
long minutes = end.get(Calendar.MINUTE) - start.get(Calendar.MINUTE);
long hours = end.get(Calendar.HOUR_OF_DAY) -
start.get(Calendar.HOUR_OF_DAY);
- long days = end.get(Calendar.DAY_OF_MONTH) -
start.get(Calendar.DAY_OF_MONTH);
+ final boolean calendarUnits = Token.containsTokenWithValue(tokens, y)
|| Token.containsTokenWithValue(tokens, M);
+ // Without years or months, count local dates directly instead of
visiting every intervening month.
+ long days = calendarUnits ? end.get(Calendar.DAY_OF_MONTH) -
start.get(Calendar.DAY_OF_MONTH) : localEpochDay(end) - localEpochDay(start);
long months = end.get(Calendar.MONTH) - start.get(Calendar.MONTH);
long years = end.get(Calendar.YEAR) - start.get(Calendar.YEAR);
// each initial estimate is adjusted in case it is under 0
@@ -561,27 +563,32 @@ public static String formatPeriod(final long startMillis,
final long endMillis,
hours += HOURS_PER_DAY;
days -= 1;
}
- while (days < 0) {
- days += start.getActualMaximum(Calendar.DAY_OF_MONTH);
- months -= 1;
- start.add(Calendar.MONTH, 1);
- }
- while (months < 0) {
- months += 12;
- years -= 1;
- }
- if (!Token.containsTokenWithValue(tokens, y) && years != 0) {
- while (years != 0) {
- months += 12 * years;
- years = 0;
- }
- }
- if (!Token.containsTokenWithValue(tokens, M)) {
- while (months > 0) {
+ if (calendarUnits) {
+ while (days < 0) {
days += start.getActualMaximum(Calendar.DAY_OF_MONTH);
months -= 1;
start.add(Calendar.MONTH, 1);
}
+ while (months < 0) {
+ months += 12;
+ years -= 1;
+ }
+ if (!Token.containsTokenWithValue(tokens, y) && years != 0) {
+ while (years != 0) {
+ months += 12 * years;
+ years = 0;
+ }
+ }
+ if (!Token.containsTokenWithValue(tokens, M)) {
+ while (months > 0) {
+ days += start.getActualMaximum(Calendar.DAY_OF_MONTH);
+ months -= 1;
+ start.add(Calendar.MONTH, 1);
+ }
+ }
+ } else {
+ months = 0;
+ years = 0;
}
// The rest of this code adds in values that
// aren't requested. This allows the user to ask for the
@@ -731,6 +738,19 @@ static Token[] lexx(final String format) {
return list.toArray(Token.EMPTY_ARRAY);
}
+ /**
+ * Computes the local epoch day without overflowing at either end of the
millisecond range.
+ *
+ * @param calendar the calendar to convert.
+ * @return the local epoch day.
+ */
+ private static long localEpochDay(final Calendar calendar) {
+ final long millis = calendar.getTimeInMillis();
+ final long offset = (long) calendar.get(Calendar.ZONE_OFFSET) +
calendar.get(Calendar.DST_OFFSET);
+ return Math.floorDiv(millis, DateUtils.MILLIS_PER_DAY)
+ + Math.floorDiv(Math.floorMod(millis,
DateUtils.MILLIS_PER_DAY) + offset, DateUtils.MILLIS_PER_DAY);
+ }
+
/**
* Converts a {@code long} to a {@link String} with optional zero padding.
*
diff --git
a/src/test/java/org/apache/commons/lang3/time/DurationFormatUtilsTest.java
b/src/test/java/org/apache/commons/lang3/time/DurationFormatUtilsTest.java
index 969793815..bb8025f1a 100644
--- a/src/test/java/org/apache/commons/lang3/time/DurationFormatUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/time/DurationFormatUtilsTest.java
@@ -23,6 +23,7 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Constructor;
@@ -483,6 +484,20 @@ void testFormatPeriodeStartGreaterEnd() {
assertIllegalArgumentException(() ->
DurationFormatUtils.formatPeriod(5000, 2500, "yy/MM"));
}
+ @Test
+ void testFormatPeriodExtremeDatesWithOffsets() {
+ assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {
+ final String format = "d H m s S";
+ final String expected =
DurationFormatUtils.formatDuration(Long.MAX_VALUE, format);
+ for (final String zone : new String[] { "GMT", "GMT+14:00",
"GMT-12:00" }) {
+ final TimeZone timeZone = TimeZones.getTimeZone(zone);
+ assertEquals(expected, DurationFormatUtils.formatPeriod(0,
Long.MAX_VALUE, format, true, timeZone), zone);
+ assertEquals(expected,
DurationFormatUtils.formatPeriod(Long.MIN_VALUE, -1, format, true, timeZone),
zone);
+ assertEquals("213503982334",
DurationFormatUtils.formatPeriod(Long.MIN_VALUE, Long.MAX_VALUE, "d", true,
timeZone), zone);
+ }
+ });
+ }
+
@SuppressWarnings("deprecation")
@Test
void testFormatPeriodISO() {
@@ -567,6 +582,59 @@ void testFormatPeriodLargeFunnelledValue() {
DurationFormatUtils.formatPeriod(0, endMillis, "s", true,
gmt));
}
+ /**
+ * Leap-day boundaries across the year-normalization fast path (values
verified against the previous year-by-year walk).
+ */
+ @Test
+ void testFormatPeriodLeapDayBoundaries() {
+ final TimeZone gmt = TimeZones.getTimeZone("GMT");
+ final Calendar leapDay = Calendar.getInstance(gmt);
+ leapDay.clear();
+ leapDay.set(2000, Calendar.FEBRUARY, 29, 0, 0, 0);
+ final Calendar feb28 = Calendar.getInstance(gmt);
+ feb28.clear();
+ feb28.set(2005, Calendar.FEBRUARY, 28, 0, 0, 0);
+ assertEquals("1826",
DurationFormatUtils.formatPeriod(leapDay.getTimeInMillis(),
feb28.getTimeInMillis(), "d", true, gmt));
+ final Calendar mar1 = Calendar.getInstance(gmt);
+ mar1.clear();
+ mar1.set(2005, Calendar.MARCH, 1, 0, 0, 0);
+ assertEquals("1827",
DurationFormatUtils.formatPeriod(leapDay.getTimeInMillis(),
mar1.getTimeInMillis(), "d", true, gmt));
+ final Calendar mar1999 = Calendar.getInstance(gmt);
+ mar1999.clear();
+ mar1999.set(1999, Calendar.MARCH, 1, 0, 0, 0);
+ assertEquals("2191",
DurationFormatUtils.formatPeriod(mar1999.getTimeInMillis(),
feb28.getTimeInMillis(), "d", true, gmt));
+ }
+
+ @Test
+ void testFormatPeriodLeapDayWithTimeBorrowing() {
+ final TimeZone timeZone = TimeZones.getTimeZone("GMT");
+ final Calendar start = Calendar.getInstance(timeZone);
+ start.clear();
+ start.set(2000, Calendar.FEBRUARY, 29, 23, 59, 59);
+ start.set(Calendar.MILLISECOND, 999);
+ final Calendar end = Calendar.getInstance(timeZone);
+ end.clear();
+ end.set(2005, Calendar.FEBRUARY, 28);
+ assertEquals("1825 0 0 0 001",
DurationFormatUtils.formatPeriod(start.getTimeInMillis(),
end.getTimeInMillis(), "d H m s S", true, timeZone));
+ }
+
+ @Test
+ void testFormatPeriodLocalDaysAcrossDaylightSaving() {
+ final TimeZone timeZone = TimeZones.getTimeZone("America/New_York");
+ final Calendar start = Calendar.getInstance(timeZone);
+ start.clear();
+ start.set(2024, Calendar.MARCH, 9, 12, 0, 0);
+ final Calendar end = (Calendar) start.clone();
+ end.add(Calendar.DAY_OF_MONTH, 1);
+ assertEquals(Duration.ofHours(23).toMillis(), end.getTimeInMillis() -
start.getTimeInMillis());
+ assertEquals("1 0",
DurationFormatUtils.formatPeriod(start.getTimeInMillis(),
end.getTimeInMillis(), "d H", true, timeZone));
+ start.set(2024, Calendar.NOVEMBER, 2, 12, 0, 0);
+ end.setTimeInMillis(start.getTimeInMillis());
+ end.add(Calendar.DAY_OF_MONTH, 1);
+ assertEquals(Duration.ofHours(25).toMillis(), end.getTimeInMillis() -
start.getTimeInMillis());
+ assertEquals("1 0",
DurationFormatUtils.formatPeriod(start.getTimeInMillis(),
end.getTimeInMillis(), "d H", true, timeZone));
+ }
+
@Test
void testFormatPeriodLongRangeBounds() {
// A one-millisecond span sitting at the extremes of the long input
range must still reduce
@@ -576,6 +644,18 @@ void testFormatPeriodLongRangeBounds() {
assertFormatPeriodOneMilli((long) Integer.MIN_VALUE - 1,
Integer.MIN_VALUE);
}
+ /**
+ * The no-y/no-M path used to normalize years by walking the calendar one
year per loop iteration, making the cost linear in the span: this case took ~292
+ * million Calendar round trips. It must complete promptly and produce the
exact day count.
+ */
+ @Test
+ void testFormatPeriodMaximumSpanCompletesQuickly() {
+ final TimeZone gmt = TimeZones.getTimeZone("GMT");
+ // floor(Long.MAX_VALUE / MILLIS_PER_DAY) days from the epoch, with
time-of-day remainders that never borrow.
+ assertTimeoutPreemptively(Duration.ofSeconds(5),
+ () -> assertEquals("106751991167",
DurationFormatUtils.formatPeriod(0, Long.MAX_VALUE, "d", true, gmt)));
+ }
+
@Test
void testFormatPeriodWithoutMonths() {
final TimeZone timeZone = TimeZone.getTimeZone("UTC");
@@ -830,4 +910,5 @@ void testUnmatchedOptionalTokens() {
assertIllegalArgumentException(() ->
DurationFormatUtils.formatDuration(1, "[[s"));
assertIllegalArgumentException(() ->
DurationFormatUtils.formatDuration(1, "[s]]"));
}
+
}