All findings · Lucene finding · LFV-1123 · datetools-round-floor-below-long-min-wraps

DateTools.round(Long.MIN_VALUE, DAY) returns a time in AD 292278994 instead of rounding down

For Long.MIN_VALUE, the usual "no lower bound" value, the start of its day lies below the long range, and DateTools.round(Long.MIN_VALUE, DAY) silently returns 9223372036794351616, a time in AD 292278994. A date range whose lower bound is rounded this way matches nothing.

Summary

Priority
Low, rank 538DateTools.round returns a time near Long.MAX_VALUE, with no error, for Long.MIN_VALUE and for times before the first resolution-unit boundary above it, so a range lower bound built from it matches nothing, in every release since 1.9. Real dates never get that close, so only code that rounds the Long.MIN_VALUE open-bound sentinel hits it, a rare boundary case.
Impact
minor
Since
1.9 (2006; the first DateTools commit, 2004)introduced 2004-09-05 · over 22 years
Affected
every release from 1.9 to 10.6.0 (13 run) and main; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of DateTools's contract, witnessed by an executable repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Diagram of the bug: the long range drawn as a ring from Long.MIN_VALUE (292269055 BC, Dec 2, 16:47:04.192) to Long.MAX_VALUE (AD 292278994, Aug 17, 07:12:55.807). Rounding Long.MIN_VALUE down to the start of its day needs a value 60,424,192 ms below Long.MIN_VALUE, which does not exist; the calendar's long arithmetic wraps it to 9223372036794351616, 16.8 hours before Long.MAX_VALUE, so the rounded time is later than every real date.
round(Long.MIN_VALUE, DAY): the start of that day lies 16.8 hours below the smallest long, and the calendar's arithmetic wraps it to 16.8 hours before the largest one, in AD 292278994. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You round the open lower bound of a date range: DateTools.round(Long.MIN_VALUE, Resolution.DAY). Long.MIN_VALUE is the value LongPoint's javadoc suggests for a half-open range; as a date it is 292269055 BC, Dec 2, at 16:47:04.192 in GMT.
  2. You expect a result no later than the input. The start of that day is 16.8 hours (60,424,192 ms) earlier, below the long range, so Long.MIN_VALUE itself is the closest possible answer.
  3. round sets hour, minute, second and millisecond to 0 on a lenient GregorianCalendar and reads the time back with getTimeInMillis(). The value below Long.MIN_VALUE wraps around by 2^64, unchecked.
  4. You get 9223372036794351616 (AD 292278994-08-16 14:25:51.616), 16.8 hours before Long.MAX_VALUE, with no error. A LongPoint range that found 1 document with Long.MIN_VALUE as its lower bound finds 0 with the rounded one.
  5. YEAR to SECOND do the same for any time before the first year, month, day, hour, minute or second boundary above Long.MIN_VALUE (up to 29.3 days for YEAR and MONTH, 7.2 h for DAY, down to 808 ms for SECOND); MILLISECOND never does. Real dates are never that close.

Who is affected

Only code that calls DateTools.round(long|Date, Resolution) directly with Long.MIN_VALUE (or new Date(Long.MIN_VALUE)) as a "no lower bound" sentinel, or a time within one unit above it. No setting is involved, and every release since 1.9 has it. Lucene's own string methods (timeToString, and dateToString through it) give wrong strings for such dates anyway (LFV-645).

Mechanism, terms and numbers377 words

DateTools.round(long, Resolution) and round(Date, Resolution) promise the input with every field finer than the resolution set to 0 or 1, so the result is never later than the input. For Long.MIN_VALUE (292269055 BC, Dec 2, at 16:47:04.192 in GMT) the start of its day lies 60,424,192 ms below the long range. round sets the fields on a lenient GregorianCalendar and returns getTimeInMillis() (DateTools.java:146-171 at 5adff78bb168), whose long arithmetic wraps by 2^64: round(Long.MIN_VALUE, DAY) is 9223372036794351616, AD 292278994-08-16 14:25:51.616, with no error and the same result with assertions on and off. YEAR to SECOND wrap the same way for every input before the first year, month, day, hour, minute or second boundary above Long.MIN_VALUE (up to 29.3 days for YEAR and MONTH, 7.2 h for DAY, down to 808 ms for SECOND). A LongPoint range whose open lower bound is rounded this way matches nothing. Every release since 1.9 (2006) has it.

The mechanism (main 5adff78bb168, lucene/core/src/java/org/apache/lucene/document/DateTools.java)

  1. A lenient calendar. round uses a per-thread Calendar.getInstance(GMT, Locale.ROOT) (:46-52), a GregorianCalendar, lenient by default, and loads the input with setTimeInMillis(time) (:148). The fields come out correct: BC 292269055, Dec 2, 16:47:04.192.
  2. Fields cleared. A deliberate fall-through switch sets every field finer than the resolution to 0 or 1 (:150-168); for DAY: hour, minute, second and millisecond.
  3. The read-back wraps. return calInstance.getTimeInMillis(); (:170, the fix site) recomputes the time from the fields. The true value, -9223372036915200000, is below Long.MIN_VALUE; the calendar's long arithmetic wraps by 2^64 and nothing checks it. Math.floorDiv(Long.MIN_VALUE, 86400000) * 86400000 gives the same value.
  4. Both overloads. round(Date, Resolution) is new Date(round(date.getTime(), resolution)) (:132-134).

The numbers

resolutionround(Long.MIN_VALUE, r)wraps for inputs up to Long.MIN_VALUE +
YEAR92233720078503516162,531,575,807 ms (about 29.3 days)
MONTH92233720367079516162,531,575,807 ms
DAY922337203679435161625,975,807 ms (about 7.2 h)
HOUR9223372036851951616775,807 ms
MINUTE922337203685477161655,807 ms
SECOND9223372036854775616807 ms
MILLISECOND-9223372036854775808 (correct)never

The one-test repro checks only DAY at Long.MIN_VALUE; the other rows, the windows and the facts below come from separate probes (sources). The first input after each window rounds correctly. The wrapped value is not even rounded (DAY gives 14:25:51.616), and rounding it again gives yet another value (9223372036742400000).

Exposure and likelihood188 words
  • Who. Code that rounds the Long.MIN_VALUE sentinel, for example a helper that aligns both ends of a LongPoint date range to a resolution after filling an open start with Long.MIN_VALUE, the form LongPoint.newRangeQuery's javadoc suggests (LongPoint.java:202-203). The same sentinel-into-date-rounding slip happened in Elasticsearch's own code (elastic/elasticsearch #138757, #142725), so the pattern is real, though not in Lucene.
  • Effect. A silently wrong value: the lower bound becomes later than every real date. With one document dated 2004-09-21, LongPoint.newRangeQuery("date", Long.MIN_VALUE, day) counts 1 hit and the same query with the rounded bound counts 0.
  • Default vs opt-in. A plain public static utility; no setting.
  • Not hit. Real dates: the windows lie within 29.3 days of 292 million BC. Lucene's only production caller of round is timeToString (DateTools.java:91; dateToString reaches it through timeToString, :79). There the wrap shows as a far-future string, timeToString(Long.MIN_VALUE, DAY) = "2922789940816" (year 292278994); without the wrap the string would still be wrong for LFV-645's reason (years outside AD 1..9999), so the effect that matters is for code that calls round directly.
  • Scale. One call; no data, no concurrency.

Minimal repro

One JUnit test method on the public API: one call, DateTools.round(Long.MIN_VALUE, DAY), and one assertion that the result is not later than the input. Deterministic, single thread; it fails with assertions on and off.

TestDateToolsRoundNearLongMin.java (lucene/core, package org.apache.lucene.document, 1 test)28 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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
 *
 *     http://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.apache.lucene.document;

import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDateToolsRoundNearLongMin extends LuceneTestCase {

  public void testRoundNeverMovesTimeForward() {
    // expected: a result no later than the input; actual: 9223372036794351616 (AD 292278994)
    long rounded = DateTools.round(Long.MIN_VALUE, DateTools.Resolution.DAY);
    assertTrue("round(Long.MIN_VALUE, DAY) = " + rounded, rounded <= Long.MIN_VALUE);
  }
}
Command
cp $REPRO/TestDateToolsRoundNearLongMin.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.document.TestDateToolsRoundNearLongMin -Ptests.seed=DEADBEEF
# same failure with -Ptests.asserts=false
Expected
round(Long.MIN_VALUE, DAY) is no later than the input (<= Long.MIN_VALUE).
Observed
AssertionError: round(Long.MIN_VALUE, DAY) = 9223372036794351616, with assertions on and off.

All files of this repro: repro/LFV-1123/

More files, environment, full output151 words

TestDateToolsRoundNearLongMin (one method, extends LuceneTestCase, package org.apache.lucene.document of the lucene/core tests). It passes checkGoogleJavaFormat and carries the Apache license header; as the regression test it also fits as one method in TestDateTools. The test uses no randomness, so the seed does not matter. With the saturating fix it passes unchanged; if a fix throws instead, the assertion has to expect the exception.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 1.9.1 to 10.6.0 (13 releases run): fail; 1.4.3: no DateTools
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (pure Java arithmetic; only this platform was run)
Command (environment)
cp $REPRO/TestDateToolsRoundNearLongMin.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.document.TestDateToolsRoundNearLongMin -Ptests.seed=DEADBEEF

Observed output

# gradle, scratch worktree at 5adff78bb168, -Ptests.seed=DEADBEEF -Ptests.asserts=false
TestDateToolsRoundNearLongMin > testRoundNeverMovesTimeForward FAILED
    java.lang.AssertionError: round(Long.MIN_VALUE, DAY) = 9223372036794351616
        at org.apache.lucene.document.TestDateToolsRoundNearLongMin.testRoundNeverMovesTimeForward(TestDateToolsRoundNearLongMin.java:26)

# gradle, same, assertions on: the same failure and value

# with the fix: PASS with assertions on and off

Why it looks unintended

  • Both round overloads promise the input with finer fields "set to 0 or 1" and state no range or exception; the wrapped result reads 14:25:51.616 and is later than the input.
  • Lucene's DateRangePrefixTree already warns about this very Calendar underflow.
  • A non-lenient Calendar and java.time both throw instead of wrapping.
The full argument134 words
  • Contract. round(Date, Resolution) and round(long, Resolution) promise "the date with all values more precise than resolution set to 0 or 1" (DateTools.java:130, :142-143), with no range, precondition or @throws; the class javadoc states no range either (:28-41).
  • Tests. TestDateTools uses ordinary dates only (years 1961 to 2005, and 1970); nothing near Long.MIN_VALUE.
  • History. No commit on DateTools.java or TestDateTools.java mentions MIN_VALUE (git log -S'MIN_VALUE': 0 commits).
  • The project knows the pitfall. DateRangePrefixTree (spatial-extras, DateRangePrefixTree.java:47-48, from #6710 / LUCENE-5648): "Beware Calendar underflow of the underlying long. If you create a Calendar from LONG.MIN_VALUE, and clear a field, it will underflow and appear close to LONG.MAX_VALUE (BC to AD)."
  • Loud alternatives. A non-lenient Calendar throws IllegalArgumentException ("HOUR_OF_DAY: 0 -> 14"); Instant.ofEpochMilli(Long.MIN_VALUE).truncatedTo(DAYS).toEpochMilli() throws ArithmeticException: long overflow.
The devil's-advocate check130 words

The strongest case for "not a bug": no long can hold the floor; the JDK's Calendar.setTime javadoc warns that Date(Long.MIN_VALUE) may give incorrect field values; the class author wrote on java-user@ in 2006 "There is no limit on the given dates in DateTools (within the limits of what Java's Calendar/Date class can do)"; and a sentinel is not a date.

Why it fails: Lucene documents none of this to its callers. The JDK caveat is about the fields read with get(), which are correct here; the wrap happens later, in getTimeInMillis. Jumping silently to the far end of the range is worse than either loud alternative. The argument does bear on likelihood, hence priority Low; a credibility audit also rated it real but minor, with a small fix.

Suggested fix

Recommended Saturate to Long.MIN_VALUE when the result would be later than the input (fix A)

Return Long.MIN_VALUE when the rounded time comes out later than the input, which in the scope probe happened only when the floor lies below the long range (+6/-1 in DateTools.java, with one javadoc sentence). The repro passes with assertions on and off; the repro, TestDateTools, two date-sort and four query-parser suites: 182 tests, 0 failures, 1 skipped by its suite.

    final long rounded = calInstance.getTimeInMillis();
    // a start below Long.MIN_VALUE wraps around to a time near Long.MAX_VALUE
    return rounded > time ? Long.MIN_VALUE : rounded;
All options and trade-offs3 options ยท 451 words

Option A was built and checked through gradle on 5adff78bb168; B and C were not run. By construction the fix changes only results later than their input. In a scope probe on unpatched main the check fired only at the wrap: of 35,377,755 sampled inputs (uniform over the whole long range, years about 1653 to 2286, the 1582 calendar switch at about 1-minute steps, and both ends), round(t, r) > t held for 6, all Long.MIN_VALUE at YEAR to SECOND (a sample, not a proof over every long).

Saturate to Long.MIN_VALUE when the result would be later than the input (fix A) (validated by the checks) (recommended)

A result later than the input is exactly the bad outcome, and in the scope probe it came only from a floor below the long range, so check exactly that after getTimeInMillis(), and say so in the javadoc of round(long, Resolution). Saturating is a policy choice, not the only correct answer: at Long.MIN_VALUE no long can meet the literal @return, and throwing is the other reasonable choice.

    final long rounded = calInstance.getTimeInMillis();
    // a start below Long.MIN_VALUE wraps around to a time near Long.MAX_VALUE
    return rounded > time ? Long.MIN_VALUE : rounded;

Trade-offs

At Long.MIN_VALUE the result is not at midnight, so the literal @return still cannot hold there (no long can); the added javadoc sentence says so. As a range lower bound it is exactly right, since no long lies below it, so a rounded open bound stays open, and the repro passes unchanged as the regression test. Lucene saturates range bounds the same way in Lucene90DocValuesProducer.saturatingShiftLower (Lucene90DocValuesProducer.java:585-600).

What the checks validated

  • Gradle in a scratch worktree at 5adff78bb168, seed DEADBEEF: the repro fails without the fix (assertions on and off) and passes with it (on and off).
  • With the fix: the repro, TestDateTools (5), TestDateSort (1), TestCustomSearcherSort (2), TestQueryParser (73), TestQPHelper (38, 1 skipped by the suite), TestStandardQP (46) and TestPrecedenceQueryParser (16): 182 tests, 0 failures. :lucene:core:checkGoogleJavaFormat passes.

Throw IllegalArgumentException instead (not built)

The same check with throw new IllegalArgumentException(...) in place of the clamp.

Trade-offs

Fails loudly, like java.time and a non-lenient Calendar, but turns today's silent value into an exception for timeToString/dateToString callers too, and the regression test has to expect the exception (expectThrows) instead.

Document the supported range only (not built)

State in the javadoc of both round overloads that inputs less than one unit above Long.MIN_VALUE are not supported (a simple bound that covers every input that wraps).

Trade-offs

Smallest change, and it could go with LFV-645's documentation of years AD 1..9999, but callers who do not read it still get the far-future value.

Downstream note

Downstream, a date restriction written as a range whose open lower bound Long.MIN_VALUE is first passed through DateTools.round matches nothing, so a rule built that way silently covers no documents.

How it was found

A proof-obligation audit of DateTools.round's contract: the result must be the input with its finer fields set to 0 or 1, so never later than the input, for every long. At Long.MIN_VALUE that floor is not representable; a one-call public-API JUnit test then witnessed the wrapped value.

The method in full123 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The finder read both round overloads against their @return obligation and the domain they accept (any long, any Date; no range, precondition or @throws). The obligation "not later than the input" needs a representable floor; for times before the first unit boundary above Long.MIN_VALUE there is none, and the code reads the time back without a check.
  • The finder then wrote a public-API JUnit test that fails on main with assertions on and off and on 10.6.0.
  • An independent reproducer, two executed cross-model reviews and a devil's-advocate verifier reproduced it with their own tests, and an adjudication re-ran the reproducer's test ("Prior reports, history and checks").

Prior reports, history and checks

Prior reports

No prior report found in apache/lucene issues, PRs and commits (with the JIRA archive), the dev@, java-user@, issues@ and commits@ lists, downstream trackers or the web (searched 2026-11-02). The nearest item, #6710, documents the same Calendar underflow for spatial-extras' DateRangePrefixTree, not DateTools.

Searches3 searches

No matching public report was found in the documented searches. The nearest items are related, not reports: #6710 (LUCENE-5648, the DateRangePrefixTree warning), #2147 (LUCENE-1070, the only issue that names DateTools.round, about time zones), the 2006 java-user@ thread "DateField vs DateTools" (the class author's remark on date limits), and elastic/elasticsearch #138757 and #142725 (the same sentinel slip in Elasticsearch's own rounding code). Limits: keyword search; GitHub search does not index review comments; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive)2026-11-02, re-checkedthree scripted prior-report searches (51 queries each: identifiers; trigger and symptom; root cause in general terms); all 29 DateTools issues read and grepped for MIN_VALUE, overflow, wrap, lenient, getTimeInMillis and 292; everything created or updated since 2026-10-31no report; #6710 and #2147 are the nearest items
Open PRs2026-11-02the changed files of all 408 open apache/lucene PRsnone touches DateTools.java
Mailing lists, JIRA, downstream, web2026-11-02dev@, java-user@, issues@ and commits@ (4 threads read in full), 5 JIRA full-text queries, Solr, Elasticsearch and Lucene.NET trackers, 21 web queries; scripted searches with 2,066, 2,054 and 573 hitsnothing about this bug; the 2006 java-user@ remark and elastic/elasticsearch #138757, #142725

History

Present since DateTools was added (c7fbb991c3e, Daniel Naber, 2004; first release 1.9, 2006). Every release run, 1.9.1 to 10.6.0, and main wrap (the DAY value, or on 10.5.2 the YEAR value); later rewrites kept the mechanism. Not fixed upstream.

Versions tested5 rows

Introduced by c7fbb991c3e "Add a new class that supports converting dates to readable strings, with limited resolution..." (Daniel Naber, 2004-09-05, SVN r150483; no PR or JIRA id): round set the finer fields on a lenient Calendar and read the time back from the start, with today's @return text. First release 1.9 (2006-02-27); 1.4.3 has no DateTools.

Later rewrites kept the mechanism: LUCENE-491 (GMT), LUCENE-1653 (cached calendar, setTimeInMillis / getTimeInMillis), LUCENE-1472 (a ThreadLocal instead of synchronization, Resolution enum), LUCENE-1846 (locale), LUCENE-1736 (today's per-thread TL_CAL and fall-through switch). Not fixed upstream: DateTools.java has the same blob on upstream main 1c2072f02843 (2026-11-02) and on 10.6.0; its last commit is dbaf97f5dcc (#14965, 2025-07-18, a lint cleanup). All runs used JDK 25, so old releases ran against today's GregorianCalendar.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: fails with assertions on and off; passes with fix A
9.12.3, 10.0.0, 10.6.0 affectedthe finder's JUnit test (the same call and assertion as the repro): round(Long.MIN_VALUE, DAY) = 9223372036794351616
10.5.2 YEAR variantthe independent reproducer's YEAR test: round(Long.MIN_VALUE, YEAR) = 9223372007850351616
1.9.1, 2.4.1, 2.9.0, 3.0.3, 3.1.0, 3.3.0, 4.0.0, 4.10.4, 8.11.4 main() formthe same call and check: the same value
1.4.3 no DateToolsthe class does not exist

Independent checks

A finder, an independent reproducer, two executed cross-model reviews (OpenAI GPT-5.6, refute and intended lenses), a devil's-advocate verifier, an adjudication, an audit and a final repro check reproduced it and agree on the root cause. This doc's gradle runs reproduced it on main and validated the fix.

Checks and verdicts263 words

The cross-model reviews ran on main ea57360912ed, where DateTools.java is unchanged; the intended-lens review's test was re-run on 5adff78bb168 with the same result. The other checks on main ran on 5adff78bb168; the release runs used each release's own jars.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedIts own version of the one-call test (the same call and assertion) fails on main (assertions on and off) and on 10.6.0.
independent reproducer realOwn YEAR test fails on main, 10.6.0, 10.5.2 and 9.12.3; measured the wrap windows; result minus 2^64 equals the exact floor; the Date overload wraps too.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own test fails (hour 14 instead of 0); controls (MILLISECOND, one day above the minimum) pass.
cross-model review, intended lens (OpenAI GPT-5.6, executed) real (high)YEAR to SECOND all wrap; a non-lenient Calendar and java.time fail loudly; intended argument moderate.
devil's-advocate verifier real (medium)The "garbage in" case does not hold; Long.MAX_VALUE and Long.MIN_VALUE + 1 day round correctly.
adjudication confirmed, LowRe-ran the reproducer's test on main with assertions on and off: fail.
separate re-run, audit and final repro check confirmed; impact minorThe one-test repro fails on main with assertions on and off; the final check set the impact class to minor.
this doc reproduced; fix A validatedGradle: fails without the fix; with it 182 tests run, 0 failures (181 pass, 1 @Ignored by its suite); in the scope probe the check fired only at the wrap.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,495 words

Finding record

LFV-1123, snapshot of 2026-11-06

Registry title
DateTools.round returns a time near Long.MAX_VALUE (AD 292278994) for Long.MIN_VALUE, and for times before the first resolution-unit boundary above it, instead of rounding down: the rounded-down time lies below the long range and the lenient Calendar arithmetic silently wraps
Area
core / document (DateTools resolution limiting: round(long, Resolution) and round(Date, Resolution))
Priority
Low, rank 538: DateTools.round returns a time near Long.MAX_VALUE, with no error, for Long.MIN_VALUE and for times before the first resolution-unit boundary above it, so a range lower bound built from it matches nothing, in every release since 1.9. Real dates never get that close, so only code that rounds the Long.MIN_VALUE open-bound sentinel hits it, a rare boundary case.
Impact class
minor
Impact note
Hits only code that calls the public static DateTools.round(long|Date, Resolution) with Long.MIN_VALUE (for example a half-open lower bound, the form LongPoint's javadoc suggests) or with a time before the first resolution-unit boundary above it (YEAR and MONTH about 29.3 days, DAY about 7.2 h, HOUR 776 s, MINUTE 56 s, SECOND 807 ms; MILLISECOND never): the result is a value above every real date, with no error, identically with -ea and -da [exec], so a range lower bound built from it matches nothing [read]; no setting and no scale are needed, every release from 1.9 (2006) through 10.6.0 and main is affected, and inside Lucene only timeToString/dateToString call round, whose output for such dates is already wrong for the separate reason recorded in LFV-645. Minor rather than silent wrong results, because the wrong value is a public utility return value with no in-tree effect of its own (the only in-tree callers, timeToString/dateToString, are already wrong there for LFV-645's reason) and the trigger is limited to the Long.MIN_VALUE sentinel or times within one unit above it.
Summary
You call DateTools.round(Long.MIN_VALUE, Resolution.DAY), for example to align the open lower bound of a date range with day-rounded indexed values, and expect a time no later than the input, as the javadoc promises ('the date with all values more precise than resolution set to 0 or 1'). Instead you get 9223372036794351616, a time in AD 292278994 just below Long.MAX_VALUE, with no error: the start of that day lies below Long.MIN_VALUE, and the lenient GregorianCalendar that round() uses wraps around in its long arithmetic. YEAR, MONTH, HOUR, MINUTE and SECOND wrap the same way for inputs before the first unit boundary above Long.MIN_VALUE, and round(Date, Resolution) delegates, so it wraps too.
Introduced
c7fbb991c3e (trunk, svn r150483, Daniel Naber, 'Add a new class that supports converting dates to readable strings, with limited resolution...'; SVN era, no PR and no JIRA id): DateTools was born with round(long, Resolution) setting the finer fields on a lenient java.util.Calendar and reading the time back, with today's @return wording and no range check [read: git show in the history clone]. Not a regression: LUCENE-491 (646d9646cbb, GMT calendar, before 1.9), LUCENE-1653 (3c3b283e3c0, cached Calendar and getTimeInMillis), LUCENE-1472 (fcf6e305b48 / b640e28ad38, ThreadLocal calendar, Resolution enum, switch), LUCENE-1846 (bb391fd3edc / 3c3e77e08c8, locale) and LUCENE-1736 (31479ab48ce / b49d9c897df, today's fall-through switch over TL_CAL) all kept the mechanism [read]; every executed release from 1.9.1 on fails [exec]. (2004-09-05 (author and committer date of c7fbb991c3e))
First affected version
1.9 [read] (release tag 2006-02-27, the first release with DateTools; 1.9 is not on Maven Central and DateTools.java is identical at the 1.9 and 1.9.1 tags); 1.9.1 [exec] is the earliest executed release and fails; 1.4.3 and earlier have no DateTools [exec: no DateTools class in the 1.4.3 jar]
Affected versions
Every release from 1.9 (2006) through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT); never fixed. [exec] round(Long.MIN_VALUE, DAY) = 9223372036794351616 on 1.9.1, 2.4.1, 2.9.0, 3.0.3, 3.1.0, 3.3.0, 4.0.0, 4.10.4 and 8.11.4 (a main() variant of the same call and check, -da, because releases before 9.x lack today's LuceneTestCase), and with the one-test JUnit unchanged on 9.12.3 and 10.0.0 (-da), 10.6.0 (-ea and -da) and main 5adff78bb168 (-ea and -da, also in the re-run); the independent reproduction's YEAR test fails on main (-ea and -da), 10.6.0, 10.5.2 and 9.12.3. Not new in 10.6 (10.5.2 and every earlier executed release fail). Not fixed upstream: DateTools.java has the same blob dda55bf2fb13 on 5adff78bb168, on 10.6.0 and on upstream main 1c2072f02843 (2026-11-02); its last commit is dbaf97f5dcc (#14965, author date 2025-07-18, a lint cleanup that does not touch round) [read through the GitHub API, 2026-11-02]. All runs used JDK 25.0.3 on Linux aarch64, so old releases ran against today's GregorianCalendar; how the JDKs of 2006-2011 behaved was not executed.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: proof-obligation audit
Upstream and prior reports
none found (2026-11-02, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's GitHub searches; rechecked the same day): three scripted prior-report searches (identifiers; trigger and symptom; root cause in general terms) of 51 queries each over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web (2066, 2054 and 573 distinct hits, none the same bug), plus manual GitHub searches (all 29 apache/lucene DateTools issues, bodies and comments grepped for MIN_VALUE, overflow, wrap, negative, lenient, getTimeInMillis and 292; apache/solr, apache/lucene-solr, apache/lucenenet, Elasticsearch, OpenSearch and all of GitHub), the changed-file lists of all 408 open apache/lucene PRs (none touches DateTools.java), everything created or updated since 2026-10-31, 5 JIRA full-text queries, direct list searches with 4 threads read in full, and 21 web queries: no report of DateTools.round wrapping for Long.MIN_VALUE or for times just above it, by anyone. Same-day recheck: GitHub search on apache/lucene 'DateTools round' (only #2147, LUCENE-1070, time zones), 'DateTools MIN_VALUE' (0), 'DateTools overflow' (0), open PRs 'DateTools' (0); none of the 38 issues and PRs updated since 2026-10-31 mentions DateTools (#16896 mentions Long.MIN_VALUE but is the doc-values GCD range-transform fix); upstream main 1c2072f02843 has the same DateTools.java blob. Related, not reports: #6710 (LUCENE-5648, 2014), whose DateRangePrefixTree in spatial-extras documents and assert-guards the same JDK Calendar underflow ('If you create a Calendar from LONG.MIN_VALUE, and clear a field, it will underflow and appear close to LONG.MAX_VALUE'), without mentioning DateTools; a 2006 java-user@ reply by the class author ('There is no limit on the given dates in DateTools (within the limits of what Java's Calendar/Date class can do)'); Elasticsearch #138757 and #142725, where Long.MIN_VALUE open lower bounds reached date rounding in Elasticsearch's own code.
Lucene commit tested
main 5adff78bb168 (2026-11-02) through gradle in a scratch worktree with assertions on and off, without and with the saturating fix (the repro, TestDateTools, TestDateSort, TestCustomSearcherSort, four query-parser suites, checkGoogleJavaFormat), and through a standalone JUnit runner on the main jars. The hunt ran its own version of the test (the same call and assertion) on 9.12.3, 10.0.0, 10.6.0 and main, a main() with the same call and check on nine releases from 1.9.1 to 8.11.4, and the independent reproducer's YEAR variant on 10.5.2 (13 releases, all fail); 1.4.3 has no DateTools. Only Linux aarch64 with OpenJDK 25.0.3 was run, so old releases ran against today's GregorianCalendar.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/document/DateTools.java:28-41 (at 5adff78bb168) (class javadoc: lexicographic order of the strings, 'helps you to limit the resolution of your dates', LongPoint offered as the other approach for dates; no range stated), :46-52 (TL_CAL: Calendar.getInstance(GMT, Locale.ROOT), lenient by default), :90-93 (timeToString: round, then format), :125-134 (round(Date, Resolution): '@return the date with all values more precise than resolution set to 0 or 1'; delegates to round(long)), :136-171 (round(long, Resolution): javadoc :136-144 with the same @return plus 'expressed as milliseconds since January 1, 1970, 00:00:00 GMT', no range, precondition or @throws; :148 setTimeInMillis(time); :150-168 fall-through switch that sets MONTH, DAY_OF_MONTH, HOUR_OF_DAY, MINUTE, SECOND and MILLISECOND; :170 return calInstance.getTimeInMillis(), fix site)
  • lucene/core/src/java/org/apache/lucene/document/LongPoint.java:202-203 (at 5adff78bb168) ('You can have half-open ranges ... by setting lowerValue = Long.MIN_VALUE'); lucene/core/src/test/org/apache/lucene/document/TestDateTools.java (no test near Long.MIN_VALUE)
  • https://github.com/apache/lucene/issues/6710 (LUCENE-5648), https://github.com/apache/lucene/issues/2147 (LUCENE-1070), https://lists.apache.org/thread/czzb8pn36p9g5x81d0oo1c29fnd6w1w0 (java-user@ 2006 'DateField vs DateTools'), https://github.com/elastic/elasticsearch/issues/138757, https://github.com/elastic/elasticsearch/issues/142725, read 2026-11-02
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/document/DateTools.java:46-52, (at 5adff78bb168) :132-134, :146-171
Who is affected
  • lucene/core/src/java/org/apache/lucene/document/LongPoint.java:202-203 (at 5adff78bb168) (half-open ranges with Long.MIN_VALUE)
  • lucene/core/src/java/org/apache/lucene/document/DateTools.java:78-93 (at 5adff78bb168) (timeToString/dateToString call round)
  • https://github.com/elastic/elasticsearch/issues/138757, https://github.com/elastic/elasticsearch/issues/142725 (read 2026-11-02)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/document/DateTools.java:28-41, (at 5adff78bb168) :125-144 (javadoc: no range, precondition or @throws)
  • lucene/core/src/test/org/apache/lucene/document/TestDateTools.java (at 5adff78bb168) (ordinary dates only)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/DateRangePrefixTree.java:47-48 (at 5adff78bb168)
  • https://lists.apache.org/thread/czzb8pn36p9g5x81d0oo1c29fnd6w1w0 (java-user@ 2006, read 2026-11-02)
  • https://github.com/apache/lucene/issues/6710
Suggested fix
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:585-600 (at 5adff78bb168) (saturatingShiftLower)
Diagrams
  • lucene/core/src/java/org/apache/lucene/document/DateTools.java:146-171 (at 5adff78bb168)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

The diagram illustrates the repro's call (main 5adff78bb168, DateTools.java:146-171).

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.