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 (&lt;!--, &lt;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]]"));
     }
+
 }

Reply via email to