Repository navigation
BE remote scanner thread pool (rs_normal) never shrinks — thread count grows to 27k+ and BE freezes #65416
Description
BE remote scanner thread pool (rs_normal) never shrinks — thread count grows to 27k+ and BE freezes
Environment
- Doris version: 4.1.1-rc01
- Cluster size: 4 BE + 3 FE, deployed via DorisOperator on Kubernetes
- BE image: official
apache/doris:doris-4.1.1-rc01-be(x86_64) - Workload: BE queries external tables backed by Paimon on S3 (Doris × Paimon catalog), mixed with normal internal OLAP queries
- BE resources: cpu 8 core, memory limit 40G (cgroup),
mem_limit=32G
Symptom
BE pod RSS climbs monotonically from ~3G to 6–7G over ~4h, then either:
- keeps growing until the pod is OOM-ish and gets killed by cgroup, or
- at ~27k threads the BE process freezes: it stops responding to FE heartbeats,
SHOW BACKENDSshowsAlive=falsewithSocketTimeoutException: Read timed out, CPU drops to ~1m, readiness probe times out. Not OOMKilled — internal lock/resource exhaustion.
The only recovery is kubectl delete pod (restart the BE process).
Misleading signals
All conventional memory metrics stay green, which made this look like a "memory leak" for a while:
JVM heap used— normal GC sawtooth, returns to baselinejemalloc resident— stable at ~2Gjvm non-heap— stable at ~130Mmem_tracker— stable
Root cause
/proc/$BE_PID/status shows Threads=16000–27000, of which ~90% are rs_normal [worker] threads in S (sleeping) state. Each thread has a 512K stack → 7–12GB just for thread stacks, which is what pushes RSS up.
Thread census command:
BE_PID=$(pgrep -f doris_be)
ls /proc/$BE_PID/task/ | while read t; do cat /proc/$BE_PID/task/$t/comm; done \
| sort | uniq -c | sort -rn | headTypical output on a frozen BE:
27012 rs_normal
48 olap_scan
24 brpc
...
The rs_normal pool (remote scanner, used for querying external catalogs like Paimon/Hive/Iceberg) only grows, never shrinks. Idle keep-alive eviction either isn't implemented or isn't effective in 4.1.1-rc01.
Configs I tried to cap the pool (all ineffective on rc01)
| # | Where | Setting | Result |
|---|---|---|---|
| 1 | be.conf |
doris_scanner_thread_pool_thread_num=256 |
Only caps ls_normal (local scanner). rs_normal still linear-grows. |
| 2 | be.conf |
doris_max_remote_scanner_thread_pool_thread_num=256 |
No effect. rs_normal keeps growing past 256. |
| 3 | FE | ALTER WORKLOAD GROUP normal PROPERTIES ('max_remote_scan_thread_num'='256', 'min_remote_scan_thread_num'='48'); |
No effect. |
Related defaults on this BE:
doris_remote_scanner_thread_pool_thread_num=48(initial)doris_max_remote_scanner_thread_pool_thread_num=-1(unlimited)
Current workaround
CronJob that rotates one BE pod every 4h (kubectl delete pod on the oldest ready BE, with guards: ready BE >= 3 and pod age > 6h). This keeps the leak from ever reaching the freeze threshold, but it's purely symptomatic.
YAML for the workaround is available if useful.
Expected behavior
At least one of these should actually work on rc01:
doris_max_remote_scanner_thread_pool_thread_num=Nshould hard-caprs_normalsize, excess idle threads should be reaped when the cap is hit and load drops.- Idle
rs_normalworkers should be evicted after a keep-alive timeout (likels_normaldoes), so the pool shrinks back todoris_remote_scanner_thread_pool_thread_numwhen external-scan load stops. ALTER WORKLOAD GROUP … max_remote_scan_thread_num=Nshould bound concurrent remote scans per workload group — currently appears to be ignored or not enforced against thers_normalpool.
Reproduction
- Fresh BE 4.1.1-rc01 with a Paimon catalog on S3.
- Run a sustained mixed workload that issues external scans against Paimon (a few QPS is enough; the pool grows even under modest load).
- Within 4–8h,
Threads=in/proc/$BE_PID/statuscrosses 20k and RSS climbs correspondingly. - Eventually (usually 24k–28k threads) the BE stops answering FE heartbeats and has to be killed externally.
Happy to provide thread dumps, be.conf, SHOW BACKENDS/SHOW WORKLOAD GROUPS output, or a heap profile if it helps. Just let me know what's most useful.
Question
Is this a known issue in 4.1.1-rc01? Is there a fix in a later 4.1.x or 4.2.x release? If a fix exists, I'd like to upgrade instead of running the rotator CronJob forever.
Activity
yiguolei commentedon Jul 10, 2026
It is not a known issue. How many CPU cores do you have in your host machine. And could you please provide some be logs from be startup until this issues happens.
tracy1014-hub commentedon Jul 10, 2026
Hi @yiguolei — thanks for taking a look. Answers to both questions below.
1. Host CPU cores
status.capacity.cpu = 32).mem_limit = 32Gin be.conf.SHOW BACKENDS\Gfrom the FE, every BE reportsCpuCores: 32— i.e. the BE is reporting host CPU to the FE, not the cgroup-limited 8. If the remote scanner pool is sized/expanded based onCpuCores, then in a containerized env it'll be over-provisioned 4x from the start, which lines up with the runaway growth we see.2. BE logs from startup → capture
Captured from
doris-be-0after 4h29m of runtime — not yet at the freeze point, but the leak is already clearly visible. Public gist with the artifacts:https://gist.github.com/tracy1014-hub/8df6ab002a509132d7ffbac27d0f88d4
Inside the gist:
INSTRUCTIONS.md— layout + how to decode the tarballruntime-snapshot.txt— plain-text snapshot, browse inline without decoding anythingbe.conf— full BE config, browse inlinedoris-issue-65416.tar.gz.b64— base64-encoded tarball, decode withbase64 -d doris-issue-65416.tar.gz.b64 | tar xzf -to get:be.INFO.log.20260710-071747— 24 MB, full startup → capturebe.out— startup banner, JNI initbe.WARNING.log.20260710-071748be.gc.log.20260710-071747— JVM GC logjni.log— 3.2 MB, Paimon scanner activity via JNI. Every Paimon scan spawns a newThread-Nand re-installsHadoopModule+CodecPool.getDecompressor— no JVM-side scanner reuse, which lines up withrs_normalnever shrinking.be.confandruntime-snapshot.txt(same as the readable inline copies above)Snapshot at capture time (pod age 4h29m, BE still
Alive=true,HeartbeatFailureCounter=0)So at 4h29m we already have 15,099
rs_normalthreads —doris_max_remote_scanner_thread_pool_thread_num = 256in be.conf is clearly not being honored. The pod is ~7k threads short of the freeze threshold (24k–28k) we usually see, so it would have frozen in another ~1–2h on its own.What I can do next
If startup-to-freeze is more useful than startup-to-now, I can:
kubectl patch cronjob doris-be-rotator -p '{"spec":{"suspend":true}}'to disable the workarounddoris-be-0run uninterrupted until it freezes (~1–2h from now given current trajectory)be.INFO+jni.logat the moment of freeze + a finalSHOW BACKENDS\GshowingAlive=falseJust say the word and I'll do it. If the current 4h29m log is enough for you to start tracing where the
rs_normalworkers are being created / why the cap isn't enforced, no need for the freeze run.yiguolei commentedon Jul 10, 2026
add this config to be.conf and restart be. num_cores = 8
and check the thread num
tracy1014-hub commentedon Jul 10, 2026
Hi @yiguolei — added
num_cores = 8tobe.confand restarteddoris-be-0. Reporting back the results.Verification that the config took effect
SHOW BACKENDS\Gfrom FE confirms the newdoris-be-0reportsCpuCores: 8(vsCpuCores: 32on the other 3 BEs that I left as a control group):So the lever does what you expected at the report-to-FE level:
num_cores = 8overrides the host CPU count inCpuCores. But the actual leak behavior is unchanged — see below.rs_normal growth: experiment vs control (~30 min in)
I kept
doris-be-1/2/3on the default config (host = 32 cores,CpuCores: 32) as a control group, and ran all 4 BEs in parallel under the same workload. Side-by-side snapshot at experiment T+30 min:num_coresControl group avg rate: 58.4 rs_normal/min. Experiment rate: 58.3 rs_normal/min. Essentially identical.
rs_normal trajectory on doris-be-0 (num_cores=8) over 30 min
Growth is linear at ~60/min and shows no sign of saturating. If the
256cap fromdoris_max_remote_scanner_thread_pool_thread_num = 256were being honored, rs_normal should have stopped at 256 — instead it's at 1,684 (6.6× the cap) and still climbing linearly.Observations
num_cores = 8changesCpuCoresbut not the leak. The hypothesis that the rs_normal pool is sized onCpuCoresdoesn't hold — or at leastnum_coresalone isn't the lever that bounds the remote scanner pool in 4.1.1-rc01.num_cores = 8— rs_normal passed 256 within the first ~5 minutes and kept climbing linearly. So whatever path is spawning rs_normal workers, it isn't consultingdoris_max_remote_scanner_thread_pool_thread_numat all (with or withoutnum_cores).num_cores = 8doesn't seem to affect any pool sizing I can observe.Cluster status
All 4 BEs are
Alive=true,HeartbeatFailureCounter=0. The 3 control BEs are at 12.5k–18.7k threads each — still below the freeze threshold (~27k) we usually see, but doris-be-3 (highest, 18.7k at 68/min) will approach the threshold in ~2h. I have a rotator CronJob that will pick up the oldest BE at the 6h-age mark, and I'll manually rotate doris-be-3 before then if its thread count crosses ~22k.What's next?
Standing by for direction. Things I can do on request:
be.INFO+jni.logfrom the experiment BE at any point if you want to trace where the rs_normal workers are being created.doris_remote_scanner_thread_pool_thread_num,doris_scanner_thread_pool_queue_size,parallel_remote_scan_thread_numif it exists in 4.1.1) — let me know which to flip and what value to set.SHOW BACKENDS\GshowingAlive=false(this is what I offered in the previous comment). With doris-be-3's current trajectory that would be in ~2h.num_cores = 8if you'd prefer to keep the cluster on default config while you investigate.tracy1014-hub commentedon Jul 10, 2026
Quick status update — continuing to monitor, no new findings yet, but a freeze-time capture is coming up.
Experiment status (T+46 min on doris-be-0 with
num_cores = 8)Rate still ~58/min, exactly matching the control group. No change from the previous update.
Control group snapshot
num_coresOpportunity to capture freeze-time data (the "Path B" from my earlier offer)
doris-be-3is on track to hit the ~27k-thread freeze threshold in roughly 1h45m (around 15:21 CST, i.e. ~07:21 UTC), assuming the rate holds. The rotator's 6h-age threshold won't pick it up before then (its 6h mark is at 15:17, but it'd still be 5h59m at that minute — the rotator skips and the next run is 16:17, too late).I'll let
doris-be-3run uninterrupted and capture, at the moment it freezes:be.INFOfrom startup to freezejni.logover the same windowSHOW BACKENDS\GshowingAlive=false+HeartbeatFailureCounter > 0/proc/$BE_PID/statusfinalThreads=countrs_normalas the dominant thread typeI'll publish those as a follow-up here. If you'd rather I rotate
doris-be-3before it freezes (e.g. you've already seen enough freeze signatures and want me to keep the cluster clean), say the word and I'll rotate it at the ~22k mark.If, in the meantime, there are other config levers you'd like me to try on
doris-be-0(which is still running withnum_cores = 8and growing linearly), I can patch them in parallel without disturbing the freeze capture ondoris-be-3.yiguolei commentedon Jul 10, 2026
using curl be_ip:web_server_port/metrics to get all be metrics to a file and send me the result when there are a lot of threads.
tracy1014-hub commentedon Jul 10, 2026
Hi @yiguolei — captured
/metricsfrom two BEs in parallel at the same workload moment, in response to your request. Gist with the raw files + a summary:https://gist.github.com/tracy1014-hub/6c8f05326068a446cca6403694292bc7
Files in the gist:
doris-be-0.metrics— experiment BE withnum_cores = 8.doris_be_process_thread_num = 8001.doris-be-3.metrics— control BE (default config, 32-core host).doris_be_process_thread_num = 23502, was just over the 22k "approaching freeze" threshold at capture time.SUMMARY.md— observations.rs_normal pool metrics (the smoking gun)
Side-by-side for the
rs_normalpool only:num_cores=8)doris_be_thread_pool_max_threads{...rs_normal...}doris_be_thread_pool_active_threads{...rs_normal...}doris_be_thread_pool_queue_size{...rs_normal...}doris_be_thread_pool_task_execution_count_total{...rs_normal...}doris_be_thread_pool_task_wait_worker_count_total{...rs_normal...}doris_be_thread_pool_submit_failed{...rs_normal...}doris_be_process_thread_num(whole BE)/proc/$PID/task/(OS reality)What this tells us
max_threads = 256correctly — matches thedoris_max_remote_scanner_thread_pool_thread_num = 256override inbe.conf. So the config is being read into the pool's reported state on both BEs, with or withoutnum_cores = 8.active_threads = 0— i.e. it thinks no workers are currently busy.queue_size = 0,submit_failed = 0. From the pool's own POV everything is healthy and idle./proc/$PID/task/shows thousands ofrs_normalthreads actually living in the OS — ~6k on be-0, ~21k on be-3 — that the pool doesn't account for.OS rs_normal thread count / task_execution_count_totalis ~1.26–1.40 on both BEs. Either (a) each task execution causes the pool to spawn a worker that's never reaped, or (b) there's a thread-creation path that bypasses thetask_execution_countcounter entirely.Net: the cap (
256) is being recorded into the pool's reported state, but it isn't being consulted when threads are spawned. Some path is creatingrs_normalworker threads without going through (or without being tracked by) the pool's bookkeeping that owns themax_threadscap. That's why the256setting is dead letter — the enforcement point exists, but the spawning path doesn't pass through it.num_cores = 8 vs default — still no difference
Both BEs report
max_threads{rs_normal} = 256andactive_threads{rs_normal} = 0. The other thread pool sizes (EvHttpServer=128,FragmentMgrAsyncWorkThreadPool=2048,ls_normal=256,mf_normal=128, etc.) are also identical between the two BEs — confirming thatnum_cores = 8in 4.1.1-rc01 changes theCpuCoresfield reported to the FE but doesn't actually influence any pool's sizing.Live state at capture time
num_cores=8, uptime ~1h45m):rs_normal=6224, threads=8241, growing at ~60/minrs_normal=21392, threads=23502, growing at ~75/min, was already past the 22k warning threshold at captureThe doris-be-3 BE is still running (not yet frozen). I'm letting it run toward the ~27k freeze threshold so I can also provide the freeze-time
be.INFO+SHOW BACKENDS\GwithAlive=falseas I previously offered. Let me know if you'd rather I rotate it before then and skip the freeze capture — the metrics + the earlier 4h29m log may be enough for you to find the leak path.yiguolei commentedon Jul 10, 2026
using pstack DORIS_BE_PID to get all stacks and send me the file.
tracy1014-hub commentedon Jul 10, 2026
Quick status note — pstack capture is in progress, freeze-time artifacts captured in parallel.
pstack status
Started
gdb -batch -ex "set pagination off" -ex "thread apply all bt" -p 955ondoris-be-3at 14:55 CST (06:55 UTC). The BE process has 25,035 threads at attach time, so the fullthread apply all btis taking a while — gdb has consumed ~7 min of CPU so far (utime=361s, stime=70s) and is still actively walking thread stacks. Estimated 20–30 min more to finish.Output is being piped through
gdb | gzip | base64so the file stays at 0 bytes until gdb finishes writing. I'll publish the pstack file in a follow-up comment as soon as it's done.Freeze-time artifacts captured meanwhile (while gdb is still attached)
/proc/$BE_PID/status:Thread census (top 10) from
/proc/$BE_PID/task/:SHOW BACKENDS\Gfrom FE —doris-be-3row:The other 3 BEs (including
doris-be-0withnum_cores = 8) areAlive=true, HeartbeatFailureCounter=0. Cluster-wise onlydoris-be-3is affected.Note on the freeze
This freeze is gdb-induced (the BE process is in ptrace stop while gdb walks 25k thread stacks), not the natural leak-induced freeze at ~27k threads. The BE crossed 25k threads during gdb attach. When gdb finishes and detaches, the BE should SIGCONT — I'll watch whether it recovers or stays frozen.
If it recovers, I'll let it run toward 27k to capture the leak-induced freeze state separately. If it doesn't recover (HEARTBEAT_FAIL keeps climbing after gdb detaches), I'll delete the pod and we'll have lost the freeze-time capture but the gdb pstack + the artifacts above are still good.
be.INFO(29 MB, startup → freeze),jni.log(4.8 MB),be.WARNING(107 KB),be.gc.log(3.3 MB), andbe.out(881 B) have all been pulled off the pod and will be published together with the pstack file once gdb is done.tracy1014-hub commentedon Jul 10, 2026
Hi @yiguolei — pstack captured. Gist with the raw pstack + analysis:
https://gist.github.com/tracy1014-hub/7ddbc6340653ead0d701d6ba26550577
Files:
pstack.txt— 3.5 MB, partial pstack of doris-be-3 (3,295 of 25,035 threads captured, 26% coverage)ANALYSIS.md— key findings + recommended next stepWhy partial
gdb -batch -ex "thread apply all bt" -p 955ran for ~33 min and had only walked 26% of the 25k threads (gdb is slow on this many threads — 100% CPU, ~7 min of CPU per 1k threads). I killed it before completion because waiting for full coverage would have taken hours and you'd be waiting on this reply. The 3,295 threads captured are statistically representative — see below.Headline finding
Every single thread in the pstack is
rs_normal, and 3,294 / 3,295 (99.97%) have an identical backtrace:(1 thread uses
pthread_cond_timedwaitinstead ofpthread_cond_wait, otherwise identical.)Diagnosis
The
rs_normalthreads are not C++ threads — they're JVM threads (created by the JNI scanner, almost certainly Paimon) parking on Java'sUnsafe_Parkprimitive waiting for work.This reconciles everything we've seen:
doris_be_thread_pool_active_threads{rs_normal} = 0— correct from the C++ pool's POV; none of these threads are in that pool.doris_be_thread_pool_max_threads{rs_normal} = 256— the cap exists on the C++ pool, but the 22,945 OS threads namedrs_normalwere never created by that pool. They share the thread name (probably because the JNI scanner uses the same naming convention) but bypass the C++ pool entirely.task_execution_count_total{rs_normal} = 15,297on be-3 (vs 22,945 actual threads) — the C++ pool has executed ~15k tasks, but ~22.9k JVM threads exist. The two numbers aren't directly comparable because they're counted by different accounting systems (one C++, one JVM), but the JVM-side count being higher is consistent with the JVM spawning threads that the C++ pool never sees.num_cores = 8had no effect on rs_normal growth — because the leak isn't on a path that consultsnum_cores. The leak is in the JNI scanner's Java-side thread creation logic.Where to look in the code
The leak path is in the Paimon JNI scanner (or whichever external-catalog scanner is being used), specifically:
Thread(or submits to an unboundedExecutorService) per external scan, parks it onUnsafe_Parkwaiting for work, but neverinterrupt()s orjoin()s it when the scan completes. The parked thread waits forever and accumulates.OptoRuntime::new_instance_Cframe a few levels deeper suggests the parked thread also allocates Java objects while waiting — possibly a freshRunnable/Callableper scan that gets GC'd but the carrier thread stays around.LockSupport.park,BlockingQueue.take,Unsafe.parkin the Paimon scanner JARs ($DORIS_HOME/lib/java_extensions/paimon-scanner/) — find the parking site, then check whether the correspondingunpark/put/notifysite is reachable on scan completion.Cluster state
After I killed gdb with
kill -9, the BE process was left inT (stopped)state (gdb didn't get a chance to send SIGCONT before dying). A manualkill -CONT 955from inside the container woke it up — it immediately went back toS (sleeping), FE marked itAlive=true,HeartbeatFailureCounter=0, pod back to1/1 Running. So the freeze during pstack was purely ptrace-induced, not a real leak-induced freeze at ~27k threads.However, the k8s liveness probe had failed during the 38-min gdb pause (BE was unresponsive because ptrace-stopped), so k8s restarted the
becontainer shortly after SIGCONT. The pod is now1/1 RunningwithRESTARTS=1, BE_PID=992 (was 955), and rs_normal count reset to 8 (fresh start). So the natural-freeze capture I was hoping to get fromdoris-be-3won't happen on this pod instance — it would take another 5+ hours of growth.If you'd like the freeze-time
be.INFO+SHOW BACKENDS\GwithAlive=falsefrom a natural ~27k-thread freeze, I can either:doris-be-3so it doesn't get killed during the next gdb attach, then re-run pstack on the natural freezedoris-be-3(or one of the other control BEs) run to natural freeze without gdb interference — the rotator is currently suspended, and the natural-freeze log would be different from the gdb-induced freeze logLet me know which (if any) you want. Otherwise the pstack analysis above is the main deliverable.
yiguolei commentedon Jul 10, 2026
It seems they are java threads. Doris use JNI to access paimon file. There should be a jvm inside doris be process. You could find the JVM ID and then you should use jstack to get jvm's stack.
yiguolei commentedon Jul 10, 2026
@tracy1014-hub Do you have a wechat?
yiguolei commentedon Jul 10, 2026
And you can also use jmap to print the object num in JVM
tracy1014-hub commentedon Jul 10, 2026
Hi @yiguolei —
jstackandjmapcaptured. You're spot-on: these are Java threads in the embedded JVM insidedoris_be. Public gist:https://gist.github.com/tracy1014-hub/1af1032ea8b8cc08b6cf3d28d790abfe
Files:
jstack-doris-be-0.txt.gz.b64— fulljstack 994output (15,080 Java threads). Decode:base64 -d file.b64 | gunzip > jstack.txt. jstack ran in 6 seconds total — vastly faster than gdb.jmap-histo-doris-be-0.txt—jmap -histo 994output (Java object histogram).JVM-ANALYSIS.md— analysis.Thread name distribution (15,080 total Java threads)
7,324 of the 15,080 Java threads are workers in
ScheduledThreadPoolExecutorinstances, all namedsdk-ScheduledExecutor-N-Mwith unique (N, M) pairs. Each unique N is a distinctScheduledThreadPoolExecutorinstance — so we have thousands of leaked executors in the JVM.All 7,324 parked threads have an identical stack
All parked on
ScheduledThreadPoolExecutor$DelayedWorkQueue.take— waiting for tasks that never arrive. The executors are nevershutdown()'d, so their workers never exit.jmap histogram confirms the leak
Top objects by bytes/instances:
byte[]org.apache.paimon.data.Timestampjava.nio.HeapByteBufferjava.util.HashMap$Nodejavax.management.MBeanAttributeInfojavax.management.Attributeorg.apache.hadoop.metrics2.impl.MetricCounterLongjava.lang.management.ThreadInfojava.lang.Threadorg.apache.paimon.shade.org.apache.parquet.io.api.Binary$ByteBufferBackedBinaryorg.apache.paimon.memory.MemorySegmentKey signals:
java.lang.Threadinstances: 15,053 — matches the OS-levelrs_normalthread count (15,174) within timing delta. Byte-for-byte confirmation that thers_normalOS threads are JavaThreadobjects.org.apache.paimon.data.Timestamp5.6 M instances — Paimon allocates a Timestamp per row (or per scan?) and they're not being GC'd. Suggests aThreadLocalcache or per-scanHashMapthat holds Timestamps and is never cleared.javax.management.MBeanAttributeInfo634k +MetricCounterLong577k — Hadoop metrics2 MBeans. The S3A client registers MBeans perS3AFileSysteminstance. 634k MBean attributes is consistent with thousands of distinct S3A client instances — every Paimon scan creates a new S3A client → new MBean registration → never unregistered.byte[]521 MB across 570k instances — Paimon data buffers / Parquet column payloads.AQS$ConditionNode~25k instances (in the full histogram) — corresponds to the ~15k parked workers (each holds one AQS$ConditionNode).Diagnosis
The leak path:
S3AFileSysteminstance (with its own S3 client,ScheduledThreadPoolExecutorfor connection pool management, MBean registration for Hadoop metrics2, etc.).S3AFileSystemis neverclose()'d, so itsScheduledThreadPoolExecutoris nevershutdown()'d, and its MBeans are never unregistered.sdk-ScheduledExecutor-N-M, parked onDelayedWorkQueue.takewaiting for tasks that never come.ScheduledThreadPoolExecutorinstances grows linearly with query rate. By 4-5 hours, we have 7,324 leaked executors → 15k parked worker threads → ~5-7 GB RSS just for thread stacks → freeze or OOM.The
jni.logfrom earlier also showed this pattern — every Paimon scan re-runsHadoopSecuredFileSystem.trySecureFileSystem+HadoopModule.install+CodecPool.getDecompressorfrom scratch, no caching of the FileSystem or compressor across scans.Where to look in the code
The fix has to be on the Paimon JNI scanner side, not in the C++ remote scanner pool. Likely places to look:
$DORIS_HOME/lib/java_extensions/paimon-scanner/— search for classes that createS3AFileSystem,AmazonS3Client,S3Client, or callFileSystem.get(...). Each call site should be checked for whether the returned FileSystem is cached/shared vs. created fresh.FileSystemclass has a globalFileSystem.CACHEkeyed by URI + UserGroupInformation — if every Paimon scan uses a slightly different URI (e.g., different query parameters, different path prefix), the cache will hold a new entry per scan, and each entry holds aScheduledThreadPoolExecutor. Worth checkingorg.apache.hadoop.fs.FileSystem$Cache$CacheCleaneractivity and the size ofFileSystem.CACHE.S3AFileSystem(or at least callclose()/shutdown()on the per-scan instance when the scan completes).The C++ remote scanner pool is healthy and bounded (
max_threads{rs_normal}=256,active=0,task_execution_count=4,798). The leak is entirely on the JVM side.WeChat
Will DM you about this separately — I want to keep the technical thread clean.
Live state
doris-be-0(the experiment BE withnum_cores = 8) is still running and growing. Latest monitor sample: rs_normal=15,174, threads=17,197 at T+3h38m. It's on track to hit the natural ~27k-thread freeze in roughly 2-3 more hours. I'm happy to either let it run to capture the natural-freezebe.INFO, or rotate it now that we've nailed the diagnosis — your call.yiguolei commentedon Jul 10, 2026
@tracy1014-hub Do you use paimon on HDFS or on S3?
tracy1014-hub commentedon Jul 10, 2026
paimon on S3
zhangstar333 commentedon Jul 10, 2026
@tracy1014-hub
have look this issue, maybe u could have a temp test to alter the catalog property. eg: fs.xxx.impl.disable.cache, and xxx could be oss, cos, s3, s3a depends your bucket.
pls let me know how the testing goes. If this fix works, will dive deeper and figure out a more complete solution.
yiguolei commentedon Jul 13, 2026
@tracy1014-hub we have find the problem and will solve this problem in next version maybe version 4.1.4.
tracy1014-hub commentedon Jul 14, 2026
yiguolei commentedon Jul 14, 2026
In Doris, we disable filesystem cache by default.
Paimon use HDFS over S3 sdk to access file on S3.
In HDFS, it will generate a file system instance for every split and will save the file system object in a global CACHE.
There maybe a policy to evit the expired filesystem in global CACHE object.
But if the query has a lot of split, it will generate a lot of file system object in short time. And every file system object will create a thread pool.
yiguolei commentedon Jul 15, 2026
#65586 @tracy1014-hub we have tested this PR, it is ok. If you can compile Doris in your local machine, you can have a try.
tracy1014-hub commentedon Jul 15, 2026
OK, I will try to fix it, Thanks
tracy1014-hub commentedon Jul 17, 2026
Update: PR #65586 helps but doesn't fully fix rs_normal growth — real root cause is
fs.s3a.connection.request.timeout=60s(hadoop-aws SDK v2 timer)@yiguolei @zhangstar333 following up on my paimon-prod (Paimon 1.4.1 on S3, Doris 4.1.1-rc01) reproduction. Compiled PR #65586 locally as suggested and tested it side by side with vanilla — TL;DR: PR #65586 fixes the S3AFileSystem cache layer (correctly and provably), but it does not stop rs_normal from growing at ~80/min in our Paimon-only workload. The real leak source is one level deeper.
Test 1 — PR #65586 alone
Built patched
hadoop-deps.jar+doris-fe.jarfromapache/doris#65586head (453115a4), overlaid onapache/doris:be-4.1.1. Rolled to one BE (be-3). Observed for ~15h side-by-side against three unpatched BEs.jmap -histo:liveon the patched BE showed the FS layer is fixed:org.apache.hadoop.fs.s3a.S3AFileSystemorg.apache.hadoop.fs.s3a.S3AInstrumentationsoftware.amazon.awssdk.services.s3.DefaultS3ClientSo
doris.fs.cache.keyis doing exactly what it should — FS instances are being reused. Butrs_normal(i.e.sdk-ScheduledExecutor-*worker threads) kept climbing at the same rate as the unpatched BEs, and hit the 25k+ freeze threshold in ~4h.The real numbers on the patched BE:
java.util.concurrent.ScheduledThreadPoolExecutorsoftware.amazon.awssdk.utils.NamedThreadFactoryjava.util.concurrent.ThreadPoolExecutor$Workerjava.lang.ThreadMAT
merge_shortest_pathson those STPEs shows the GC root is each STPE's own daemon worker Thread (sdk-ScheduledExecutor-N-M) parked onDelayedWorkQueue.take— classic strong-ref cycle.Test 2 — JFR ThreadStart sampling: where do new STPEs come from
60-second JFR trace on be-3 captured 190 new
sdk-ScheduledExecutorthread starts. Every single stack was identical:Looking at
TimerUtils.timeSyncTaskIfNeeded(SDK v2):The
timeoutisfs.s3a.connection.request.timeout→apiCallTimeout/apiCallAttemptTimeoutonClientOverrideConfiguration. hadoop-aws 3.4.2 default is 60 seconds (seeConstants.DEFAULT_REQUEST_TIMEOUT_DURATIONandAWSClientConfig.createApiConnectionSettings). Because SDK v2 usesScheduledThreadPoolExecutor.corePoolSize=1, allowCoreThreadTimeOut=falsefor its internal timer STPE, everyschedule()call ensures a core worker is prestarted; those workers park forever and are strongly reachable via the STPE'sworkQueuearray.Since Paimon
HadoopFileIOissues an S3 request per split per read op, and the FS cache reuse (post-#65586) is by-bucket only, each S3 request still triggers onesdk-ScheduledExecutordaemon thread that never dies. Under our workload that's ~80/min per BE — 4h to freeze.The one-line fix
Zero code change, just a catalog property so hadoop-aws sets
apiCallTimeout=0andTimerUtilstakes the no-op path:Verified on paimon-prod for the last 30+ minutes across all 4 BEs (all on the base
apache/doris:be-4.1.1image, no fscachefix):ScheduledThreadPoolExecutorcount: 0 (was 5,895)NamedThreadFactorycount: 5 stable, not growing (was 5,536 and growing)rs_normalthread count: 48-68 stable per BE, not growing (was 20-27k and growing)total_threads: 2058-2078 (was 24-29k)Side effect is minor: no more 60s API call timeout, but socket/connection timeouts (
fs.s3a.connection.timeout, socket read timeout) still apply, and Doris' query-level timeout already covers the user-facing SLA.Suggestions for 4.1.4
apiCallTimeoutto 0 for the Doris-owned S3 clients, or document the interaction so users of Paimon-on-S3 know to setfs.s3a.connection.request.timeout=0in their catalog. Right now the default is a functional footgun on any long-running Doris BE that reads S3.HADOOP-*ticket asking whether SDK v2's per-request timeout timer should share a bounded executor instead of relying on the caller to shutdown/reuse — the fact that a singleschedule()call permanently adds a core worker is surprising and undocumented.Happy to attach the full jmap/jstack/MAT artifacts if useful.
CalvinKirs commentedon Jul 20, 2026
apache/hadoop#8426 Setting the timeout to
0doesn’t seem like the right solution. What we really need is the fix from the upstream Hadoop PR.