Skip to content

Preserve nanosecond precision in Duration values#192

Open
welpie21 wants to merge 3 commits into
surrealdb:mainfrom
welpie21:fix/duration-nanosecond-precision
Open

Preserve nanosecond precision in Duration values#192
welpie21 wants to merge 3 commits into
surrealdb:mainfrom
welpie21:fix/duration-nanosecond-precision

Conversation

@welpie21

Copy link
Copy Markdown

The bug

SurrealDB durations are nanosecond-precise (u64 seconds + u32 subsec nanos), but the SDK truncated them to milliseconds in both directions across the JNI boundary:

  • Write: ValueMut.createDuration(Duration d) passed d.toMillis() to newDuration(long millis).
  • Read: Value.getDuration() rebuilt the value with Duration.ofMillis(getDuration(ptr)), where the Rust side returned d.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)

Case Expected Observed
RETURN duration::from_nanos(1234567)getDuration() PT0.001234567S PT0.001S
RETURN duration::from_nanos(1500)getDuration() PT0.0000015S PT0S
RETURN duration::from_nanos(1000000500)getDuration() PT1.0000005S PT1S
Bind Duration.ofNanos(1500), RETURN $d = duration::from_nanos(1500) true false
POJO round-trip of Duration.ofSeconds(90061, 123) PT25H1M1.000000123S PT25H1M1S
Bind Duration.ofNanos(1), RETURN $d PT0.000000001S PT0S

The fix

The JNI boundary now carries (seconds, nanos) pairs, mirroring the existing datetime handling (a single i64 of nanos was rejected since it only covers ~292 years, far less than SurrealDB can store):

  • Write: newDuration(long seconds, int nanos); Java passes d.getSeconds() / d.getNano(), Rust builds Duration::new(seconds as u64, nanos as u32).
  • Read: getDuration(ptr) returns a long[2] of {seconds, subsecNanos} (same shape as getDateTime); Java rebuilds with Duration.ofSeconds(seconds, nanos).

Edge cases (documented in javadoc and tested):

  • Negative durations on write throw SurrealException — SurrealDB durations are unsigned, so there is no faithful representation (previously toMillis() as u64 would have wrapped to a huge positive duration).
  • Reads beyond java.time.Duration range (stored seconds > Long.MAX_VALUE, i.e. beyond ~292 billion years) throw SurrealException instead of silently corrupting. Everything up to Long.MAX_VALUE seconds round-trips exactly.

Tests

New DurationPrecisionTests (embedded memory engine) covering:

  • Read path: duration::from_nanos for sub-millisecond, sub-microsecond, and mixed seconds+nanos values.
  • Write path: bound-parameter equality against duration::from_nanos(1500).
  • Round-trips via bound params: zero, 1 ns, 999 ns, 1500 ns, micros, millis, mixed 90061.000000123s, ~5000 years, and ~10,000 years with a 999999999 ns fraction.
  • POJO create/select round-trip with a nanosecond-fraction duration.
  • Negative durations rejected with SurrealException.

Full ./gradlew test (33 classes) and ./gradlew recordTest are green; existing millisecond-based duration tests pass unchanged.

🤖 Generated with Claude Code

welpie21 and others added 2 commits July 10, 2026 14:48
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>
@welpie21 welpie21 marked this pull request as ready for review July 10, 2026 14:28

@emmanuel-keller emmanuel-keller left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread src/main/rust/valuemut.rs
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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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

Comment thread src/main/rust/value.rs
if let Value::Duration(d) = value.as_ref() {
d.as_millis() as jlong
let seconds = d.as_secs();
if seconds > i64::MAX as u64 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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