Skip to content

fix(spark): accept Substrait precisions coarser than microseconds - #1128

Merged
nielspardon merged 2 commits into
substrait-io:mainfrom
alexandrefimov:issue-1125-spark-precision
Aug 19, 2026
Merged

fix(spark): accept Substrait precisions coarser than microseconds#1128
nielspardon merged 2 commits into
substrait-io:mainfrom
alexandrefimov:issue-1125-spark-precision

Conversation

@alexandrefimov

@alexandrefimov alexandrefimov commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Spark stores timestamps and day-time intervals as a microseconds Long, and Util.assertMicroseconds refused any Substrait precision but 6. So a plan carrying interval_day<3> or precision_timestamp<0> did not convert to Spark at all, even though a coarser precision carries fewer digits than microseconds, not different ones, and fits that representation exactly.

The fix is a rescale rather than a relaxed guard, and it applies only where a value is in hand. Util.toMicroseconds scales a coarse value into microseconds and the three temporal literal conversions use it; the type conversions keep requiring exactly microseconds.

That split is the whole point. A Spark type carries no precision of its own, so mapping precision_timestamp<3> onto TimestampNTZType says nothing about the millisecond counts it describes, and a type conversion has no value at hand to rescale — a cast to a coarser precision becomes a cast between two TimestampNTZTypes and truncates nothing, and a column declared at one has its data read as microseconds. Both previously failed loudly on the exact-6 check and still do. What changes is that a literal, which does carry its value, converts.

So this covers the literal half of #1125 and leaves the column half where it was: a plan whose column is precision_timestamp<3> is still rejected. Making that work means rescaling data rather than mapping a type — a normalization pass over the plan — which is worth deciding on deliberately rather than acquiring as a side effect here. The Closes keyword is off this description for that reason.

A precision finer than microseconds is still rejected everywhere: it does not fit, and rounding it would be the silent kind of loss this is avoiding.

The reverse direction needs nothing, and the asymmetry mostly closes on its own: a coarse precision now enters only on a literal, is rescaled there, and leaves as 6, which is lossless; a column type at a coarse precision does not enter at all, so nothing widens.

Two things came along that are not strictly the fix:

  • The rescale multiplies, so it uses Math.multiplyExact, and the interval literal's days * 86400 * 1e6 does too. Neither overflows within the spec's ranges, but Long.MAX_VALUE at precision 0 used to wrap to -1000000 — one second before the epoch — rather than fail.
  • DialectGenerator declared max_precision = 9 for PRECISION_TIMESTAMP, PRECISION_TIMESTAMP_TZ and INTERVAL_DAY, which was already wrong before this PR, since the guard has always required 6. It reads Util.MICROSECOND_PRECISION now, and spark_dialect.yaml is regenerated. core's SparkDialectParseTest pins that number off the copied file, so it moves with it.

Part of #1125. Found by @nielspardon while reviewing #1120.

@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 fix for #1125 is right in substance — a coarse precision does fit Spark's microsecond representation, and scaling the literal value is the correct reading. My concern is placement: the guard you relaxed sat in the type visitor, which serves far more than literals, while the rescale only landed in the literal visits. That turns two previously-loud paths into silently-wrong ones. Details inline.

Two things that can't be anchored to the diff:

  • DialectGenerator.supportedTypes() still declares max_precision = Some(9) for PRECISION_TIMESTAMP, PRECISION_TIMESTAMP_TZ and INTERVAL_DAY, and the checked-in spark/spark_dialect.yaml agrees. This PR is what decides the answer is 0..6, so a producer reading the dialect to decide what to emit is told 9 is fine and then hits the rejection your new test asserts. DialectSuite can't catch the divergence — it only compares the generator against the file, and both hold the same number. Feeding these from Util.MICROSECOND_PRECISION keeps them in step.
  • The Spark→Substrait direction still hard-pins precision 6 (ToSubstraitType.scala:154-156, ToSubstraitLiteral.scala:38,103,105), so a coarse precision that now enters comes back out as 6. For a literal that's value-lossless since it was rescaled; for a column type the type widens while the data doesn't. Worth a deliberate decision rather than a side effect.

Unrelated to the code: per AGENTS.md ("Keep PR descriptions high-signal"), the closing paragraph of the description — "Verified on all three variants ... 62 tests each" — is worth removing, since the title and body become the squash-merge commit that semantic-release turns into CHANGELOG.md and CI already reports what passed. The rationale paragraphs above it are exactly what that guidance asks for.

Comment thread spark/src/main/scala/io/substrait/spark/ToSubstraitType.scala Outdated
Comment thread spark/src/main/scala/io/substrait/spark/utils/Util.scala
Comment thread spark/src/main/scala/io/substrait/spark/utils/Util.scala Outdated
Comment thread spark/src/main/scala/io/substrait/spark/utils/Util.scala Outdated
Comment thread spark/src/test/scala/io/substrait/spark/TypesAndLiteralsSuite.scala Outdated
Comment thread spark/src/test/scala/io/substrait/spark/TypesAndLiteralsSuite.scala Outdated
Comment thread spark/src/main/scala/io/substrait/spark/utils/Util.scala Outdated
Spark stores timestamps and day-time intervals as a microseconds Long, and the
conversion from Substrait refused any precision but 6 outright, so an
interval_day<3> or precision_timestamp<0> plan did not convert at all. A coarser
precision carries fewer digits, not different ones, so it fits that
representation exactly.

The check was load-bearing rather than merely cautious: the literal converters
pass the Substrait value through verbatim, so accepting a coarser precision
without scaling would have read milliseconds as microseconds. Scale the value
instead, and keep rejecting a precision finer than microseconds, which genuinely
does not fit.

The reverse direction is unchanged: Spark's physical value is always microseconds,
so it already emits precision 6.
Relaxing the guard in the type visitor covered cast targets and column
types too, where nothing rescales the values, so a plan asking for
millisecond resolution kept microsecond values. The type visitor goes
back to requiring exactly microseconds and the literal visits rescale,
which they can do because they hold the value.

Also: exact arithmetic in the rescale and in the interval literal,
a range message that covers both ends, and the dialect's max_precision
read from the same constant as the guard.
@alexandrefimov
alexandrefimov force-pushed the issue-1125-spark-precision branch from adefe80 to 2ef2689 Compare August 19, 2026 12:43
@alexandrefimov

Copy link
Copy Markdown
Contributor Author

All taken except one, with the details in the threads. Every claim in the review reproduced before I touched anything — the cast evaluating to 1234567 where the plan asks for 1234000, both overflow sentinels, and the backwards message on a negative precision.

Two notes on the pair that could not be anchored to the diff.

DialectGenerator reads Util.MICROSECOND_PRECISION now and spark_dialect.yaml is regenerated to 6. One consequence worth flagging: core's SparkDialectParseTest.parsesPrecisionTypes asserts that number off the copied file, so it moves with it and this PR now touches core/src/test. Say the word if you would rather have the dialect change stand on its own. Separately from the framing — the 9 was already wrong before this PR, since the guard has always required exactly 6, so it is a divergence this fixes rather than one it creates.

On the reverse direction: with the type conversions back to requiring exactly 6, the asymmetry mostly closes by itself. A coarse precision now enters only on a literal, is rescaled there, and leaves as 6, which is value-lossless; a column type at a coarse precision does not enter at all, so nothing widens. The decision that remains is the one in the first thread — whether the column case should be made to work through a normalization pass.

Description trimmed, and the Closes keyword taken off it for the reason in that thread.

@nielspardon

Copy link
Copy Markdown
Member

Re-reviewed at 2ef26890 — all threads resolved.

Two of the fixes are better than what I suggested: taking Math.multiplyExact to the pre-existing day multiply as well, and dropping expr.getType() from the literal visits entirely rather than keeping it. The latter also sidesteps #1134, where PrecisionTimeLiteral.getType() derives the wrong type class. The precision-6 round-trip asymmetry I raised in the review body is now moot by construction rather than patched, since nothing coarser than 6 can enter as a type.

Checked locally as well as in CI, since the shared Scala source compiles against two Scala versions: the suite is green on both spark-3.5_2.12 and spark-4.0_2.13, DialectSuite confirms the generated dialect still matches the checked-in yaml, and :spark:spotlessCheck is clean.

@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.

LGTM

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.

2 participants