morningman opened a new pull request, #68710:
URL: https://github.com/apache/doris/pull/68710
### What problem does this PR solve?
Issue Number: None
Related PR: #66399 (fluss catalog)
Problem Summary:
**In short.** The fluss client and paimon, which BE runs inside its embedded
JVM for the fluss and paimon catalogs, call `System.exit` when one of their
worker threads dies, and a full JVM heap makes those threads die. Inside BE
that exit is a crash: a scan that should fail with `OutOfMemoryError` took the
whole BE down instead. This PR ships Doris copies of the three library classes
that do it, which log instead of exiting, packaged so that they survive the
Maven build cache CI builds with.
**Background**
- BE reads fluss and paimon tables through JNI plugins
(`fe/be-java-extensions/fluss-scanner`, `paimon-scanner`) that run in the one
JVM BE embeds (`-Xmx2048m` by default). Each plugin bundles its library
unmodified.
- PluginRuntime searches a plugin directory's jars in name order, except
that a jar whose manifest carries `Doris-Shadows-Classes` is searched first
(`jni-spi/PROTOCOL.md`). `hadoop-deps` already uses this to ship a
Doris-patched `org.apache.hadoop.fs.FileSystem`; `check_plugin_layout.py`
checks that such a jar holds exactly the classes it names.
- `System.exit` called from a JVM thread inside BE is an `::exit()` of the
BE process: the JVM's shutdown hooks run, then the C++ global destructors run
while BE is still serving. `jvm_launcher.cpp` describes the same failure for
the JVM's signal handlers, which `-Xrs` keeps away; `System.exit` does not go
through a signal.
**The problem, and what it cost**
Three library classes end the process when a thread dies:
| Class | Where it runs | What it does |
|---|---|---|
| fluss `org.apache.fluss.utils.FatalExitExceptionHandler` | every thread
from fluss's `ExecutorThreadFactory`: metadata refresh, remote file downloader,
security token renewal, lookup and write clients, future-timeout delayer; also
`FutureUtils.assertNoException` | `System.exit(-17)` on any uncaught exception |
| fluss `org.apache.fluss.utils.concurrent.ShutdownableThread` |
`RemoteLogDownloader`'s thread, one per log scanner | `System.exit(-1)` on any
`Error` from its work (Kafka's original exits only on its own `FatalExitError`)
|
| paimon `org.apache.paimon.utils.FatalExitExceptionHandler` | threads from
paimon's `ExecutorThreadFactory`: `AsyncRecordReader` (merge reads),
`ParallelExecution` | `System.exit(-17)` |
An `OutOfMemoryError: Java heap space` strikes whatever thread allocates at
that moment, including these threads while they are idle (the download thread
allocates while it waits for work). Concurrent fluss and paimon reads can fill
the default 2 GB heap, and when they did, BE aborted instead of failing the
query:
```
libc++abi: terminating due to uncaught exception of type std::system_error:
mutex lock failed: Invalid argument
```
thrown in BE's compaction thread on a mutex the exit had already destroyed.
Seen twice on a Release BE with the default heap:
- eight concurrent `SELECT *` union reads of a 30M-row fluss primary-key
table (100K updated keys per bucket in the log tail, 16 scanners each): all 24
queries failed with `OutOfMemoryError`, as they should, and then BE aborted;
- sixteen bucket reads at once over a fluss primary-key table of 2M keys,
half of them updated since its last kv snapshot: the first query failed with
the out-of-memory error, and seconds to minutes later BE aborted. A JFR
`jdk.Shutdown` event named the caller: `ShutdownableThread.run()` ->
`System.exit(-1)` on a `DownloadRemoteLog-[...]` thread.
So one query that runs the JVM heap out restarts the whole BE, and every
query on it fails.
A second, smaller defect in the same class: closing a log scanner (on a BE
scan thread) shuts its download thread down and waits on a latch with no
timeout. fluss logs "Starting" before the `try` whose `finally` counts that
latch down, and under a full heap the JVM can also unwind a compiled frame
without running its `finally` ("failed reallocation of scalar replaced
objects"). A thread that died either way left the scan thread waiting for ever,
holding its query's context; seen once for four hours after an out-of-memory
run.
**How this PR fixes it**
Two commits, one per library:
1. **fluss**: a new module `fe/be-java-extensions/fluss-client-patch` with
Doris copies of `FatalExitExceptionHandler` (logs the exception and returns)
and `ShutdownableThread` (an `Error` from its work is logged and ends the
thread, what fluss already does with any other `Throwable`; "Starting" is
logged inside the `try`; `awaitShutdown()` also returns once the thread is no
longer alive).
2. **paimon**: a new module `fe/be-java-extensions/paimon-common-patch` with
a Doris copy of paimon's `FatalExitExceptionHandler` that only logs.
The copies declare every member the originals declare, so the library code
compiled against the originals links to them. Each module's jar holds only
these classes and names them in `Doris-Shadows-Classes`. The plugin depends on
the module, declared ahead of the library, so `copy-dependencies` puts the jar
into the plugin directory, where PluginRuntime searches it first, and surefire
runs the plugin's tests on the copies as well.
Why a module of its own rather than a second jar built by the plugin module:
CI builds with the Maven build cache (`fe/.mvn/maven-build-cache-config.xml`).
A cache hit restores a module's own jar and nothing else, while
`copy-dependencies` is forced to run again. A second jar that the plugin module
wrote into its own `target/lib` was missing from every cache-restored build
(checked: paimon-scanner's `target/lib` 155 -> 154 jars, fluss-scanner 4 -> 3),
so the plugin would deploy with the library's original classes. As a dependency
module it is copied on every build, cached or not.
What it buys:
- A JNI read that runs BE's JVM out of heap fails its queries; BE stays up.
- Closing a fluss log scanner cannot hang a BE scan thread on a download
thread that already died.
- The fix survives CI builds that hit the Maven build cache.
Not in this PR: keeping the heap from running out in the first place (the
opt-in admission in #PR4), and restarting a download thread that died (a range
of that log scanner that still needs a remote log segment fails or waits; BE
stays up). FE's fluss connector carries the same handler, but FE only runs
admin RPCs on fluss's threads and already exits on `OutOfMemoryError`
(`-XX:OnOutOfMemoryError`), so it is left as is.
**Results**
- The second reproduction above, six out-of-memory queries in a row: BE
stays up. Five download threads log `died of an error` where fluss's class
would have exited. The queries that follow succeed and match the expected
`COUNT(*)` / `SUM(price)`, and the JVM's live heap is back to 175 MB.
- Unit tests, run against the original library classes: the test JVM exits
(status 239 for the handlers, 255 for `ShutdownableThread`) or `shutdown()` is
still waiting after 60 s. With the copies, all pass.
**Classes, and how they connect**
- `org.apache.fluss.utils.FatalExitExceptionHandler` (new,
`fluss-client-patch`): logs at ERROR and returns.
- `org.apache.fluss.utils.concurrent.ShutdownableThread` (new,
`fluss-client-patch`): fluss's class with `run()` and `awaitShutdown()` changed
as above.
- `org.apache.paimon.utils.FatalExitExceptionHandler` (new,
`paimon-common-patch`): logs at ERROR and returns.
- `fluss-scanner/pom.xml`, `paimon-scanner/pom.xml`: depend on the patch
module, ahead of the library.
- `be-java-extensions/pom.xml`, `build.sh`, `run-fe-ut.sh`: list the two
modules; `fe/check/checkstyle/suppressions.xml`: import control is suppressed
for the patch modules' fluss and paimon packages (import control is rooted at
`org.apache.doris`), every other check applies.
- PluginRuntime (`jni-bootstrap`, untouched): searches a
`Doris-Shadows-Classes` jar first.
```
BE process
'- embedded JVM
'- PluginRuntime, plugins/jni/fluss/
(plugins/jni/paimon/ likewise)
|- fluss-client-patch-<v>.jar Doris-Shadows-Classes:
FatalExitExceptionHandler, ShutdownableThread <- searched first
|- fluss-client-<v>.jar the same two classes, now never
loaded
'- fluss-scanner.jar FlussJniScanner ...
a fluss ExecutorThreadFactory thread dies -->
FatalExitExceptionHandler.uncaughtException
fluss: System.exit(-17)
-> BE aborts
Doris: log, the pool
starts another thread
RemoteLogDownloader thread throws an Error --> ShutdownableThread.run
fluss: System.exit(-1)
-> BE aborts
Doris: log, the thread
ends
BE scan thread closes the log scanner -->
ShutdownableThread.awaitShutdown
Doris: returns when the
latch is counted down or the thread is dead
```
**Merge order.** No code dependency on other PRs. Please merge it before
#PR3, which lets every scan run up to 16 scanners per instance again by
default: more JNI readers at once make running the JVM heap out more likely,
and without this PR that becomes a BE crash.
### Release note
A fluss or paimon catalog scan that runs the BE's JVM out of heap no longer
brings down the BE process; the affected queries fail with the out-of-memory
error.
### Check List (For Author)
- Test <!-- At least one of them must be included. -->
- [ ] Regression test
- [x] Unit Test
- [x] Manual test (add detailed scripts or steps below)
- [ ] No need to test or manual test. Explain why:
- [ ] This is a refactor/code format and no logic has been changed.
- [ ] Previous test can cover this change.
- [ ] No code files have been changed.
- [ ] Other reason <!-- Add your reason? -->
**Unit tests.** `FlussClientThreadDeathTest` (new, 5 tests): a thread from
fluss's `ExecutorThreadFactory` dies of an uncaught `OutOfMemoryError`; a
future `FutureUtils.assertNoException` watches fails; a `ShutdownableThread`
whose work throws `OutOfMemoryError` dies and `shutdown()` returns; one that
dies as it starts, and one that ends without counting its latch down, both let
`shutdown()` return. `PaimonThreadDeathTest` (new, 2 tests): a paimon factory
thread dies of an `OutOfMemoryError`, and a pool built on that factory keeps
serving after a worker dies that way. On this branch: fluss-scanner 66 tests,
paimon-scanner 66 tests pass.
**Build.** paimon-scanner and fluss-scanner built with `-am`: checkstyle 0
violations; both plugins' `target/lib` hold the patch jar with its
`Doris-Shadows-Classes` entry. Built again with the Maven build cache on after
removing the `target/` of both plugins and both patch modules: all four
restored from the cache, patch jars still in place.
`tools/be-java-plugins/check_plugin_layout.py` passes on the two plugins
deployed the way build.sh deploys them.
**Manual.** The reproductions above on a Release BE (2 GB JVM heap, local
fluss 1.0.0 cluster), before and after.
- Behavior changed:
- [ ] No.
- [x] Yes. <!-- Explain the behavior change --> An uncaught exception in
a fluss or paimon library thread inside BE is logged instead of exiting the BE
process.
- Does this need documentation?
- [x] No.
- [ ] Yes. <!-- Add document PR link here. eg:
https://github.com/apache/doris-website/pull/1214 -->
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label <!-- Add branch pick label that this PR should
merge into -->
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]