wangmingzhou1986 commented on issue #67898:
URL: https://github.com/apache/doris/issues/67898#issuecomment-5658109992
Thanks for the very precise triage — it pointed straight at the right
discriminator. I collected the requested evidence, and **it resolves the
ambiguity: the leaking threads are not Doris scheduler threads at all.**
## TL;DR
The `rs_normal` name in `/proc/<pid>/task/*/comm` is **inherited `comm`, not
a Doris thread**. In `jstack` those same TIDs are **AWS SDK v2
`sdk-ScheduledExecutor-<n>-<m>` threads**. Doris's remote scan pool is
correctly bounded the whole time (`r_scan real_num:8`, cap 256). What actually
leaks is **AWS SDK async clients created per scan and never closed** — roughly
**2 SDK `ScheduledExecutor` instances (10 threads) per query per BE**.
So your first branch is the right one:
> If `real_num` stays at or below 256 while `/proc` grows, investigate
JNI/plugin-created threads or native-name inheritance.
Both conditions hold simultaneously.
Build: `doris-4.1.3-rc02-7126cf65d96` (same as reported). JVM: `OpenJDK
17.0.2+8-86`.
---
## 1. `SHOW WORKLOAD GROUPS` for `normal`
```
Name scan_thread_num max_remote_scan_thread_num
min_remote_scan_thread_num
normal -1 -1 -1
```
All three are `-1` at the workload-group level, i.e. **the group does not
override the BE fallback**. Consistent with your reading of the override path.
## 2. `[topic_publish_wg] ... thread info=` — the decisive measurement
Identical **before and after** the 20-query reproducer, on **both** BEs:
```
thread info=scheduler: p_normal_simple_scheduler, info:
[exec num:16, real_num:16, min_num:16, max_num:16],
[l_scan num:48, real_num:48, min_num:48, max_num:48],
[r_scan num:8, real_num:8, min_num:8, max_num:256],
[mem_tab_flush num:8, real_num:8, min_num:8, max_num:64]
```
`r_scan real_num` **never moves off 8**, while `/proc` grows by hundreds.
The executor's accounting is not broken, and the explicit BE value of 256 *is*
in effect as `max_num` — it simply does not govern the threads that are
actually leaking.
This also **corrects the framing of my original report**: it is not that
`doris_max_remote_scanner_thread_pool_thread_num` "has no effect". It bounds
the remote scan scheduler correctly; it has no effect *on this leak* because
the leaked threads were never in that pool. The issue title is misleading and
should probably be amended.
## 3. Controlled before/after (freshly restarted BE)
Reproducer: 20 × `SELECT COUNT(*) FROM paimon_lake.store.sys_user`. All 20
completed normally (0 cancelled, 0 timed out).
**`doris-be-0` — restarted immediately before BEFORE:**
| metric | BEFORE | AFTER | Δ |
|---|---|---|---|
| `/proc/<pid>/task` total | 1,783 | 1,993 | **+210** |
| `comm == rs_normal` | **8** | **217** | **+209** |
| JVM threads (`jstack`) | 27 | 244 | +217 |
| `sdk-ScheduledExecutor` threads | **0** | **200** | **+200** |
| **distinct `sdk-ScheduledExecutor-<n>` instances** | **0** | **40** |
**+40** |
| `VmRSS` | 1,008 MB | 1,236 MB | +228 MB |
| `r_scan real_num` (BE self-report) | 8 | **8** | **0** |
| `/threadz` rows | 16 | **16** | **0** |
**`doris-be2-0` — not restarted, ~15 h uptime, same workload:**
| metric | BEFORE | AFTER | Δ |
|---|---|---|---|
| `/proc/<pid>/task` total | 39,148 | 39,350 | +202 |
| distinct SDK instances | 7,473 | 7,513 | **+40** |
| `r_scan real_num` | 8 | **8** | **0** |
**Rate: +2 SDK executor instances = +10 threads, per query, per BE.**
Identical on a clean BE and on one that already holds 37 k threads — the leak
rate does not decay.
`/threadz` and `/threadz?group=thread pool` are unchanged across the run,
i.e. Doris-registered pools are stable while `/proc` grows.
## 4. Thread identity — this is where it becomes unambiguous
On the 15 h BE (37,537 threads total, 35,757 with `comm == rs_normal`):
```
jstack thread-name histogram (top):
35745 sdk-ScheduledExecutor-<n>-<m>
10 Thread-<n>
6 GC Thread#
6 G1 Refine#
1
org.apache.hadoop.fs.FileSystem$Statistics$StatisticsDataReferenceCleaner
1 Timer for 's3a-file-system' metrics system
1 idle-connection-reaper
... (VM/Service/Sweeper/Finalizer, 1 each)
jstack total JVM threads : 35,789
distinct sdk-ScheduledExecutor instances : 7,149 (ids 0..7148, ~5 threads
each)
occurrences of "rs_normal" / "scan" / "Scanner" in jstack : 0
```
Mapping the decimal TIDs to `nid=0x…` as you asked — 5 lowest and 5 highest
`rs_normal` TIDs, **all ten are JVM threads**:
```
TID 10000 (0x2710) -> "sdk-ScheduledExecutor-1096-3" #6135 daemon
TID 10001 (0x2711) -> "sdk-ScheduledExecutor-1100-3" #6136 daemon
TID 10002 (0x2712) -> "sdk-ScheduledExecutor-1091-3" #6137 daemon
TID 10003 (0x2713) -> "sdk-ScheduledExecutor-1088-3" #6138 daemon
TID 10004 (0x2714) -> "sdk-ScheduledExecutor-1093-2" #6139 daemon
TID 9995 (0x270b) -> "sdk-ScheduledExecutor-1089-2" #6109 daemon
TID 9996 (0x270c) -> "sdk-ScheduledExecutor-1099-2" #6131 daemon
TID 9997 (0x270d) -> "sdk-ScheduledExecutor-1097-1" #6094 daemon
TID 9998 (0x270e) -> "sdk-ScheduledExecutor-1092-2" #6133 daemon
TID 9999 (0x270f) -> "sdk-ScheduledExecutor-1077-3" #6134 daemon
```
`/proc/<pid>/task/<tid>/status` for those TIDs:
```
Name: rs_normal [work <- truncated at TASK_COMM_LEN (16)
State: S (sleeping)
wchan: futex_wait_queue_me
```
(`/proc/<pid>/task/<tid>/stack` is not readable in our container — no
`CAP_SYS_ADMIN`. The Java-side stack below makes it unnecessary.)
**Every single one of the 35,745 is `WAITING (parking)`**, with an identical
stack:
```
"sdk-ScheduledExecutor-1096-3" #6135 daemon prio=5 os_prio=0 cpu=0.29ms
elapsed=46067.04s nid=0x2710 waiting on condition
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park([email protected]/Native Method)
at
java.util.concurrent.locks.LockSupport.park([email protected]/LockSupport.java:341)
at
java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(...)
at
java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(...:1177)
at java.util.concurrent.ThreadPoolExecutor.getTask(...:1062)
at java.util.concurrent.ThreadPoolExecutor.runWorker(...:1122)
at java.lang.Thread.run([email protected]/Thread.java:833)
```
`cpu=0.29ms` after `elapsed=46,067s` — these threads have done essentially
nothing since creation. They are parked scheduler threads of **abandoned AWS
SDK async clients**.
### Why they are named `rs_normal`
Linux threads inherit `comm` from the creating thread unless explicitly
renamed. These executors are constructed on a Doris `rs_normal [worker]` thread
(inside the Paimon JNI call), and the JDK does not rename
`ScheduledThreadPoolExecutor` worker threads at the OS level. So `/proc` shows
`rs_normal [work` for threads that have nothing to do with the Doris scheduler.
**A `/proc`-based thread-name histogram is actively misleading here** — exactly
the caveat you raised.
### Scope of the leak
Note `Timer for 's3a-file-system' metrics system` and
`idle-connection-reaper` appear **exactly once**. So `S3AFileSystem` itself is
effectively a singleton, but **the SDK clients underneath are not** — 7,149 of
them accumulated in ~15 h. The lifecycle problem is on the client, not on the
filesystem object.
## 5. Table / query characteristics
| | |
|---|---|
| Table | Paimon **primary-key** table (`primaryKeys: [user_id]`) |
| Partition keys | none |
| File format | `parquet`, `zstd` |
| Bucket | `4` |
| Columns | 24 |
| Storage | S3 (MinIO), `s3a://` via Hadoop |
| Query | `SELECT COUNT(*)`, small table, few splits |
| Outcome | **20/20 completed normally**, none cancelled or timed out |
The leak therefore does not require large scans or failures — 20 trivial
counts on a tiny table are enough.
## 6. `enable_paimon_cpp_reader` A/B — attempted, but **not** a clean result
I ran the same 20 queries with `SET enable_paimon_cpp_reader=true`. **All 20
failed** at:
```
doris::PaimonCppReader::init_reader()
```
presumably because the C++ reader does not support this primary-key table in
this build. Thread and SDK-instance deltas were **exactly 0**.
I want to be explicit that this is **not** evidence that the C++ path is
leak-free — the queries never reached the read path. It is only consistent with
the leak living behind the JNI reader. A proper A/B needs an append-only table
that the C++ reader actually supports; I can run that if it would help.
---
## Where this leaves the investigation
- The task-executor accounting invariant is **not** broken (`num == real_num
== 8`, `max_num == 256`).
- There are **not** multiple same-named scheduler owners.
- It is **native-name inheritance** plus a genuine **JNI-side client
lifecycle leak**: each Paimon scan appears to build an AWS SDK async client
(with its own 5-thread `ScheduledExecutor`) that is never `close()`d, so both
the threads and their per-thread allocator caches accumulate until `rss` hits
`mem_limit`.
That suggests ownership sits with the Lakehouse/Paimon JNI reader (S3 client
construction/close per scan or per split), not with BE execution/workload
scheduling. Linking with #65416 still makes sense — the `/proc` symptom is
identical and would have the same explanation.
Two small notes on the mitigation guidance in the original report, for
anyone who finds this later:
- Rolling BE restarts remain the only demonstrated mitigation. Confirmed
again here: a restart took this BE from 37,537 threads / 5,449 MB to 1,783
threads / 1,008 MB.
- `SHOW BACKENDS` reports `Alive: true` right up to the point where every
query fails on `MEM_LIMIT_EXCEEDED`, so it must not be used as the health
signal. Tracking thread count gives hours of warning; tracking memory
percentage alone does not.
Happy to collect anything else — an append-only-table C++ reader A/B, a
repeat on 4.1.4-rc04, or `jcmd VM.native_memory` / allocator breakdowns.
--
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]