DateTools turns a date into a fixed-format string such as 20260101, so that sorting the strings sorts the dates and range queries work on them. The year is printed with yyyy: the year of the era, with no BC marker and at least four digits.
stringToTime then return. Select the still to open it at full size. Generated with AI, checked against the code.- You index dates as
DateToolsstrings:timeToString(t, Resolution.DAY)gives20260101for 2026-01-01. The javadoc promises that sorting the strings sorts the dates, and thatstringToTimeturns a string back into the time. - You expect that for every date: a document dated 1200 BC does not match a range query for AD 1000 to AD 1500, and year 10000 sorts after 2026.
- Before AD 1 the era is dropped: 1200 BC-06-01 and AD 1200-06-01 both become
12000601, so the AD 1000..1500 range matches the 1200 BC document, and 2 BC-01-01 (00020101) sorts after AD 1 (00010101) and reads back as AD 2-01-01. No error. - After 9999 the year gets a fifth digit: 10000-01-01 becomes
100000101, which sorts between 999-12-31 (09991231) and 1000-01-01 (10000101), long before20260101;stringToTimethrowsParseExceptionon it. - The cause is the pattern
yyyyMMdd...(DateTools.java:198):yis the year of the era, with no BC marker and at least four digits.stringToDatepicks its parser by string length (:117), and no parser has 9 characters.
Applications that index dates as DateTools strings, or use the classic or flexible query parser with a date resolution, and have a date before AD 1 or after 9999: historical BC dates, a far-future "never" date, a typed year-10000 bound. Range hits, sort order and read-back go wrong with no error. Rare; every release since 1.9.
Mechanism, terms and numbers766 words
DateTools (core, org.apache.lucene.document) converts dates to strings for field values and search terms, and its class javadoc says "The strings are structured so that lexicographic sorting orders them by date"; stringToTime converts "a string produced by timeToString or dateToString back to a time". Neither sentence names a range, and timeToString accepts any long.
The year is printed with SimpleDateFormat's yyyy, which is the year of the era, with no era letter, at least four digits. So a BC year prints as the AD year with the same number (1200 BC-06-01 and AD 1200-06-01 both give 12000601; 2 BC-01-01 gives 00020101, sorts after AD 1 and reads back as AD 2-01-01), and a year after 9999 gets a fifth digit (10000-01-01 gives 100000101, sorts between 999 and 1000, and stringToTime throws ParseException on it because the parser is picked by string length). A TermRangeQuery for AD 1000 to AD 1500 matches a 1200 BC document; the classic and flexible query parsers with a date resolution turn date:[1/1/2000 TO 12/31/10000] into an inverted range that matches nothing, and date:[1/1/10000 TO 12/31/10001] into one that matches an AD 1000 document. Dates from 0001-01-01 to 9999-12-31 are unaffected. The code is unchanged since the class was added in 2004; every release from 1.9 to 10.6.0 (10.6.0 by reading) and main is affected. The recommended fix rejects out-of-range times with IllegalArgumentException and documents the range; indexed terms do not change.
DateTools: a public class in core (org.apache.lucene.document).timeToString(long, Resolution)anddateToString(Date, Resolution)write a date as a string in GMT at a chosen resolution, from YEAR (yyyy, 4 characters) to MILLISECOND (yyyyMMddHHmmssSSS, 17);stringToTime/stringToDateread it back. The class javadoc: "The strings are structured so that lexicographic sorting orders them by date, which makes them suitable for use as field values and search terms" (DateTools.java:28-31). It offersLongPointas another approach (:37-40).- Query parser date ranges: with a date resolution set, the classic and flexible query parsers convert the bounds of
field:[6/1/2005 TO 6/4/2005]withDateTools(QueryParser.java:50-60).
timeToStringrounds the time and formats it; it checks no range (DateTools.java:90-93).- The format is
new SimpleDateFormat("yyyyMMddHHmmssSSS".substring(0, formatLen), Locale.ROOT)(:198). InSimpleDateFormat,yis the year of era and the letter count is a minimum width; there is no era letterG. - So 1200 BC prints
1200, exactly like AD 1200, and year 10000 prints10000, one character more. Compared character by character,100000101(10000-01-01) is smaller than10000101(1000-01-01). stringToDateparses withTL_FORMATS.get()[dateString.length()](:117); the array has formats only for lengths 4/6/8/10/12/14/17, so a 9-character string hitsnull, which is wrapped asParseException(:118-121). A BC string has a normal length and parses as the AD year with the same number.- The classic query parser converts each range bound inside its own
catch (Exception e) {}(QueryParserBase.java:454-479); the flexible parser converts both bounds in onetryand, in its catch, keeps the texts it has so far (TermRangeQueryNodeProcessor.java:100-142). A bound in year 10000 converts to a 9-character term that sorts among the year-1000 strings. With a later lower bound the range is inverted and matches nothing (date:[1/1/2000 TO 12/31/10000]gives20000101..100001231). Otherwise it covers the wrong terms:date:[1/1/500 TO 12/31/10000]misses AD 1000, 1200 and 2026 documents, anddate:[1/1/10000 TO 12/31/10001]matches an AD 1000 document (executed, classic and flexible). Both parsers read the typed date in the JVM default time zone (QueryParserBase.java:450), so near the edges a typed date can leave the GMT range: under America/Los_Angeles,date:[1/1/2026 TO 12/31/9999]ends in year 10000 GMT and misses a 2026 document.
| date (DAY resolution unless noted) | string | what goes wrong |
|---|---|---|
| 1200 BC-06-01 | 12000601 | the string of AD 1200-06-01; matches a range AD 1000..1500; reads back as AD 1200 |
| 2 BC-01-01 | 00020101 | sorts after AD 1-01-01 (00010101); reads back as AD 2-01-01, 1,096 days later |
| 1 BC and AD 1 (YEAR) | 0001 for both | two years, one string |
| 10000-01-01 | 100000101 | sorts between 09991231 (999-12-31) and 10000101 (1000-01-01); stringToTime throws ParseException |
query parser date:[1/1/2000 TO 12/31/10000] | 20000101..100001231 | inverted range, matches nothing |
query parser date:[1/1/10000 TO 12/31/10001] | 100000101..100011231 | not inverted, but matches an AD 1000-06-01 document |
query parser date:[1/1/500 TO 12/31/10000] | 05000101..100001231 | misses AD 1000, AD 1200 and 2026 documents |
| control: AD 1, 1582-10-15, 1970, 2026, 9999-12-31 23:59:59.999, all 7 resolutions | four-digit years | sorted and read back exactly |
Exposure and likelihood351 words
- Who is exposed. Users of the legacy
DateToolsencoding (an explicit choice: its javadoc offersLongPoint, and the document package recommendsLongFieldfor dates) and of query-parser date ranges withsetDateResolution, whose data or queries contain a date outside the years 1 to 9999: historical collections with BC dates; a "never" or "scheduled" date in year 10000 or later, orLong.MAX_VALUE; a bound typed as12/31/10000. - What the damage looks like. Range and prefix queries match the wrong documents or none: a 1200 BC document matches AD 1000..1500, a range AD 2000..20000 found neither a 2026 nor a year-10000 document in the devil's-advocate verifier's run, and the parsed
date:[1/1/10000 TO 12/31/10001]matches an AD 1000 document. Sorts put year 10000 between 999 and 1000. BC dates read back as the AD date with the same number. The only loud symptom is theParseExceptionwhen a year-10000 string is read back. - Extreme values.
Long.MIN_VALUEandLong.MAX_VALUEgive strings of 9 to 22 characters that start with2922; most throw on read-back, and at MINUTE resolution both are silently parsed into a different time (30253281730712).round(Long.MIN_VALUE, YEAR)wraps to a positive time. This edge was confirmed by a verifier's probe only and is not the headline. - How likely. Rare: present-day data, date pickers and formatters do not produce such dates, and nobody has reported it since 1.9 (2006).
- Scale. One call reproduces it; assertions on and off behave the same; nothing depends on index size, settings or concurrency.
- Not affected. Dates from 0001-01-01 to 9999-12-31 GMT: the control passes at all 7 resolutions, and every day of that range at DAY resolution (3,652,061 days) sorts and reads back exactly; the other resolutions are sampled. Query parsers read typed dates in the JVM default time zone, so a typed date at the very edges can still fall outside the GMT range. Before 1582-10-15 the strings follow the Julian side of
GregorianCalendar; they are consistent and in order, so they are not part of this bug. Dates indexed asLongPoint/LongFielddo not useDateTools.
The test shows that DateTools.timeToString(2 BC-01-01 GMT, DAY) gives "00020101" (no era), and stringToTime turns DateTools' own string back into AD 2-01-01.
/*
* 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 java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.Locale;
import java.util.TimeZone;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestDateToolsBCRoundTrip extends LuceneTestCase {
public void testBCDateRoundTrip() throws Exception {
Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("GMT"), Locale.ROOT);
cal.clear();
cal.set(Calendar.ERA, GregorianCalendar.BC);
cal.set(2, Calendar.JANUARY, 1);
long twoBC = cal.getTimeInMillis();
String s = DateTools.timeToString(twoBC, DateTools.Resolution.DAY);
// expected: stringToTime gives back 2 BC-01-01; actual: "00020101" parses as AD 2-01-01
assertEquals("2 BC-01-01 GMT -> " + s, twoBC, DateTools.stringToTime(s));
}
}
# in a copy of this finding's repro folder (repro/LFV-645/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.document.TestDateToolsBCRoundTrip
- Expected vs actual
- Expected
-62198928000000(2 BC-01-01); actual-62104233600000(AD 2-01-01):2 BC-01-01 GMT -> 00020101 expected:<-62198928000000> but was:<-62104233600000>(-ea and -da).
More files, environment, full output38 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: 2 BC-01-01 GMT -> 00020101 expected:<-62198928000000> but was:<-62104233600000>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.apache.lucene.document.TestDateToolsBCRoundTrip.testBCDateRoundTrip(TestDateToolsBCRoundTrip.java:35)- The javadoc names no range ("lexicographic sorting orders them by date"), and
timeToStringaccepts anylong. stringToTimereads back "a string produced bytimeToString", yet returns AD 2 for 2 BC and throws on year 10000's string.- Earlier ordering breaks here were fixed as bugs (LUCENE-491, LUCENE-1846: "important to guarantee correct ordering of generated terms").
The full argument279 words
- Contract. The ordering sentence has no condition (
DateTools.java:28-31).timeToStringtakes "the date expressed as milliseconds since January 1, 1970, 00:00:00 GMT" (:85), anylong, and validates nothing (:90-93).stringToTime/stringToDateconvert "a string produced bytimeToStringordateToStringback to a time" (:96,:108); for year 10000 they throw on that string, for 2 BC they return another time. The@returntext says "a string in formatyyyyMMddHHmmssSSSor shorter" (:75,:87); a 9-character string has none of that format's prefix lengths (4, 6, 8, 10, 12, 14, 17), which is whystringToDatehas no parser for it. - Tests.
TestDateToolsuses dates from 1961 to 2005 and 1970; its malformed inputs are97,200401011235009999andaaaa.TestDateSortuses dates in October 2007 andTestCustomSearcherSortdates in February and March 1980. No test covers or pins a year outside 1 to 9999. - History. The 1.9 CHANGES entry introduced
DateToolsas the class that "can cope with dates before 1970". Two weeks after adding it, its author answered LUCENE-142 (#1220, historical dates, resolved as Fixed; its patch offered 1970 +/- 5,000 years, BC included) with "The new DateTools class now solves the 'pre-1970' problem". LUCENE-491 (#1569, time zone) and LUCENE-1846 (#2921, locale) fixed earlier ordering breaks; the LUCENE-1846 CHANGES entry says "The usage of the US locale is important to guarantee correct ordering of generated terms", and in that issue uschindler wrote that "the dates in DateTools should sort according to String.compare()". - Elsewhere in Lucene the era is handled on purpose: the spatial
DateRangePrefixTreewas built with explicit BC and+-year handling (LUCENE-5648, LUCENE-7278).
The devil's-advocate check222 words
The strongest "intended" case (built by the devil's-advocate verifier and the reviews by another model family, all of whom rated it moderate): read literally, @return a string in format yyyyMMddHHmmssSSS is exactly what SimpleDateFormat prints, BC and five-digit years included; the design has been fixed-width with four-digit years and a length-based parser since 2004; the class was written to fix "pre-1970", not BC or year 10000; a four-digit year field is commonly read as 0001 to 9999; the class is legacy (its javadoc offers LongPoint); changing the encoding would change indexed terms; nobody reported it in about 20 years.
Why it fails: no range is stated or checked anywhere (not in DateTools.java, and none of the 34 commits that touched it mentions a year limit, BC or an era; nothing found says BC or five-digit years were considered and left out), and the read-back sentence fails under the literal reading too, because the parser rejects or silently shifts DateTools' own output. Maintainers treat the ordering sentence as a contract (LUCENE-1846), and LUCENE-142 shows historical dates were in scope. Legacy status and term compatibility decide how to fix it (document and validate the range), not whether it is a bug. The adjudication's confidence is medium for exactly this reason: maintainers may well answer "document the range", which is the recommended fix.
Recommended Reject times outside the years 1 to 9999 with IllegalArgumentException, and document the range
Make the limit explicit: timeToString and dateToString throw IllegalArgumentException for a time before 0001-01-01 or after 9999-12-31 GMT, and the javadoc says so (+38 lines). In-range terms do not change. Validated: the repro's 5 cases now fail loudly; core, queryparser and benchmark tests pass. Not covered: the query parsers' silent fallback and terms already indexed.
public static String timeToString(long time, Resolution resolution) {
if (time < MIN_TIME || time > MAX_TIME) {
throw new IllegalArgumentException(
"time must be between " + MIN_TIME + " (0001-01-01 00:00:00.000 GMT) and " + MAX_TIME
+ " (9999-12-31 23:59:59.999 GMT), got: " + time);
}
final Date date = new Date(round(time, resolution));
return TL_FORMATS.get()[resolution.formatLen].format(date);
}All options and trade-offs3 options ยท 946 words
Option A is the compatible fix the adjudication, the devil's-advocate verifier and the security screen point to, validated here with gradle. Option B extends the encoding instead; option C only documents the range. Neither was run. With A, replace the issue repro by the fix's own test (TestDateTools.testYearRange), and add a CHANGES entry: callers that pass out-of-range sentinels get an exception from then on.
Reject times outside the years 1 to 9999 with IllegalArgumentException, and document the range (validated by the checks) (recommended)
timeToString (and through it dateToString) checks the time against two constants computed once with Calendar.getInstance(GMT, Locale.ROOT), the calendar round uses: 0001-01-01 00:00:00.000 GMT (-62135769600000) and 9999-12-31 23:59:59.999 GMT (253402300799999). The class javadoc gains a paragraph on the range, and both methods get @throws IllegalArgumentException (+38/-0 in DateTools.java). Checking time is enough: for a time already in the range, round moves it down to the start of its year, month, day, and so on, which stays in the range (outside it this does not hold: round(Long.MIN_VALUE, YEAR) wraps to a positive time).
public static String timeToString(long time, Resolution resolution) {
if (time < MIN_TIME || time > MAX_TIME) {
throw new IllegalArgumentException(
"time must be between " + MIN_TIME + " (0001-01-01 00:00:00.000 GMT) and " + MAX_TIME
+ " (9999-12-31 23:59:59.999 GMT), got: " + time);
}
final Date date = new Date(round(time, resolution));
return TL_FORMATS.get()[resolution.formatLen].format(date);
}Trade-offs
- Every string
DateToolscan still write has a four-digit AD year, so both javadoc promises hold for all of them, and existing in-range terms are byte-identical: no reindex for indexes that hold only in-range dates. - A behaviour change: callers that pass
Long.MAX_VALUE/Long.MIN_VALUEsentinels or BC dates now get an exception instead of a wrong string. It needs a CHANGES/MIGRATE note; main is the natural target. - The query parsers stay silent. Each swallows the exception and keeps the unconverted text of the failing bound (
QueryParserBase.java:454-479); the flexible parser converts both bounds in onetry, so when the lower bound fails the upper one stays unconverted too (TermRangeQueryNodeProcessor.java:100-142). Executed with A for both parsers:date:[1/1/2000 TO 12/31/10000]becomes20000101..12/31/10000, still inverted, count 0;date:[1/1/10000 TO 12/31/10001]stays raw text and still matches an AD 1000 document, as on stock;date:[1/1/10000 TO 12/31/2030]becomes1/1/10000..20301231in the classic parser (3 wrong hits on a 4-document index) and1/1/10000..12/31/2030in the flexible one (1 wrong hit). A follow-up could make an out-of-range date fail the parse, and decide what the flexible parser's sharedtryshould keep. - Existing data. A neither detects nor repairs out-of-range terms already in an index: BC terms stay ambiguous (a BC term is the string of an AD date, so the instant cannot be recovered from it) and year-10000 terms stay unreadable. They need reindexing from the original timestamps; the CHANGES/MIGRATE note should say so.
- Time zones. The range is in GMT, but the query parsers read a typed date in the JVM default time zone: with A,
date:[1/1/2026 TO 12/31/9999]under America/Los_Angeles keeps the raw upper text12/31/9999(count 0, not 1), anddate:[1/1/0001 TO 12/31/2030]under Asia/Tokyo keeps the raw lower text and still misses an AD 1-06-01 document (executed, both parsers). - Applications with real BC or year-10000 dates get no
DateToolsencoding for them; they needLongPoint/LongField, which the javadocs already recommend, or their own encoding.stringToDateis unchanged.
What the checks validated
- Scratch worktree at
a9779ef3c193, gradle, JDK 25. The repro: on stock 5 of 6 fail (assertions on and off); with A the same 5 fail fast withIllegalArgumentException("time must be between -62135769600000 ... got: -62198928000000") and the control passes, so no newly written out-of-range term is silently wrong (terms already in an index are untouched). - The new
TestDateTools.testYearRange(the boundaries sort and read back at all 7 resolutions; one millisecond outside,Long.MIN_VALUEandLong.MAX_VALUEthrow, fortimeToStringanddateToString) passes with A and fails on stock; the other 5TestDateToolstests pass. - The callers of
DateTools:org.apache.lucene.document.*,TestDateSortandTestCustomSearcherSort(61 suites, 502 tests), the whole:lucene:queryparser:test(30 suites, 533 tests) and:lucene:benchmark:test(18 suites, 90 tests): 0 failures with assertions on (seed 5EED0645) and off (5EED0646). The whole:lucene:core:test: 748 suites, 8,890 tests, 0 failures, 322 skipped. - The core lint run succeeded:
checkGoogleJavaFormat,ecjLintMainandrenderJavadocran and passed;forbiddenApisMain,forbiddenApisTestandecjLintTestwere reported up to date from an earlier successful run in the same worktree, whose log was not kept. Re-run them before the PR.
Keep every string for the years 1 to 9999 byte-identical and add new forms only outside it, for example BC years as - followed by 10000 minus the year (- sorts before every digit, and the complement puts earlier BC years first), and years after 9999 behind a character that sorts after 9.
Trade-offs
Existing indexes stay valid and historical dates become usable, but it is a format extension of a legacy class: new parsers per length, a decision for the query parsers, more tests and maintainer agreement. Solr fixed the same SimpleDateFormat pitfall in its own formatter by moving to java.time (SOLR-8904); that is precedent for calling it a bug, not a sort-preserving design to copy (an ISO year with a minus sign does not sort BC years in date order either).
A javadoc sentence, no behaviour change. Not run.
Trade-offs
Removes the contract mismatch on paper but leaves the silent wrong terms and the ParseException on DateTools' own output.
Applications that enforce date-based visibility rules as DateTools term ranges should reject dates outside the years 1 to 9999 first: in a test index, a year-10000 "never" date passed a "visible from <= now" filter.
A bounded-exhaustive check ran the real DateTools on 142 boundary instants (BC years, the 1582 calendar switch, 9999/10000, the long extremes) at all 7 resolutions and tested every ordered pair against the javadoc's order and read-back promises. Every violation was outside the years 1 to 9999. Public-API tests then reproduced it.
The method in full139 words
Method: bounded-exhaustive check on the stock main jar (the study's "semi-formal" tier), a hunt unit (javadoc equivalence claims, enumerated), 2026-10-26, aimed by a code-reading lead that yyyy is the year of era.
- The model ran the stock jar on 142 instants:
Long.MIN_VALUE/Long.MAX_VALUE, every year start and end from 4 BC to AD 3, 999/1000, 1581-1583 with the Gregorian cutover days, 1969-1971, 2026, 9998-10001 and 12345, each +-1 ms, at all 7 resolutions, and checked all ordered pairs for order and read-back against the javadoc. - The finder's log reports violations only outside the years 1 to 9999 and a clean in-range subset. These counts were not re-run independently, so the doc cites only the targeted cases.
- Then an AI finder wrote the public-API JUnit, and independent AI tools reproduced, reviewed, judged and audited it (see the checks).
No prior report found (2026-10-27): four runs of the scripted prior-report search (196 queries) plus manual GitHub, JIRA, mailing-list and web searches; all 29 Lucene DateTools issues read. Closest: the same SimpleDateFormat pitfall in Solr's own date formatter (SOLR-8904), which never touched DateTools.
Searches3 searches
Nothing reports DateTools strings losing the era before AD 1 or widening to five digits after 9999. Upstream main a95c754ee003 has the same DateTools.java (last change dbaf97f5dcc, 2025-07-18, #14965, a generics cleanup); no open PR touches it.
Related, not the same bug: the 2016 dev@ thread "Solr date formatting/parsing; upgrade to java.time" with SOLR-8880, SOLR-1899, SOLR-2773 and the fix SOLR-8904 (Solr's own formatter); #6710 / #8333 (LUCENE-5648 / LUCENE-7278, DateRangePrefixTree with explicit BC handling); #1569 (LUCENE-491) and #2921 (LUCENE-1846), earlier ordering fixes with other causes; #2810 (LUCENE-1736, the length-indexed parser array); #1124 / #1220 (LUCENE-46 / LUCENE-142, DateField's pre-1970 limit); apache/lucenenet#772 (the .NET port, limited to 0001-9999 by its platform date type). A maintainer may still call the SimpleDateFormat pitfall well known.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), the JIRA archive | 2026-10-27 | four scripted prior-report search runs (identifiers and error text; symptom wording; format and round trip; years and sentinels), all 29 DateTools issues read and grepped for era, BC, 9999, 10000, negative and five-digit years | no report; the closest are earlier ordering fixes with other causes |
| Downstream repositories | 2026-10-27 | apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/lucenenet; all of GitHub for the error text | no use of DateTools in Solr, Elasticsearch or OpenSearch; lucenenet#772 is platform-limited |
| Mailing lists, JIRA SOLR and the web | 2026-10-27 | dev@, java-user@, issues@ and commits@ (tool queries plus 5 threads read in full), 24 SOLR tickets on BC and five-digit years, 15 web searches | the same pitfall in Solr's own formatter (SOLR-8904), not DateTools |
Present since DateTools was added (c7fbb991c3e, 2004-09-05; first release 1.9, 2006); no later change added an era, a signed year or a range check. A version-neutral probe fails on 17 releases from 1.9.1 to 10.5.2, on main and on the introducing commit; 1.4.3 has no DateTools.
Versions tested6 rows
Introduced by c7fbb991c3e (svn r150483, 2004-09-05, committed by dnaber: "Add a new class that supports converting dates to readable strings, with limited resolution."; svn era, no PR or JIRA id), with the yyyy patterns, the length-based parser and the ordering sentence. Later changes kept them: f72c1e579fe (2006, static formats), fcf6e305b48 (LUCENE-1472, 2011, ThreadLocal formats), 31479ab48ce (LUCENE-1736, 2011, substring(0, formatLen) and the format array indexed by length), b6a4748f21b (2012, Locale.ROOT), 2971f311a2b (LUCENE-9911, 2021) and a reformat; git log -S finds no commit that added an era, a signed year or a range check. Not a regression.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's test fails 5 of 6 (gradle and standalone, assertions on and off) |
| 10.5.2, 9.12.3 | affected | this doc's test fails 5 of 6; the finder's, the reproducer's and the devil's advocate's tests fail too |
| 1.9.1, 2.0.0, 2.1.0, 2.9.4, 3.0.3, 3.2.0, 3.3.0, 3.6.2, 4.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 10.0.0 | affected | the version-neutral probe fails 5 of 5; its control passes |
c7fbb991c3e (introducing commit) | affected | its DateTools.java, compiled on its own, fails the probe 5 of 5 |
| 1.4.3 | API absent | no DateTools class |
| main + fix | rejected | out-of-range times throw IllegalArgumentException; in-range strings unchanged |
An independent reproducer, a devil's advocate, an adjudication and reviews by another model family (which ran their own tests) all found it real; the adjudication confirmed it with medium confidence, about how maintainers will resolve it, not the mechanism. A registry re-run, an audit and this doc's own runs reproduced it.
Checks and verdicts419 words
Every check wrote or ran its own test against the stock jars, with assertions on and off unless noted.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Public-API JUnit 3 of 3 fail on main (-ea, -da), 10.5.2 and 9.12.3; the bounded-exhaustive model found violations only outside the years 1 to 9999. |
| cross-model review, refute lens | real (high) | An independent reviewer from another model family (OpenAI GPT-5.6) ran its own boundary check on main ea57360912ed (DateTools.java identical to a9779ef3c193), assertions on and off, with an in-range control passing; intended-behaviour argument rated moderate. |
| Independent reproducer | real | Own JUnit 5 of 6 fail on main (-ea, -da), 10.5.2 and 9.12.3, control passing; its probe of Long.MIN_VALUE / Long.MAX_VALUE corrected the candidate (MINUTE strings are mis-parsed, not rejected). |
| Cross-model review, intended lens | real (high) | OpenAI GPT-5.6: own JUnit 4 of 5 fail on main and 10.5.2, control passing; a separate run re-ran it on a9779ef3c193 (-ea, -da) and 9.12.3 with the same result; root cause agreed, inputs in contract. |
| Devil's advocate | real (medium-high) | Built the strongest intended case and found it fails; own reachability JUnit 3 of 4 fail on main, 10.5.2 and 9.12.3 (TermRangeQuery and the classic query parser). |
| adjudication | confirmed (medium) | Re-ran the independent reproduction's JUnit (5 of 6 fail, -ea and -da) and the finder's (3 of 3); medium because maintainers may resolve it by documenting the range. |
| security screen | not security-sensitive | Out of scope under SECURITY.md: no denial of service, index damage or data exposure from Lucene; only application date rules built on DateTools term ranges can fail open. |
| registry re-run and audit | keep | The finder's JUnit fails 3 of 3 again (-ea and -da); the intended-behaviour check's fails 3 of 4 with -da; the code read confirms :198 and :117. |
| this doc's runs | reproduced; fix validated | New test through gradle and the standalone runner on main (-ea, -da), 10.5.2 and 9.12.3; the fix validated against the core module and the queryparser and benchmark suites. |
| independent fact check | reproduced | Re-ran the doc's gradle command (5 of 6 fail, assertions on and off) and the standalone runner on main and on 10.5.2 and 9.12.3 with -da; swept every day from 0001-01-01 to 9999-12-31 at DAY resolution with no violation; ran both query parsers with and without the fix, which corrected this doc's query-parser statement. |
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.
- PNG 484 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,580 words
- Registry title
- DateTools strings stop sorting by date and stop round-tripping outside years AD 1..9999: the 'yyyy' year-of-era pattern drops the BC era (2 BC sorts after AD 1 and parses back as AD 2; 1 BC and AD 1 give the same string) and gives years after 9999 a fifth digit (10000 sorts before 2026, and stringToTime throws ParseException on DateTools' own output)
- Area
- core / document (DateTools date-to-string term encoding: timeToString/dateToString and stringToTime/stringToDate)
- Priority
- Low, rank 534: DateTools gives wrong order and round trip only for boundary dates outside AD 1..9999 in a legacy encoding, so it is a Low boundary-value row after the out-of-range parser row LFV-082 and above its DateTools sibling LFV-283.
- Impact class
- silent wrong results
- Impact note
- Hits applications that index dates as DateTools strings (the legacy encoding; its javadoc offers LongPoint instead) and have a date outside AD 1..9999 - a historical BC date, a far-future 'never' or sentinel date in year 10000 or later, a Long.MIN_VALUE/Long.MAX_VALUE bound, or a classic or flexible query parser date range with a five-digit year (date resolution set): TermRangeQuery, PrefixQuery and string sorting on those terms give wrong hits and wrong order with no error (executed: a 1200 BC document matches [AD 1000 TO AD 1500], and [AD 2000 TO AD 20000] matches neither a 2026 nor a year-10000 document), BC values read back with stringToTime silently come back as the AD year with the same number, and year-10000 values throw ParseException; no setting and no scale needed, deterministic with -ea and -da, every release since 1.9 (2006); dates inside AD 1..9999 are unaffected.
- Summary
- You convert a date before AD 1 or after 9999 with DateTools.timeToString (or dateToString) and expect what the class javadoc promises: strings whose lexicographic order is date order, which stringToTime turns back into the same time. Instead the SimpleDateFormat pattern 'yyyy...' prints the year of era with no era and at least four digits: 2 BC-01-01 becomes "00020101", which sorts after AD 1's "00010101" and parses back as AD 2-01-01 (at YEAR resolution 1 BC and AD 1 both give "0001"), and 10000-01-01 becomes "100000101", which sorts before "20260101" and makes stringToTime throw ParseException, because the parser is chosen by string length. Range queries, prefix queries and sorts on such terms therefore return wrong results with no error.
- Introduced
- c7fbb991c3e55fb6c3c3f3d2f0757176a7eccb06 (trunk, svn r150483, 'Add a new class that supports converting dates to readable strings, with limited resolution.', committed by ASF committer dnaber; svn era, no PR and no JIRA id): the class's first commit already formats with the SimpleDateFormat patterns 'yyyy' .. 'yyyyMMddHHmmssSSS' (year of era, no era letter, no range check), parses by string length (4/6/8/10/12/14/17) and carries the ordering sentence [read: git show; exec: that commit's DateTools.java compiled on its own fails the history probe 5 of 5]. Not a regression: later refactors kept the patterns (f72c1e579fe 2006 static formats; fcf6e305b48 LUCENE-1472 2011 ThreadLocal formats; 31479ab48ce LUCENE-1736 2011 'yyyyMMddHHmmssSSS'.substring(0, formatLen) and the format array indexed by string length; b6a4748f21b 2012 Locale.ROOT; 2971f311a2b LUCENE-9911 2021 ParseException cause; 2d6ad2fee6d reformat), and git log -S finds no commit that ever added an era, a signed year or a range check (2004-09-05 (c7fbb991c3e, trunk))
- First affected version
- 1.9 [read] (2006-02-27, the first release with DateTools; first release candidate 1.9-rc1 2006-02-21; 1.9 is not on Maven Central); 1.9.1 [exec] (2006-03-02) is the first executed release and fails the history probe 5 of 5; 1.4.3 and earlier have no DateTools [exec: ClassNotFoundException on 1.4.3]
- Affected versions
- Every release that has DateTools, 1.9 through 10.6.0 (10.6.0 by reading), and main a9779ef3c193; never fixed. [exec] the version-history runs's version-neutral main() probe (checks: 2 BC < AD 1 at DAY, 2 BC round trip, 1 BC < AD 1 at YEAR, 2026 < 10000 at DAY, year-10000 round trip; its control AD 1, 1582-10-15, 1970, 2026, 9999-12-31 passes) fails 5 of 5 on 17 releases (1.9.1, 2.0.0, 2.1.0, 2.9.4, 3.0.3, 3.2.0, 3.3.0, 3.6.2, 4.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2), on main and on the introducing commit's DateTools.java; the finder's JUnit fails 3 of 3 on main (-ea and -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 5 of 6 on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's reachability JUnit fails 3 of 4 on main (-ea and -da), 10.5.2 and 9.12.3; the cross-model reviewer's own tests fail on main (ea57360912ed -ea and -da; a9779ef3c193 -ea and -da), 10.5.2 and 9.12.3. [read] 147 of the 165 release tags in the history clone contain DateTools.java with only the seven yyyy-prefixed patterns; upstream main a95c754ee003 (2026-10-26) has the same DateTools.java (last change dbaf97f5dcc, 2025-07-18, generics cleanup) and no open PR touches it, so 10.6.0 (released 2026-10-29) and the unreleased 11.0 line are affected (not executed). Not executed: x86 and JDKs other than 25.0.3 (the behaviour is documented java.text.SimpleDateFormat semantics: 'y' is the year of era and the letter count is a minimum width). JDK 25.0.3, aarch64
- 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: exhaustive check, code reading
- Upstream and prior reports
- none found (2026-10-27, read-only; the prior-report record verdict new: four scripted prior-report searches with 196 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; manual GitHub REST searches in apache/lucene (all 29 DateTools issues, bodies and comments grepped for era, BC, 9999, 10000, negative and five-digit years), apache/lucenenet, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and all of GitHub for the error text; 3 JIRA searches over LUCENE and SOLR; 5 list threads read; 15 web searches; the finder's searches and the independent reproduction's and the intended-behaviour check's scripted prior-report searches, 43 queries each, agree). Closest items, none the same bug (18 hits, all same_bug false): the 2016 dev@ thread 'Solr date formatting/parsing; upgrade to java.time' and SOLR-8880 / SOLR-1899 / SOLR-2773 (fixed by SOLR-8904), the same SimpleDateFormat year-of-era defect class in Solr's own date formatter, never mentioning Lucene's DateTools; #6710 (LUCENE-5648) and #8333 (LUCENE-7278), where the spatial DateRangePrefixTree was built with explicit BC and '+'-year handling; #1569 (LUCENE-491, UTC collation) and #2921 (LUCENE-1846, locale), earlier DateTools ordering fixes with other causes; #2810 (LUCENE-1736, the length-indexed parser array); #1124 / #1220 (LUCENE-46 / LUCENE-142, the predecessor DateField's pre-1970 limit); apache/lucenenet#772 (the .NET port, limited to years 0001..9999 by its platform date type). Not fixed upstream: DateTools.java is unchanged on upstream main a95c754ee003 (last change dbaf97f5dcc, 2025-07-18, #14965, generics cleanup) and no open PR touches it.
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-28) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 and 9.12.3 release jars (this doc's test); a version-neutralmain()probe on 17 releases from 1.9.1 to 10.5.2, on main and on the introducing commit'sDateTools.java. The fix was validated with gradle ona9779ef3c193. Upstream maina95c754ee003has the sameDateTools.java(read, not executed).
- Finding record
lucene/core/src/java/org/apache/lucene/document/DateTools.java:28-31 (at a9779ef3c193) (class javadoc: 'The strings are structured so that lexicographic sorting orders them by date, which makes them suitable for use as field values and search terms.'), :37-40 (LongPoint offered as the other approach), :75-77 and :87-88 ('@return a string in format yyyyMMddHHmmssSSS or shorter'), :85 ('@param time the date expressed as milliseconds since January 1, 1970, 00:00:00 GMT', no range), :90-93 (timeToString: round, then format; no validation), :95-123 (stringToTime/stringToDate: 'Converts a string produced by timeToString or dateToString back to a time'; :117 picks the format by dateString.length(); :118-121 wraps any failure in ParseException 'Input is not a valid date string'), :146-171 (round on a lenient GMT Calendar), :177-189 (Resolution lengths 4/6/8/10/12/14/17), :198 (new SimpleDateFormat("yyyyMMddHHmmssSSS".substring(0, formatLen), Locale.ROOT), no era letter)lucene/core/src/java/org/apache/lucene/document/package-info.java (at a9779ef3c193) (recommends LongField for dates; cited by the security follow-up at :83)lucene/core/src/test/org/apache/lucene/document/TestDateTools.java (at a9779ef3c193) (dates 1961-2005 and 1970 only; no year outside AD 1..9999; read by the independent reproduction, the cross-model review and the intended-behaviour check)a Lucene main checkout: git diff ea57360912ed a9779ef3c193 -- DateTools.java is empty (the reviewers' runs on ea57360912ed exercise the same code)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/document/DateTools.java:28-31, (at a9779ef3c193) :37-40, :54-65, :85, :90-93, :95-123 (:117 parser by length; :118-121 ParseException), :177-189, :194-200 (:198 pattern)lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParser.java:50-60 (at a9779ef3c193)lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:447-482 (at a9779ef3c193) (getRangeQuery; :454-479 conversion inside catch (Exception))lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/processors/TermRangeQueryNodeProcessor.java:100-142 (at a9779ef3c193) (conversion inside try; :133 'not a date')lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:450 (at a9779ef3c193) (DateFormat.getDateInstance(SHORT, locale): JVM default zone), :461-475 (parser time zone only for the inclusive end of day); TermRangeQueryNodeProcessor.java:101, :110-124
- Who is affected
lucene/core/src/java/org/apache/lucene/document/DateTools.java:37-40 (at a9779ef3c193) (LongPoint offered)lucene/core/src/java/org/apache/lucene/document/package-info.java:83 (at a9779ef3c193) (LongField recommended for dates)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/document/DateTools.java:28-31, (at a9779ef3c193) :75, :85, :87, :90-93, :96, :108lucene/core/src/test/org/apache/lucene/document/TestDateTools.java (at a9779ef3c193) (dates 1961-2005, 1970; malformed inputs :44, :50, :56)lucene/CHANGES.txt:19114-19118 (at a9779ef3c193) (LUCENE-1846), :21722-21726 (1.9: DateTools added)
- Suggested fix
lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:454-479; (at a9779ef3c193) lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/processors/TermRangeQueryNodeProcessor.java:100-142 (at a9779ef3c193)
- TestDateToolsBCRoundTrip.java (JUnit test):
repro/LFV-645/src/org/apache/lucene/document/TestDateToolsBCRoundTrip.java(37 lines)
A light, step-by-step picture of the repro, made in parallel with this doc from executed runs on main a9779ef3c193 and the code.
assets/LFV-645/bug.png(484 KB)
