Skip to content

fix(isthmus): convert pre-epoch temporal literals instead of throwing - #1127

Open
alexandrefimov wants to merge 2 commits into
substrait-io:mainfrom
alexandrefimov:issue-1126-preepoch-timestamp
Open

fix(isthmus): convert pre-epoch temporal literals instead of throwing#1127
alexandrefimov wants to merge 2 commits into
substrait-io:mainfrom
alexandrefimov:issue-1126-preepoch-timestamp

Conversation

@alexandrefimov

@alexandrefimov alexandrefimov commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

getTimestampString and createTimeString split a temporal value into whole seconds and a nanosecond remainder, once per supported precision, with truncating division:

          long seconds = TimeUnit.MILLISECONDS.toSeconds(value);
          int fracSecondsInNano =
              (int) (TimeUnit.MILLISECONDS.toNanos(value) - TimeUnit.SECONDS.toNanos(seconds));

Before the epoch the value is negative and truncation rounds towards zero, so the remainder comes out negative — and TimestampString.withNanos rejects that. A precision_timestamp of -500 at precision 3, which is 1969-12-31 23:59:59.5, did not convert at all; it threw an IllegalArgumentException carrying no message.

Split with floorDiv / floorMod instead, so the remainder is never negative, and share the split between the two methods rather than repeating it four times each. The set of supported precisions (0, 3, 6, 9) is unchanged.

The second commit adds a rejection path precision_time did not have: it is a time of day, so a value below zero or a day or more is reported by value. Both ends read worse before — a negative value came back from TimeString.fromMillisOfDay as a corrupt time string, and a large one as either Guava's Hour out of range: [24] or, once the millisecond count overflows an int, a silently wrong time (4346734 seconds converted to 14:22:46). The bound is our reading of what a precision_time is; algebra.proto declares the field a signed int64 with no stated range.

The new tests fail on main — the pre-epoch one with exactly the reported exception, the range one because nothing is thrown at all.

Closes #1126.

@alexandrefimov

Copy link
Copy Markdown
Contributor Author

Self-review turned up one thing in my own change, fixed in 0f6168d.

Flooring the split means a negative value no longer reaches withNanos, but for createTimeString it reaches TimeString.fromMillisOfDay instead, which reports Invalid time format: [00:00:0/] — a corrupt string rather than the value that was wrong. A precision_time is a time of day and cannot be negative, so it is now rejected by value with a message naming it. It threw before this PR too, with the bare IllegalArgumentException from withNanos, so this is a message rather than a behaviour change.

Two other checks came back clean: both Cannot handle PrecisionTime/PrecisionTimestamp with precision %d. messages are byte-identical to before, and nothing outside ExpressionRexConverter calls either method, so the shared split has no other callers to surprise.

One thing worth knowing for whoever reads the test: precision 9 is unreachable through this path even though the switch accepts it — toCalcite rejects precision_timestamp<9> first, per #995. So the test covers 0, 3 and 6.

@nielspardon nielspardon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The floorDiv/floorMod split is the right fix, and sharing it between the two methods is a real improvement.

This overlaps #1121 — both rewrite getTimestampString and createTimeString with the same split, so whichever lands second will conflict. I'd take this one first and let #1121's generalization sit on top; no need to act on the {0,3,6,9} Javadoc here, since #1121 replaces that switch outright.

One for the description, which becomes the squash-merge commit message: "both failure messages are unchanged" is no longer true — the second commit adds a third, and a rejection path precision_time didn't have.

Comment thread isthmus/src/main/java/io/substrait/isthmus/expression/ExpressionRexConverter.java Outdated
Comment thread isthmus/src/test/java/io/substrait/isthmus/CalciteLiteralTest.java Outdated
getTimestampString and createTimeString split a value into whole seconds and a
nanosecond remainder with truncating division. Before the epoch the value is
negative, so truncation leaves a negative remainder, and both TimestampString and
TimeString reject that with a bare IllegalArgumentException: a precision_timestamp
of -500 at precision 3, which is 1969-12-31 23:59:59.5, did not convert at all.

Split with floorDiv/floorMod so the remainder is never negative, and share the
split between the two methods rather than repeating it once per supported
precision. The set of supported precisions and both failure messages are
unchanged.
Found reviewing the change above. A precision_time is a time of day, so a value
below zero or a day or more is not one. Neither is reported as such today: a
negative value reaches TimeString.fromMillisOfDay and comes back as a corrupt
time string (Invalid time format: [00:00:0/]), and a value of a day or more is
either Guava's "Hour out of range: [24]" from inside TimeString or, once the
millisecond count overflows an int, a silently wrong time - 4346734 seconds
converts to 14:22:46. A range check names the value instead, and makes the
narrowing to int provably safe.
@alexandrefimov
alexandrefimov force-pushed the issue-1126-preepoch-timestamp branch from 12bcfed to 3ff105d Compare August 21, 2026 17:11
@alexandrefimov

Copy link
Copy Markdown
Contributor Author

Rebased onto main and reworked per the review: the guard is a range check now, the test covers both ends of the domain with the boundary second either side, and the description no longer claims the failure messages are unchanged. Taking this one first, with #1121 to follow on top.

Unrelated to this PR, found while checking the branch: ./gradlew :isthmus:build fails on a clean origin/main with every isthmus test throwing NoClassDefFoundError: org/antlr/v4/runtime/tree/ParseTreeVisitor. :core:jar and :core:shadowJar share an output file and there the plain one lands last. Filed as #1142.

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.

isthmus: a pre-epoch precision_timestamp with a fractional second throws converting to Calcite

2 participants