Repository navigation
[CORE] Preserve Java throwables at the JNI boundary - #13560
felipepessoto wants to merge 12 commits into
Conversation
e7bf0b6 to
8aff74c
Compare
Split layer 2 of apache#13133, depending on apache#13560. Preserve the Java carrier through Velox input streams and lazy output loading, with focused native, real-JVM, and Delta constraint regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Hi @zhztheplayer @marin-ma @zhouyuan, could you review #13560? It’s the core-only split from #13133, covering Java throwable preservation and JNI reference lifetime. The Velox integration is in separate PRs, and CI is green. Thanks! |
There was a problem hiding this comment.
🟡 Changes recommended
Carrier allocation failure can discard the original Java throwable and replace it with a bad_alloc-based GlutenException; construction needs a restoration guard and targeted failure coverage.
1 open issue
What changed in this PR
This PR adds backend-neutral transport for original Java throwables across JNI boundaries. It is the core layer of the split implementation; backend-specific propagation and hybrid Java/C++ stack traces remain outside its scope.
Changes:
- Add
JavaExceptionwith shared global-reference ownership and thread-aware cleanup. - Update JNI exception handling to restore direct and standard nested Java carriers while retaining native-error fallback.
- Register focused lifetime and failure-path tests, isolate allocator fault injection, and document the transport contract.
| File | Description |
|---|---|
| docs/developers/NewToGluten.md | Document exception preservation and scope limits. |
| cpp/core/tests/utils/TestJniEnvironment.h | Add a mock JNI environment for ownership and failure tests. |
| cpp/core/tests/JavaExceptionTest.cc | Test lifetime, thread cleanup, nesting, and JNI failures. |
| cpp/core/tests/JavaExceptionAllocationTest.cc | Test diagnostic allocation failure in isolation. |
| cpp/core/tests/CMakeLists.txt | Register both exception test executables. |
| cpp/core/jni/JniError.h | Restore original throwables at JNI exit boundaries. |
| cpp/core/jni/JniCommon.h | Delegate callback exception capture to JavaException. |
| cpp/core/jni/JavaException.h | Define the throwable carrier interface. |
| cpp/core/jni/JavaException.cc | Implement ownership, diagnostics, cleanup, and restoration. |
| cpp/core/CMakeLists.txt | Include the carrier in the core library build. |
Review effort: Balanced
Split layer 2 of apache#13133, depending on apache#13560. Preserve the Java carrier through Velox input streams and lazy output loading, with focused native, real-JVM, and Delta constraint regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
zhztheplayer
left a comment
There was a problem hiding this comment.
Thanks. Can we have light Java-side test cases to cover the changes?
|
Run Gluten Clickhouse CI on x86 |
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
3 open issues
activeByDefaultprofiles are only active when no other profiles are explicitly enabled. In this… · New Thejni_testchange detector matchespom\\.xmlat the repository root, which will trigger the… · New Thejni_testchange detector matchespom\\.xmlat the repository root, which will trigger the… · New
3 resolved since last review
Review effort: Lite
412c773 to
bb6b836
Compare
rdtr
left a comment
There was a problem hiding this comment.
Thanks for splitting this out, @felipepessoto, and for the Java-side tests, including the already-pending cases and the named local capacity in the latest push. I read the diff and the surrounding callers, and I also looked at #13561 and #13563 where they use this API. This is from reading the code only, I did not build or run anything. The reference handling reads correct to me. The global ref is deleted once by the last shared_ptr owner, copies and exception_ptr handoffs share it, and the detach only happens on a thread the destructor attached itself. Three inline comments, none of them blocking from my side. The first is a risk on attached pool threads when memory is short, the second is a question about the doc and ClosableIterator, and the third is a small doc suggestion.
| try { | ||
| return JavaException(env, throwable); | ||
| } catch (...) { | ||
| if (!env->ExceptionCheck() && env->Throw(throwable) != JNI_OK) { |
There was a problem hiding this comment.
Risk, only when memory is short. When the JavaException can't be constructed here (make_shared<State> throws or NewGlobalRef returns null), this line puts the original throwable back as a pending exception with env->Throw(throwable) and rethrows the C++ error.
Normally the thread entered native code through a JNI method, so the C++ error unwinds to JNI_METHOD_END, which sees the pending exception, leaves it alone and returns to Java. For example on the input iterator path:
ColumnarBatchOutIterator.nativeHasNextenters native code underJNI_METHOD_START.
gluten/cpp/core/jni/JniWrapper.cc
Lines 450 to 462 in bb6b836
- Velox runs the task serially on that same thread, and the input operator calls
RowVectorStream::hasNext.
gluten/cpp/velox/compute/WholeStageResultIterator.cc
Lines 124 to 138 in bb6b836
gluten/cpp/velox/operators/plannodes/RowVectorStream.cc
Lines 51 to 78 in bb6b836
JniColumnarBatchIterator::nextInternalcalls the Java iterator and thencheckException, which isJavaException::check.
gluten/cpp/core/jni/JniCommon.cc
Lines 300 to 311 in bb6b836
gluten/cpp/core/jni/JniCommon.h
Lines 56 to 57 in bb6b836
- Construction fails and this line restores the throwable.
gluten/cpp/core/jni/JavaException.cc
Lines 168 to 176 in bb6b836
- The C++ error unwinds to the
std::exceptionbranch ofJNI_METHOD_END.env->ExceptionCheck()is true there, so it neither restores a carrier nor throwsGlutenException, andnativeHasNextreturnsfalse.
gluten/cpp/core/jni/JniError.h
Lines 43 to 51 in bb6b836
The JVM then throws the original in Java when the native method returns, which also clears it from the thread.
However, when the thread was started by Gluten's C++ code or by Velox, there is no Java frame below it to return to. Examples are the folly pools in VeloxBackend (spill, IO, broadcast build).
gluten/cpp/velox/compute/VeloxBackend.cc
Lines 250 to 274 in bb6b836
Jobs submitted through
HookedExecutor attach the thread before they run, and SparkThreadInitializer::destroy deliberately doesn't detach afterwards, so the thread stays attached for later jobs.gluten/cpp/velox/compute/VeloxRuntime.cc
Lines 167 to 205 in bb6b836
gluten/cpp/core/jni/JniCommon.h
Lines 611 to 631 in bb6b836
The
SparkAllocationListener constructor comment already expects calls from IO threads.gluten/cpp/core/jni/JniCommon.h
Line 403 in bb6b836
On such a thread the C++ error is caught somewhere in C++, nothing clears the pending exception, and the thread goes back to its pool with it. The next job on that thread, possibly for a different task, makes a JNI call with an exception pending, and its checkException reports the old throwable as its own error. If NewGlobalRef fails with a pending OutOfMemoryError, line 172 keeps that one pending instead, with the same effect. Before this PR checkException always cleared the exception, so this is new.
Could the restore happen only when a JNI entry is on the stack? For example JNI_METHOD_START could hold a small RAII guard that bumps a thread_local depth counter, and check() would restore only when the depth is above zero and clear otherwise. Pool threads that attached themselves have depth zero, so they keep the old clearing behavior, and a JNI method that doesn't use the macro would only lose the original in this rare case. If that's too much for this PR, always clearing here as checkException did before would also avoid the carry-over. JNI_METHOD_END would then report the C++ error as a GlutenException, so only the original throwable is lost, and only when memory is short.
There was a problem hiding this comment.
Added a nonallocating JniEntryGuard to JNI_METHOD_START. Failed carrier construction now restores or retains pending Java state only when the current thread has a guarded JNI entry. Otherwise it clears the pending exception before propagating the native failure, without detaching the worker. Successfully captured carriers still use shared global references and remain transferable across threads.
The regressions reproduce both initial-state and global-reference allocation failures on native workers. They now verify that the same attached worker can execute another Java callback, while guarded JNI entries still preserve the original or newer pending exception. Nested Java reentry and cross-library scope checks also pass.
| bool pushed_; | ||
| }; | ||
|
|
||
| std::string describe(JNIEnv* env, jthrowable throwable) { |
There was a problem hiding this comment.
Could describe get a short comment on where its result ends up? Reading only this file, the message looks diagnostic-only, since JNI_METHOD_END rethrows the original and only falls back to what() when that fails. With the pinned Velox a throw inside an operator call is caught by CALL_OPERATOR and rethrown as VELOX_FAIL("Operator::{} failed for [...]: {}", ..., e.what()).
https://github.com/IBM/velox/blob/6deac80271569277bf54b6d04547620c17438348/velox/exec/Driver.cpp#L74-L84
https://github.com/IBM/velox/blob/6deac80271569277bf54b6d04547620c17438348/velox/exec/Driver.cpp#L413-L414
#13561 and #13563 wrap the input-iterator and memory callbacks so the object survives that, but the jol:/jni: spill filesystem callbacks in JniFileSystem.cc, used when spillFileSystem=heap-over-local is set, aren't wrapped in any of the three PRs. On that path this string stays the only part of the Java exception that reaches Spark. The destructors there also log only e.what().
gluten/cpp/velox/jni/JniFileSystem.cc
Lines 83 to 84 in bb6b836
gluten/cpp/velox/jni/JniFileSystem.cc
Lines 164 to 165 in bb6b836
A line like "Used by what(). Paths that can't carry the JavaException itself, such as Velox operator wrapping and destructor logging, only see this text." would explain why it is built eagerly and why failures fall back to a placeholder.
There was a problem hiding this comment.
Added a short comment above describe explaining that lossy native wrappers and destructor logging may have only what(), not the carrier. Eager best-effort formatting and the placeholder fallback are unchanged. This core PR does not add carrier wrapping to the jni:/jol: filesystem callbacks.
|
|
||
| JNI callbacks must call `checkException(env)` after invoking Java. It throws | ||
| `gluten::JavaException`, which owns a shared global reference to the original Java | ||
| throwable. `JNI_METHOD_END` restores that same object with JNI `Throw`, retaining |
There was a problem hiding this comment.
Question. This says JNI_METHOD_END restores the same object, and the JNI boundary does. On the main iterator path the object is wrapped again right after. ClosableIterator.hasNext() and next() catch Exception and call translateException, and ColumnarBatchOutIterator.translateException returns new GlutenException(e) unless the message matches a schema error.
Code that matches on the top-level class or SparkThrowable sees GlutenException, with the original only as the cause. The tests in #13561 show the same thing. The identity checks call hasNext0()/next0() directly, and nested native iterators retain the actual Java cause expects GlutenException -> GlutenException -> original through outer.hasNext().
Neither #13561 nor #13563 touches ClosableIterator. Is the wrapping intended to stay? If so, I think the doc should say callers of hasNext()/next() get the original as a cause. If not, this seems like the place to stop wrapping an exception that came from a Java callback.
There was a problem hiding this comment.
Yes, the existing Java-side wrapping is intended to stay. Updated the documentation and PR description to make the distinction explicit: the same throwable is restored at the JNI return boundary, while public hasNext()/next() may wrap an Exception in GlutenException or the existing schema-error translation, preserving the original in the cause chain.
Callers therefore cannot assume the original is the top-level class or SparkThrowable. ClosableIterator and ColumnarBatchOutIterator behavior is unchanged; the preservation guarantee is not a change to their wrapping policy.
Split the backend-neutral exception carrier and core regression coverage from apache#13133. Preserve shared throwable ownership, best-effort diagnostics, JNI restoration and native-only fallback without the later Velox or memory-callback layers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Guard carrier construction while the original local throwable is still alive. Restore it only if JNI has no newer pending exception, and keep successful carrier propagation outside the guard. Cover allocation failure before State acquisition and document the capture-failure contract. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Exercise production checkException and JNI_METHOD_END in a small real-JVM CTest suite, without Spark or a backend. Cover original throwable identity and metadata, direct and nested propagation, native-only fallback and successful return. Build the fixture with the core native tests and include its Java source in Spotless. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Move the four real-JVM cases into gluten-arrow/src/test/java and normal Surefire execution. Enable the explicit jni-test profile in the existing Spark 3.5 Java CI job and transfer its test-only native bridge as a separate artifact. Remove the standalone Java main and CTest jar runner; keep production transport and native entrypoints unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Build the test-only JNI bridge and run its JUnit suite in the existing native/UDF job. Remove the separate test artifact and Spark group-specific path, and add narrow triggers for the Java suite and its build wiring. Keep the tests, Maven profile and production transport unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Move the JNI-only exclusion into a default module profile that appends inherited exclusions. Enabling jni-test disables only that profile instead of clearing the complete Surefire exclusion list. Preserve the existing native loading and CI contracts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Catch standard JNI-boundary errors by const reference without changing matching or fallback behavior. Restore the diagnostic-allocation test JNI function table with a local nonallocating guard before its stack replacement expires, including during unwinding. Keep catch-all handling out of scope. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Check pending JNI state after direct and nested carrier restoration. Attempt the existing GlutenException fallback only if no Java exception is pending, preserve newer pending errors, and log fallback failure. Cover the failed-restore paths with scoped real-JVM fault injection and name the unchanged diagnostic local-frame capacity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Guard the direct JavaException handler before restoring the original throwable, matching the nested path. Add real-JVM direct and nested regression coverage for a newer exception installed during carrier propagation before JNI_METHOD_END. Keep existing restoration-failure fallback and native-only behavior unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Track nested guarded JNI entries on the current thread without retaining throwables or allocating guard state. Failed carrier capture restores pending Java state only within such an entry; otherwise clear it before native failure propagates. Preserve successful cross-thread carrier transport, verify worker reuse and nested reentry, and clarify diagnostics and public iterator cause-chain behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Clarify that a pending Java allocation error from failed global-reference capture is preserved only within a guarded JNI entry; native-only workers follow the clearing contract above. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
c7a7586 to
8917db5
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Cross-thread JNI cleanup and allocation-failure recovery alter the shared JNI contract, requiring human validation of native/JVM CI evidence, including cross-library entry tracking, beyond static review.
No open issues 🎉
Review effort: Balanced
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Thanks @rdtr. I sent the changes and CI is green |


What changes are proposed in this pull request?
Java callback failures currently become text in
checkExceptionand return as a newGlutenException. This loses the original exception type, cause chain, and Spark/Delta metadata. This patch preserves the same Java throwable object at the JNI return boundary instead. Existing public iteratorExceptionwrappers remain unchanged; inspect their cause chain rather than assuming the original is the top-level exception.gluten::JavaExceptionholds a shared JNI global reference across native copies,std::exception_ptr, and thread handoffs. Cleanup uses the current thread's JNI environment and detaches only threads it attached.checkExceptioncaptures the throwable;JNI_METHOD_ENDrestores it through direct or standard nested exceptions. Successful native propagation keeps JNI clear until the return boundary. Native-only failures retain the existingGlutenExceptionfallback.Scope: Backend-neutral JNI transport, layer 1 of 3. Velox iterator and memory-callback integration follow in #13561 and #13563; #13133 is the combined reference. This layer alone does not protect the full Velox driver path, translate native errors, or merge Java/C++ stack traces.
How was this patch tested?
jni-testMaven path is unchanged.These are focused core/JNI checks; this PR does not claim end-to-end Velox or Delta coverage.
Was this patch authored or co-authored using generative AI tooling?
Generated-by: GitHub Copilot 1.0.102-0.unstable.r40949665530.gab1eef5 (model: gpt-6-astra)