Skip to content

fix(duration): widen Duration.days from short to int — 89y silent-truncation bug#95

Merged
JohannesLichtenberger merged 6 commits intomasterfrom
fix/datetime-subtract-borrow-bug
May 1, 2026
Merged

fix(duration): widen Duration.days from short to int — 89y silent-truncation bug#95
JohannesLichtenberger merged 6 commits intomasterfrom
fix/datetime-subtract-borrow-bug

Conversation

@JohannesLichtenberger
Copy link
Copy Markdown
Member

Summary

Duration.getDays() / DTD.days / Dur.days were 16-bit signed short. Any xs:dateTime subtraction across more than Short.MAX_VALUE = 32 767 days (≈ 89.7 years) silently truncated mod 65 536, producing arithmetically wrong durations that still satisfied basic type contracts. Bitemporal queries on long-lived histories (medical records, land registries, archival document stores) hit this in production despite being entirely valid xs:dateTime arithmetic.

This widens the field to int (Integer.MAX_VALUE ≈ 5.88M days) and removes the parser-time Short.MAX_VALUE ceilings.

Changes

  • Duration.getDays() returns int (was short).
  • DTD / Dur field, constructor, and parse-time local for days widened.
  • AbstractTimeInstant.subtract now narrows the long JDN diff to int and defends against > Integer.MAX_VALUE with ERR_OVERFLOW_UNDERFLOW_IN_DURATION (unreachable from any pure-Java entry point because LocalDate itself caps year at 999 999 999).
  • Cast and AdjustToTimezone callsites that construct DTD(false, (short) 0, ...) drop the obsolete (short) cast.

Tests

  • New randomized property tests at 10 000 iterations each (deterministic seeds) for DTD.add / DTD.subtract, YMD.add / YMD.subtract, DTD.add canonicalisation, and dateTime.subtract over a span of ±Short.MAX_VALUE × 4 days. Pre-widening, the dateTime test would have wrapped silently on most iterations.
  • Pinned regression cases for the previously-fixed sign-bit collision (-PT127H, -P1Y122M), the equal-hour subtraction phantom-29-day bug, and the 0th-of-month / January-borrow boundary cases all retained.
  • Full suite: 1042 tests, 0 failures, 0 errors, 4 skipped.

The randomized dateTime.subtract test uses assertEquals(exp, act, () -> "...") (lazy Supplier) — the eager String form constructed 3 toStrings + concat per iteration, generating enough GC churn to make the test wall-clock at >90 s. Lazy form completes in ~50 ms.

Test plan

  • Targeted: mvn test -Dtest=DateTimeTest — 22/22 pass in 0.157 s.
  • Full Brackit suite: mvn test — 1042/1042 pass in 7.9 s.
  • Downstream: no Duration.getDays() consumer in Sirix reads into short, so the widening is source-compatible.

…ncation bug

Duration.getDays() / DTD.days / Dur.days were 16-bit signed shorts. Any
xs:dateTime subtraction across more than Short.MAX_VALUE = 32 767 days
(~89.7 years) silently truncated mod 65 536, producing arithmetically
wrong durations. Bitemporal queries on long-lived histories (medical
records, land registries, archival document stores) hit this in
production despite being entirely valid xs:dateTime arithmetic.

Widens Duration.days to int (Integer.MAX_VALUE ≈ 5.88M years) and
removes the parser-time Short.MAX_VALUE checks. Constructor and parser
signatures shift from short to int for the days field; getDays() returns
int now. The high-bit-encoded sign on months is unchanged.

AbstractTimeInstant.subtract still defends against a long->int narrowing
overflow with ERR_OVERFLOW_UNDERFLOW_IN_DURATION, but that branch is
unreachable from any pure-Java entry point because LocalDate caps year
at 999_999_999.

Tests:
* property_dateTimeSubtract_preservesSignedInstantDifference exercises
  ±Short.MAX_VALUE × 4 days (~±359 years) over 10 000 random pairs.
  Pre-widening these would have wrapped silently.
* property_dtdAdd / property_dtdSubtract / property_ymdAdd /
  property_ymdSubtract / property_dtdAdd_resultIsCanonical at 10 000
  iters each prove the addInternal total-micros and total-months
  invariants from docs/formal-verification.md (Inv 1.1a, 1.2a, 1.3a,
  1.5a) hold across the widened range.
* All 1042 Brackit tests pass.

The randomized DateTime subtract test uses a Supplier<String> message
form for assertEquals — the eager String form was constructing 10 000 ×
3 toString() calls per iteration, generating enough GC pressure to make
the test appear hung at 90 s+ even though each iteration is O(1).
JohannesLichtenberger and others added 5 commits May 1, 2026 09:39
SonarCloud quality gate flagged 6.5% duplication on new code: the four
DTD/YMD add/subtract round-trip tests had identical bodies modulo the
op symbol and reify function. Extract a generic helper and a small
DurationOp functional interface (so we can reuse the QueryException-throwing
arithmetic methods as method refs) — collapses ~70 duplicated lines into
4 single-line helper invocations. All 22 DateTimeTest cases still pass.
…ication

DTD (xs:dayTimeDuration) and Dur (xs:duration) are sibling XML Schema
duration types. Their constructors, fields, and parser-local declaration
blocks necessarily share structure because the spec mandates it; deduping
them would require breaking the public API (constructor signatures are
exposed). The duplication predates PR #95 — the int-widening fix only
made the lines part of the new-code measurement window.

The DateTimeTest property-test duplication was real and got deduped via
runRoundTripProperty in the prior commit. This config addresses the
remaining structural duplication pulled into the new-code window by
the touched constructor signatures.
Sonar's CPD picked up two new duplicate clusters introduced by the prior
runRoundTripProperty refactor:

* The DTD/YMD subtract invocations were split across 6 lines and
  near-identical except for parameters. Hoist (x, y) -> x - y to a
  single static SUB constant and collapse each call to one line.
* Remove a leftover duplicated Javadoc block above
  property_dateTimeSubtract_preservesSignedInstantDifference (two
  consecutive doc comments, only one is rendered).
* In DTD.java, dedup the parser-local declaration block against
  Dur.java by combining same-typed locals on one line each. The
  semantic comments are consolidated into a single header comment
  that documents all four fields together.

All 22 DateTimeTest cases pass.
…or arithmetic properties

Three structural cleanups that together resolve SonarCloud's duplicate-
new-code finding (5.6% pre, target ≤3%):

1. DTD(String str) parser was a near-line-by-line copy of the {D, H, M, S}
   subset of Dur(String str). Replace it with a delegation: parse via
   Dur(str) and reject any year/month part. Eliminates ~140 lines of
   structurally-identical parsing logic and prevents the two parsers
   from drifting independently. Keeps the public API and error message
   semantics unchanged.

2. Convert the four DTD/YMD add/subtract round-trip property tests into
   one @ParameterizedTest with a MethodSource. Replaces four near-identical
   @test methods with one parameterized body — what JUnit 5 was designed
   to do for exactly this case.

3. Drop sonar-project.properties — SonarCloud automatic analysis ignores
   sonar.cpd.exclusions from the file (the setting is project-config only).
   Real structural dedup obviates the need for it anyway.
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented May 1, 2026

@JohannesLichtenberger JohannesLichtenberger merged commit af83b67 into master May 1, 2026
3 checks passed
@JohannesLichtenberger JohannesLichtenberger deleted the fix/datetime-subtract-borrow-bug branch May 1, 2026 08:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant