Repository navigation
Description
Search before asking
- I had searched in the issues and found no similar issues.
Version
doris-4.1.3-rc02(AVX2) RELEASE
features: -TDE,-VARIANT_NESTED_GROUP,-HDFS_STORAGE_VAULT,+UI,+AZURE_BLOB,+AZURE_STORAGE_VAULT,-HIVE_UDF,+BE_JAVA_EXTENSIONS
build: git://vm-122@7126cf65d96ebc43fce0906f51e92c1a2ccf24a6
What's Wrong?
Every query against an external catalog (Paimon on S3/MinIO, via CREATE CATALOG ... 'type'='paimon')
leaks roughly 10 threads per BE, named rs_normal. The threads are never reclaimed.
Thread count therefore grows linearly with the number of external-table queries,
and because jemalloc's per-thread tcache scales with thread count,
BE rss grows with it until it hits mem_limit and every query fails with
MEM_LIMIT_EXCEEDED — while SHOW BACKENDS still reports Alive: true.
Setting doris_max_remote_scanner_thread_pool_thread_num to a finite value
(we tried 256, down from the default -1) and restarting BE does not bound this pool:
the count climbed past 3,192 and kept going.
Evidence
1. Only rs_normal grows
Thread-name histogram from /proc/<be_pid>/task/*/comm:
| thread name | be-0 (uptime 17.7 h) | be2-0 (uptime 22 h) |
|---|---|---|
rs_normal |
24,377 | 35,332 |
brpc_arrow_flig |
512 | 512 |
doris_be |
352 | 344 |
brpc_light / brpc_heavy / EvHttpServer |
128 / 128 / 128 | 128 / 128 / 128 |
SendBatchThreadP / DownloadThreadP |
64 / 64 | 64 / 64 |
ls_normal |
48 | 48 |
p_normal_blocki / TabletPublishTx / SegmentPrefetch |
32 / 32 / 32 | 32 / 32 / 32 |
| everything else, summed | ~1,779 | ~1,771 |
Every other pool is identical between the two BEs and stays flat. Only rs_normal diverges,
and it diverges in proportion to uptime (i.e. to accumulated query count).
2. Controlled experiment — growth is caused by external-catalog queries, and is not reclaimed
Both BEs observed simultaneously; rs_normal counted before and after each phase:
| phase | be-0 Δ | be2-0 Δ |
|---|---|---|
| idle 60 s (zero queries) | -1 | 0 |
20 × SELECT COUNT(*) FROM <paimon_catalog>.<db>.<tbl> |
+203 | +201 |
| idle 120 s | -4 | -1 |
≈ 10 threads per query per BE. Queries fan out to all BEs, so both grow together.
Idle neither grows nor reclaims.
3. The config knob does not bound it
From /api/show_config (all three are mutable=false):
doris_remote_scanner_thread_pool_thread_num = 48
doris_max_remote_scanner_thread_pool_thread_num = -1 <- default in this build
doris_remote_scanner_thread_pool_queue_size = 102400
Note the docs state the default for doris_max_remote_scanner_thread_pool_thread_num
is 512, but this build reports -1.
We set it explicitly to 256 in be.conf and restarted the BE. Verified it took effect:
doris_max_remote_scanner_thread_pool_thread_num = 256
rs_normal nevertheless climbed past 3,192 and kept growing at the same rate.
So whatever creates rs_normal threads is not governed by this parameter.
Ruled out: workload groups are not the cause. We have exactly one workload group,
and enable_workload_group_for_scan = false.
4. Memory consequence
jemalloc stats at rss 6.81 GB (be2-0, 35 k threads):
| metric | be-0 | be2-0 |
|---|---|---|
jemalloc_allocated_bytes |
2.49 GB | 4.02 GB |
jemalloc_tcache_bytes |
1.09 GB | 1.51 GB |
jemalloc_metadata_bytes |
0.77 GB | 1.08 GB |
jemalloc_retained_bytes |
1.12 GB | 1.52 GB |
35 k threads × ~40 KB tcache ≈ 1.5 GB, matching the measured tcache almost exactly.
tcache + metadata = 2.59 GB = 38 % of rss.
Eventually:
[MEM_LIMIT_EXCEEDED] ... process memory used 6.62 GB(= 6.62 GB[vm/rss]),
limit 7.00 GB, soft limit 6.30 GB, sys available memory 398 MB
At that point every query through the BE fails, including SELECT ... LIMIT 1,
yet SHOW BACKENDS still shows Alive: true — so health checks based on liveness
do not detect it.
Restarting BE clears it completely, confirming the threads (not data) hold the memory:
| before restart | after restart | |
|---|---|---|
| be2-0 rss / threads | 6,805 MB / 37,103 | 971 MB / 1,778 |
| be-0 rss / threads | 4,514 MB / 26,579 | 1,361 MB / 1,933 |
5. Environment note (may or may not be relevant)
BE runs in a container. nproc inside the container reports 16 (the host's core count),
while the cgroup CPU limit is 2 (be2-0) / 6 (be-0).
JEMALLOC_CONF=percpu_arena:percpu,background_thread:true,metadata_thp:auto,
muzzy_decay_ms:5000,dirty_decay_ms:5000,oversize_threshold:0,prof:true,
prof_active:false,lg_prof_interval:-1,lg_extent_max_active_fit:8
dirty_decay_ms/muzzy_decay_ms are already aggressive (5 s), so this is not jemalloc
withholding freed pages — the memory is genuinely attached to live threads.
What You Expected?
rs_normal threads are returned to (or bounded by) a pool after the scan finishes,
so that BE thread count and rss stay flat under a steady stream of external-catalog
queries, and doris_max_remote_scanner_thread_pool_thread_num actually caps the pool.
How to Reproduce?
- Create an external catalog (we used Paimon on S3; a Hive/Iceberg catalog on object
storage will likely do as well). - Record the baseline:
for t in /proc/<be_pid>/task/*/comm; do cat $t; done | grep -c '^rs_normal' - Run N (e.g. 20) trivial queries against a table in that catalog,
e.g.SELECT COUNT(*) FROM <catalog>.<db>.<tbl>; - Re-count
rs_normal. Expect ≈10 × Nmore threads per BE. - Wait a few minutes with no queries and count again — the threads are not reclaimed.
Anything Else?
- Happy to provide the full thread-name histograms,
/api/show_configdumps,
/metricsjemalloc series, or a time series ofrss/ thread count
(we sample both every 5 minutes). - If
rs_normalis expected to be bounded by a different config than
doris_max_remote_scanner_thread_pool_thread_num, please point us at it and
we'll re-test and report back. - The discrepancy between the documented default (
512) and this build's default (-1)
may itself be worth a look.
Are you willing to submit PR?
- Yes I am willing to submit a PR!
Activity
Doris-Breakwater commentedon Sep 12, 2026
Initial triage: the evidence is internally consistent and this should be treated as a high-severity availability/resource-exhaustion problem. It is also a near-exact duplicate of #65416, which reports the same unbounded
rs_normalgrowth for Paimon-on-S3 on 4.1.1-rc01, including ineffective BE and workload-group caps. This report adds a much better controlled reproducer, so the two issues should be linked and investigated together.What is verified from the reported revision
I checked the exact build commit,
7126cf65d96ebc43fce0906f51e92c1a2ccf24a6:rs_normalis the remote scan scheduler owned by thenormalworkload group (construction path). Having only one workload group narrows the problem, but does not bypass this workload-group-owned scheduler.doris_max_remote_scanner_thread_pool_thread_numis indeed-1(config), but-1is an automatic-sizing sentinel, not an unlimited value: it becomesmax(512, detected_cores * 10)(calculation). Thus/api/show_config = -1and the documented effective floor of 512 are compatible, although the documentation should explain the sentinel.normalworkload group supplies a positivemax_remote_scan_thread_num, which overrides the BE fallback (override path).num_threads + pending_start < max_threads(cap check), and idle workers above the minimum are supposed to retire after 500 ms (retirement path). A single correctly accounted pool therefore cannot intentionally contain 3,192 or 35,000 live workers.This confirms that the observed behavior violates the intended cap/lifecycle. It does not yet identify which invariant is failing. The remaining possibilities include scheduler accounting/teardown, multiple same-named scheduler owners, or non-Doris/JVM-side threads retaining or inheriting the native name. The thread-name histogram alone cannot distinguish them, so I would not assign a Paimon or scheduler root cause yet.
Highest-value missing evidence
Please collect the following on a freshly restarted BE, immediately before and after the 20-query reproducer:
SHOW WORKLOAD GROUPSoutput fornormal, especiallymax_remote_scan_thread_numandmin_remote_scan_thread_num.[topic_publish_wg] ... thread info=. This revision already logs remote-pool accounting asr_scan num:<accounted>, real_num:<thread-set-size>, min_num:<...>, max_num:<...>(format). The before/after values are the most direct test of whether the executor itself sees the leaked threads./threadzand/threadz?group=thread%20poolbefore/after. Comparing Doris-registered thread counts with/proc/<pid>/taskwill show whether allrs_normalTIDs were created by Doris's thread wrapper.rs_normalTIDs:/proc/<pid>/task/<tid>/status,wchan, andstack(if permitted), plus native backtraces. Also providejcmd <pid> Thread.print -lorjstack <pid>and map those decimal TIDs to Javanid=0x.... This will establish whether they are idle in the scheduler, blocked in JNI/Paimon/Hadoop/S3 code, or absent from the JVM.Suggested next steps
kind/bugandarea/catalogare appropriate, plus the repository's high-severity/availability label if applicable.r_scan num/real_num/max_numplus representative stacks to choose the owner before changing code. Ifreal_numstays at or below 256 while/procgrows, investigate JNI/plugin-created threads or native-name inheritance. Ifreal_numitself exceedsmax_num, the task-executor accounting invariant is directly broken. If several pool instances appear, trace workload-group scheduler creation/destruction.SET enable_paimon_cpp_reader=trueif that reader supports this table. Growth only on the default JNI path would isolate Paimon/JNI lifecycle from the common remote scheduler; growth on both paths would point back toward scheduler ownership/accounting.Breakwater-GitHub-Analysis-Slot: slot_52e24c6dcbe1
wangmingzhou1986 commentedon Sep 14, 2026
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_normalname in/proc/<pid>/task/*/commis inheritedcomm, not a Doris thread. Injstackthose same TIDs are AWS SDK v2sdk-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 SDKScheduledExecutorinstances (10 threads) per query per BE.So your first branch is the right one:
Both conditions hold simultaneously.
Build:
doris-4.1.3-rc02-7126cf65d96(same as reported). JVM:OpenJDK 17.0.2+8-86.1.
SHOW WORKLOAD GROUPSfornormalAll three are
-1at 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 measurementIdentical before and after the 20-query reproducer, on both BEs:
r_scan real_numnever moves off 8, while/procgrows by hundreds. The executor's accounting is not broken, and the explicit BE value of 256 is in effect asmax_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:/proc/<pid>/tasktotalcomm == rs_normaljstack)sdk-ScheduledExecutorthreadssdk-ScheduledExecutor-<n>instancesVmRSSr_scan real_num(BE self-report)/threadzrowsdoris-be2-0— not restarted, ~15 h uptime, same workload:/proc/<pid>/tasktotalr_scan real_numRate: +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.
/threadzand/threadz?group=thread poolare unchanged across the run, i.e. Doris-registered pools are stable while/procgrows.4. Thread identity — this is where it becomes unambiguous
On the 15 h BE (37,537 threads total, 35,757 with
comm == rs_normal):Mapping the decimal TIDs to
nid=0x…as you asked — 5 lowest and 5 highestrs_normalTIDs, all ten are JVM threads:/proc/<pid>/task/<tid>/statusfor those TIDs:(
/proc/<pid>/task/<tid>/stackis not readable in our container — noCAP_SYS_ADMIN. The Java-side stack below makes it unnecessary.)Every single one of the 35,745 is
WAITING (parking), with an identical stack:cpu=0.29msafterelapsed=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_normalLinux threads inherit
commfrom the creating thread unless explicitly renamed. These executors are constructed on a Dorisrs_normal [worker]thread (inside the Paimon JNI call), and the JDK does not renameScheduledThreadPoolExecutorworker threads at the OS level. So/procshowsrs_normal [workfor 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 systemandidle-connection-reaperappear exactly once. SoS3AFileSystemitself 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
primaryKeys: [user_id])parquet,zstd4s3a://via HadoopSELECT COUNT(*), small table, few splitsThe leak therefore does not require large scans or failures — 20 trivial counts on a tiny table are enough.
6.
enable_paimon_cpp_readerA/B — attempted, but not a clean resultI ran the same 20 queries with
SET enable_paimon_cpp_reader=true. All 20 failed at: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
num == real_num == 8,max_num == 256).ScheduledExecutor) that is neverclose()d, so both the threads and their per-thread allocator caches accumulate untilrsshitsmem_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
/procsymptom 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:
SHOW BACKENDSreportsAlive: trueright up to the point where every query fails onMEM_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.wangmingzhou1986 commentedon Sep 14, 2026
Resolved in 4.1.4. Upgraded the cluster today and the leak is gone.
Measurement
Same reproducer as before (20 ×
SELECT COUNT(*)on a Paimon primary-key table via external catalog), both BEs freshly restarted:doris-4.1.3-rc02-7126cf65d96)doris-4.1.4-rc04-ad35a140c7f)sdk-ScheduledExecutor-<n>instances/proc/<pid>/taskThe SDK executor instance count stays at 1 on both BEs instead of growing by 2 per query. That is exactly the mechanism identified earlier (AWS SDK async clients created per Paimon scan and never
close()d), so this issue can be closed as fixed in 4.1.4.One small note for anyone searching: the published image
apache/doris:be-4.1.4self-reports asdoris-4.1.4-rc04-ad35a140c7f. There is no separate-rc04tag on Docker Hub — the rc04 build is the 4.1.4 release.A compatibility caveat worth documenting for Paimon-catalog users
4.1.4 bumps the bundled Paimon from 1.3.1 to 1.4.2, and the
java_extensionsdirectory is renamedpaimon-scanner→paimon-connector. Two things followed from that in our environment:1. Lake-format compatibility is fine. Our tables are written by Paimon 1.3.1 (Flink CDC) and now read by 1.4.2. Before upgrading I verified this in isolation — a throwaway pod running the 4.1.4 image with
sleep(no Doris process), using its bundled Paimon 1.4.2 through the Java API to read production tables directly:57,783,725fromFOR VERSION AS OF 2333on 4.1.3 and57,783,725from the 1.4.2 direct read. After the upgrade the same query still returns57,783,725.(Comparing row counts across engines on a live table is meaningless — the table commits every ~30 s, so each engine pins a different snapshot. Pinning both to one snapshot is what makes the comparison valid.)
2. FE and BE must be on the same Paimon version. The documented upgrade order is BE first, then FE. During that window — BE on 4.1.4, FE still on 4.1.3 — every external-catalog query fails:
The FE serializes Paimon split/table objects and the BE deserializes them;
CatalogEnvironment'sserialVersionUIDchanged 1 → 2 between Paimon 1.3.1 and 1.4.2. So for deployments using Paimon external catalogs, the BE-then-FE window is a planned outage, not a transparent rolling upgrade. Ours lasted about 6 minutes. It might be worth a line in the upgrade docs.Happy to run anything else if it would help confirm.
Asthenia0412 commentedon Sep 14, 2026
Hi @wangmingzhou1986, I've submitted the master-side fix in #67963: Paimon is bumped 1.3.1 -> 1.4.2 (same as the 4.1.4 release you verified), with the paimon-scanner code adapted to the 1.4.2 API. This should fix the AWS SDK async client thread leak on master. Thanks for the detailed triage!
[fix](fe) Upgrade Paimon to 1.4.2 to fix AWS SDK async client thread …
wangmingzhou1986 commentedon Sep 15, 2026
@Asthenia0412 thanks — that matches what we measured. Confirming the fix from the release side, in case it helps the PR review.
We upgraded the whole cluster (2 FE + 2 BE) from
doris-4.1.3-rc02-7126cf65d96todoris-4.1.4-rc04-ad35a140c7fand re-ran the identical 20-query reproducer on freshly restarted BEs:sdk-ScheduledExecutor-<n>instances/proc/<pid>/taskSo the Paimon 1.3.1 → 1.4.2 bump is indeed what fixes it, and the three JNI-cleanup PRs suggested earlier in triage were not the relevant change. Since #67963 is the same bump for master, I'd expect it to close this.
Two things we verified before upgrading that may save someone else the trouble:
1. Lake-format compatibility (1.4.2 reader over 1.3.1-written tables) holds. Our tables are written by Flink CDC with Paimon 1.3.1. Before touching production we ran the 4.1.4 image as a throwaway
sleeppod (no Doris process) and used its bundled Paimon 1.4.2 through the Java API to read production tables directly. Three static tables matched field-by-field, and a 57.8M-row primary-key table matched exactly when both engines were pinned to the same snapshot:57,783,725fromFOR VERSION AS OF 2333on 4.1.3, and57,783,725from the 1.4.2 direct read. After the upgrade the same query still returns57,783,725.Worth noting for anyone repeating this: comparing row counts across engines on a live table is meaningless — ours commits every ~30 s, so each engine pins a different snapshot and the numbers differ in both directions. Pinning both sides to one snapshot is what makes the comparison valid.
2. FE and BE must be on the same Paimon version — the documented BE-then-FE order is an outage window for Paimon catalogs. With BE on 4.1.4 and FE still on 4.1.3, every external-catalog query fails:
The FE serializes Paimon split/table objects for the BE to deserialize, and
CatalogEnvironment'sserialVersionUIDchanged 1 → 2 between Paimon 1.3.1 and 1.4.2. Ours was down about 6 minutes. For deployments using Paimon external catalogs this is a planned outage rather than a transparent rolling upgrade — might be worth a line in the upgrade docs alongside this change.Happy to test anything else on 4.1.4 if useful.