Java-Rust JNI Bindings in OpenDAL#
OpenDAL's Java bindings expose the core Rust blocking::Operator to Java via the Java Native Interface (JNI), implemented using the jni crate. The binding layer lives in bindings/java/src/ and bridges two type systems, two memory models, and two error-handling conventions.
Key source files:
| Layer | File |
|---|---|
| Rust JNI entry point | bindings/java/src/lib.rs |
| Operator JNI functions | bindings/java/src/operator.rs |
| Error conversion | bindings/java/src/error.rs |
| Input stream JNI | bindings/java/src/operator_input_stream.rs |
| Output stream JNI | bindings/java/src/operator_output_stream.rs |
Java Operator class | Operator.java |
Java OperatorInputStream | OperatorInputStream.java |
Java OperatorOutputStream | OperatorOutputStream.java |
Function Naming Convention#
JNI resolves native methods by symbol name. Every Rust function exported to Java follows the pattern:
Java_{package_underscored}_{ClassName}_{methodName}
For example, Java_org_apache_opendal_Operator_read maps to the Java declaration private static native byte[] read(long op, String path, ReadOptions options) in Operator.java . All exported functions are #[no_mangle] pub unsafe extern "system".
Return Type Consistency#
Java native declarations and their Rust counterparts must agree on types. The mapping follows standard JNI conventions:
| Java type | Rust JNI type |
|---|---|
void | () (no return) |
long | jlong |
byte[] | jbyteArray |
Object (any) | jobject |
Object[] | jobjectArray |
Examples from the codebase:
write→void: Java declaresnative void write(...); Rust function returns().read→byte[]: Java declaresnative byte[] read(...); Rust returnsjbyteArray.stat→Metadata(object): Java declaresnative Metadata stat(...); Rust returnsjobject.list→Entry[](array): Java declaresnative Entry[] list(...); Rust returnsjobjectArray.constructReader→long(pointer): Java declaresnative long constructReader(long op, String path); Rust stores theStdBytesIteratorpointer as ajlong.
⚠️ Mismatch pitfall:
createDir,copy, andrenameare declared asnative longin Java but their Rust implementations return()(void). These return values are unused on the Java side and exist only as a declaration artifact. When adding new methods, ensure the Java return type matches the Rust return type to avoid silent UB.
Error Propagation Pattern#
JNI does not support Rust's Result type natively. OpenDAL's binding layer uses a two-function pattern to propagate errors as Java exceptions:
- Inner function (
intern_*): Returnscrate::Result<T>and uses?for early exit on errors. This is ordinary Rust error handling. - Outer exported function: Calls the inner function, then handles errors with
.unwrap_or_else(|e| { e.throw(&mut env); <default_value> }).
When an error occurs, e.throw(&mut env) calls env.throw() via JNI to set a pending exception on the Java thread. The outer function returns a safe default value (e.g., JByteArray::default().into_raw() for byte arrays, JObject::default().into_raw() for objects, or 0 for longs), which Java ignores once the exception is pending.
See this pattern across: operator.rs read, operator.rs write, operator_input_stream.rs constructReader, and operator_output_stream.rs writeBytes.
Exception Type: OpenDALException#
All Rust errors are converted to org.apache.opendal.OpenDALException . The to_exception method maps opendal::ErrorKind variants to string error codes:
ErrorKind | String code |
|---|---|
NotFound | "NotFound" |
PermissionDenied | "PermissionDenied" |
AlreadyExists | "AlreadyExists" |
Unsupported | "Unsupported" |
ConfigInvalid | "ConfigInvalid" |
IsADirectory / NotADirectory | "IsADirectory" / "NotADirectory" |
RateLimited | "RateLimited" |
IsSameFile | "IsSameFile" |
ConditionNotMatch | "ConditionNotMatch" |
RangeNotSatisfied | "RangeNotSatisfied" |
| all others | "Unexpected" |
JNI errors (e.g., from type conversions) are also funneled through this path: jni::errors::Error converts to an opendal::Error with kind Unexpected , so all errors surface uniformly as OpenDALException.
Native Object Lifetime and Memory#
Rust objects (operators, readers, writers) are heap-allocated and passed to Java as jlong pointer values stored in a NativeObject base class. Java owns the lifecycle: when close() or disposeInternal() is called, the corresponding Rust drop or close runs. For example:
Operator.disposeInternalcalls Rustdrop(Box::from_raw(op)).OperatorOutputStream.disposeWritercallswriter.close()to flush before dropping .
Stream I/O#
OperatorInputStream wraps a StdBytesIterator that yields byte chunks. The Java side buffers one chunk and drains it byte-by-byte via read() . A null return from Rust readNextBytes signals EOF .
OperatorOutputStream buffers writes up to a configurable maxBytes (default 16 384) then flushes by calling native writeBytes to a blocking::Writer . The writer is closed (and the stream committed) in disposeWriter, which calls writer.close() .