Skip to content

Commit 934a1f1

Browse files
committed
feat: compile-time feature-gated Rust backtrace for JNI error diagnostics
1 parent 88d8faf commit 934a1f1

7 files changed

Lines changed: 356 additions & 8 deletions

File tree

java/lance-jni/Cargo.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

java/lance-jni/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ crate-type = ["cdylib"]
1414

1515
[features]
1616
default = []
17+
backtrace = ["lance/backtrace", "lance-core/backtrace"]
1718

1819
[dependencies]
1920
lance = { path = "../../rust/lance", features = ["substrait"] }

java/lance-jni/src/error.rs

Lines changed: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,28 +175,35 @@ impl std::fmt::Display for Error {
175175

176176
impl From<LanceError> for Error {
177177
fn from(err: LanceError) -> Self {
178+
let backtrace_suffix = err
179+
.backtrace()
180+
.map(|bt| format!("\n\nRust backtrace:\n{}", bt))
181+
.unwrap_or_default();
182+
let message = format!("{}{}", err, backtrace_suffix);
183+
178184
match &err {
179185
LanceError::DatasetNotFound { .. }
180186
| LanceError::DatasetAlreadyExists { .. }
181187
| LanceError::CommitConflict { .. }
182-
| LanceError::InvalidInput { .. } => Self::input_error(err.to_string()),
183-
LanceError::IO { .. } => Self::io_error(err.to_string()),
184-
LanceError::NotSupported { .. } => Self::unsupported_error(err.to_string()),
185-
LanceError::NotFound { .. } => Self::io_error(err.to_string()),
188+
| LanceError::InvalidInput { .. } => Self::input_error(message),
189+
LanceError::IO { .. } => Self::io_error(message),
190+
LanceError::NotSupported { .. } => Self::unsupported_error(message),
191+
LanceError::NotFound { .. } => Self::io_error(message),
186192
LanceError::Namespace { source, .. } => {
187193
// Try to downcast to NamespaceError and get the error code
188194
if let Some(ns_err) = source.downcast_ref::<NamespaceError>() {
189-
Self::namespace_error(ns_err.code().as_u32(), ns_err.to_string())
195+
let ns_message = format!("{}{}", ns_err, backtrace_suffix);
196+
Self::namespace_error(ns_err.code().as_u32(), ns_message)
190197
} else {
191198
log::warn!(
192199
"Failed to downcast NamespaceError source, falling back to runtime error. \
193200
This may indicate a version mismatch. Source type: {:?}",
194201
source
195202
);
196-
Self::runtime_error(err.to_string())
203+
Self::runtime_error(message)
197204
}
198205
}
199-
_ => Self::runtime_error(err.to_string()),
206+
_ => Self::runtime_error(message),
200207
}
201208
}
202209
}
@@ -234,3 +241,104 @@ impl From<Utf8Error> for Error {
234241
Self::input_error(err.to_string())
235242
}
236243
}
244+
245+
#[cfg(test)]
246+
mod tests {
247+
use super::*;
248+
249+
// Helper: extract the java_class from an Error via Display output
250+
fn java_class(err: &Error) -> &JavaExceptionClass {
251+
&err.java_class
252+
}
253+
254+
#[test]
255+
fn test_invalid_input_maps_to_illegal_argument() {
256+
let lance_err = LanceError::invalid_input("bad input");
257+
let jni_err: Error = lance_err.into();
258+
assert_eq!(
259+
*java_class(&jni_err),
260+
JavaExceptionClass::IllegalArgumentException
261+
);
262+
assert!(jni_err.message.contains("bad input"));
263+
}
264+
265+
#[test]
266+
fn test_dataset_not_found_maps_to_illegal_argument() {
267+
let lance_err = LanceError::dataset_not_found("my_dataset", "not found".to_string().into());
268+
let jni_err: Error = lance_err.into();
269+
assert_eq!(
270+
*java_class(&jni_err),
271+
JavaExceptionClass::IllegalArgumentException
272+
);
273+
assert!(jni_err.message.contains("my_dataset"));
274+
}
275+
276+
#[test]
277+
fn test_dataset_already_exists_maps_to_illegal_argument() {
278+
let lance_err = LanceError::dataset_already_exists("my_dataset");
279+
let jni_err: Error = lance_err.into();
280+
assert_eq!(
281+
*java_class(&jni_err),
282+
JavaExceptionClass::IllegalArgumentException
283+
);
284+
assert!(jni_err.message.contains("my_dataset"));
285+
}
286+
287+
#[test]
288+
fn test_commit_conflict_maps_to_illegal_argument() {
289+
let lance_err = LanceError::commit_conflict_source(42, "conflict".to_string().into());
290+
let jni_err: Error = lance_err.into();
291+
assert_eq!(
292+
*java_class(&jni_err),
293+
JavaExceptionClass::IllegalArgumentException
294+
);
295+
}
296+
297+
#[test]
298+
fn test_io_maps_to_ioexception() {
299+
let lance_err = LanceError::io("disk failure");
300+
let jni_err: Error = lance_err.into();
301+
assert_eq!(*java_class(&jni_err), JavaExceptionClass::IOException);
302+
assert!(jni_err.message.contains("disk failure"));
303+
}
304+
305+
#[test]
306+
fn test_not_supported_maps_to_unsupported() {
307+
let lance_err = LanceError::not_supported("nope");
308+
let jni_err: Error = lance_err.into();
309+
assert_eq!(
310+
*java_class(&jni_err),
311+
JavaExceptionClass::UnsupportedOperationException
312+
);
313+
assert!(jni_err.message.contains("nope"));
314+
}
315+
316+
#[test]
317+
fn test_not_found_maps_to_ioexception() {
318+
let lance_err = LanceError::not_found("missing_uri");
319+
let jni_err: Error = lance_err.into();
320+
assert_eq!(*java_class(&jni_err), JavaExceptionClass::IOException);
321+
assert!(jni_err.message.contains("missing_uri"));
322+
}
323+
324+
#[test]
325+
fn test_fallthrough_maps_to_runtime() {
326+
let lance_err = LanceError::internal("internal oops");
327+
let jni_err: Error = lance_err.into();
328+
assert_eq!(*java_class(&jni_err), JavaExceptionClass::RuntimeException);
329+
assert!(jni_err.message.contains("internal oops"));
330+
}
331+
332+
#[test]
333+
fn test_no_backtrace_suffix_when_backtrace_is_none() {
334+
// Without the backtrace feature enabled in lance-core default tests,
335+
// backtrace() returns None, so no suffix should be appended.
336+
let lance_err = LanceError::io("clean message");
337+
let jni_err: Error = lance_err.into();
338+
assert!(
339+
!jni_err.message.contains("Rust backtrace:"),
340+
"Expected no backtrace suffix, got: {}",
341+
jni_err.message
342+
);
343+
}
344+
}

java/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
<spotless.scala.scalafmt.version>3.7.5</spotless.scala.scalafmt.version>
4040
<spotless.delimiter>package</spotless.delimiter>
4141
<rust.release.build>false</rust.release.build>
42+
<rust.features></rust.features>
4243
<skip.build.jni>false</skip.build.jni>
4344
<shade.base>org.lance.shaded</shade.base>
4445
<spotless.license.header>
@@ -396,6 +397,9 @@
396397
<configuration>
397398
<path>lance-jni</path>
398399
<release>${rust.release.build}</release>
400+
<features>
401+
<feature>${rust.features}</feature>
402+
</features>
399403
<!-- Copy native libraries to target/classes for runtime access -->
400404
<copyTo>${project.build.directory}/classes/nativelib</copyTo>
401405
<copyWithPlatformDir>true</copyWithPlatformDir>
@@ -409,6 +413,9 @@
409413
<configuration>
410414
<path>lance-jni</path>
411415
<release>${rust.release.build}</release>
416+
<features>
417+
<feature>${rust.features}</feature>
418+
</features>
412419
<verbosity>-v</verbosity>
413420
</configuration>
414421
</execution>

rust/lance-core/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ proptest.workspace = true
5555
rstest.workspace = true
5656

5757
[features]
58+
# Capture Rust backtraces in error types. When disabled (the default),
59+
# the backtrace field is zero-sized with no overhead. At runtime, capture
60+
# is still gated by RUST_BACKTRACE=1.
61+
backtrace = []
5862
datafusion = ["dep:datafusion-common", "dep:datafusion-sql"]
5963

6064
[lints]

0 commit comments

Comments
 (0)