Preserve nanosecond precision in Duration values#192
Conversation
SurrealDB durations are nanosecond-precise (u64 seconds + u32 subsec
nanos), but the JNI boundary truncated them to milliseconds in both
directions: ValueMut.createDuration() passed Duration.toMillis() and
Value.getDuration() rebuilt from a millisecond count. Sub-millisecond
durations silently collapsed to zero and mixed values lost their
nanosecond fraction.
The JNI boundary now carries (seconds, nanos) pairs, mirroring the
existing datetime handling: newDuration(long seconds, int nanos) on the
write path and a long[2] of {seconds, subsecNanos} on the read path.
Negative durations are rejected with a SurrealException (SurrealDB
durations are unsigned), and reading a stored duration beyond
java.time.Duration's range (seconds > Long.MAX_VALUE) throws instead of
silently corrupting.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
emmanuel-keller
left a comment
There was a problem hiding this comment.
Review — approve with nits.
I verified the numerics end-to-end and found no correctness bug: both directions are lossless (no millis/f64 intermediate — it's a (seconds, nanos) pair mirroring the datetime handling), overflow-safe at both bounds (Duration::new can't carry since nanos < 1e9; Duration.ofSeconds(secs, subsec) has floorDiv == 0 so no addExact overflow even at Long.MAX_VALUE), and negatives are all caught by the seconds < 0 guard (every negative java.time.Duration has seconds <= -1). Exception paths use the correct defaults and mirror the shipped getDateTime/newDatetime pattern. Strong test suite.
Minor test gaps: no round-trip at the exact upper bound Duration.ofSeconds(Long.MAX_VALUE, 999_999_999), and the read-overflow reject path (seconds > i64::MAX) has no test asserting it raises.
| let value = Value::Duration(Duration::from_millis(millis as u64)); | ||
| JniTypes::new_value_mut(value) | ||
| with_env_body!(env, env, { | ||
| if seconds < 0 || nanos < 0 { |
There was a problem hiding this comment.
The nanos < 0 disjunct is unreachable via the public API — Duration.getNano() is always 0..=999_999_999. Harmless as defense-in-depth against direct native misuse, just noting it's effectively dead given the only caller.
There was a problem hiding this comment.
Agreed — it's unreachable via the public API since the only caller passes Duration.getNano() (always 0..=999_999_999). Kept the guard as defense-in-depth against direct native misuse and added a comment saying exactly that (7ad4cd7).
| if let Value::Duration(d) = value.as_ref() { | ||
| d.as_millis() as jlong | ||
| let seconds = d.as_secs(); | ||
| if seconds > i64::MAX as u64 { |
There was a problem hiding this comment.
This read-overflow reject path (seconds > i64::MAX) is only reachable from a server-side duration exceeding i64::MAX seconds (Java can't construct one), and it's currently untested. A one-line comment on reachability plus a test asserting SurrealException would be nice.
There was a problem hiding this comment.
Done in 7ad4cd7: added the reachability comment, and it turned out the overflow duration is producible server-side — the SurrealQL duration lexer parses seconds as u64, so the literal 10000000000000000000s (10^19 s > i64::MAX) evaluates to a valid server duration. Added serverSideDurationBeyondJavaRangeIsRejectedOnRead to DurationPrecisionTests: it runs RETURN 10000000000000000000s on the memory engine, asserts isDuration() is true, and asserts getDuration() throws SurrealException with the exact documented message.
…tion - valuemut.rs: note that the nanos < 0 disjunct is unreachable via the public Java API (Duration.getNano() is 0..=999_999_999) and is kept as defense-in-depth against direct native misuse. - value.rs: note that the seconds > i64::MAX reject path is only reachable from a server-side duration; Java cannot construct one. - Add a test producing such a duration server-side via the SurrealQL literal 10000000000000000000s and asserting getDuration() throws SurrealException with the documented message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bug
SurrealDB durations are nanosecond-precise (
u64seconds +u32subsec nanos), but the SDK truncated them to milliseconds in both directions across the JNI boundary:ValueMut.createDuration(Duration d)passedd.toMillis()tonewDuration(long millis).Value.getDuration()rebuilt the value withDuration.ofMillis(getDuration(ptr)), where the Rust side returnedd.as_millis().The JS SDK (
packages/sqon/src/value/duration.ts) keeps durations nanosecond-precise, so the two SDKs disagreed on the same data.Reproduction (before the fix)
RETURN duration::from_nanos(1234567)→getDuration()PT0.001234567SPT0.001SRETURN duration::from_nanos(1500)→getDuration()PT0.0000015SPT0SRETURN duration::from_nanos(1000000500)→getDuration()PT1.0000005SPT1SDuration.ofNanos(1500),RETURN $d = duration::from_nanos(1500)truefalseDuration.ofSeconds(90061, 123)PT25H1M1.000000123SPT25H1M1SDuration.ofNanos(1),RETURN $dPT0.000000001SPT0SThe fix
The JNI boundary now carries
(seconds, nanos)pairs, mirroring the existing datetime handling (a singlei64of nanos was rejected since it only covers ~292 years, far less than SurrealDB can store):newDuration(long seconds, int nanos); Java passesd.getSeconds()/d.getNano(), Rust buildsDuration::new(seconds as u64, nanos as u32).getDuration(ptr)returns along[2]of{seconds, subsecNanos}(same shape asgetDateTime); Java rebuilds withDuration.ofSeconds(seconds, nanos).Edge cases (documented in javadoc and tested):
SurrealException— SurrealDB durations are unsigned, so there is no faithful representation (previouslytoMillis() as u64would have wrapped to a huge positive duration).java.time.Durationrange (stored seconds >Long.MAX_VALUE, i.e. beyond ~292 billion years) throwSurrealExceptioninstead of silently corrupting. Everything up toLong.MAX_VALUEseconds round-trips exactly.Tests
New
DurationPrecisionTests(embeddedmemoryengine) covering:duration::from_nanosfor sub-millisecond, sub-microsecond, and mixed seconds+nanos values.duration::from_nanos(1500).90061.000000123s, ~5000 years, and ~10,000 years with a999999999ns fraction.SurrealException.Full
./gradlew test(33 classes) and./gradlew recordTestare green; existing millisecond-based duration tests pass unchanged.🤖 Generated with Claude Code