[Bug] HikariCP connection pools are never closed: leaked housekeeper threads pin the job ChildFirstClassLoader (Metaspace leak in session clusters)
- Dominant language
- Java
- Stars
- 6.5k
- Forks
- 2.2k
- Avg merge
- 3d 9h
- Merged PRs (30d)
- 7
Description
### Search before asking
- [X] I searched in the [issues](https://github.com/apache/flink-cdc/issues) and found nothing similar.
Closest prior art, none of it covering this: #3533 / #3534 (`[hotfix][runtime] Invalidate cache correctly to avoid classloader leakage`, a different cache), #4534 (`[FLINK-40623][cdc-base] Release chunk splitter JDBC connection once a table is split`, about a single splitter connection rather than the pool), #2512 (adds `hikari.*` options but does not touch pool lifecycle).
### Flink version
Flink 1.20.5, Flink CDC 3.6.0, MySQL CDC source (MySQL 5.7.28). Standalone session cluster on Kubernetes, Temurin 17.0.19, TaskManager `-XX:MaxMetaspaceSize=768m`.
### Bug
**HikariCP connection pools created by the CDC source are never closed, so their `housekeeper` threads outlive the job and permanently pin the job's `ChildFirstClassLoader`.** In a session cluster this is an unbounded Metaspace leak.
`JdbcConnectionPools` is a static singleton holding the pools:
```java
private static JdbcConnectionPools instance;
private final Map pools = new HashMap<>();
public void clear() throws IOException {
synchronized (pools) {
pools.values().stream().forEach(HikariDataSource::close);
...
}
}
```
`clear()` is the only thing that ever calls `HikariDataSource.close()`. Of the eight files in this repository that reference `JdbcConnectionPools`, **the only one that calls `clear()` is `flink-cdc-base/src/test/java/.../GenericConnectionPoolTest.java`**. There is no production caller — not in `JdbcDataSourceDialect`, `JdbcConnectionFactory`, `MySqlSnapshotSplitAssigner` or `MySqlBinlogSplitAssigner`. (The same pattern exists in both copies: `flink-cdc-base/...relational/connection/JdbcConnectionPools.java` and `flink-connector-mysql-cdc/...source/connection/JdbcConnectionPools.java`.)
Each `HikariDataSource` starts a daemon thread named ` housekeeper`, where `poolName` is built as `connection-pool-:` in `JdbcConnectionPoolFactory` / `PooledDataSourceFactory`. Because nothing closes the pool, that thread runs for the lifetime of the TaskManager JVM.
### Evidence from a heap dump
I dumped a TaskManager that had **zero allocated slots** — verified two ways: `allocatedSlots` was empty in the REST API, and a sweep of all 71 subtasks of all 7 running jobs found none on that host (other TaskManagers returned non-zero counts, so the sweep itself was working). Every thread below therefore belongs to a job that is **no longer scheduled on that TaskManager**.
Thread-name histogram of that idle TaskManager (110 threads total):
```
connection-pool-:3306 housekeeper x4
blc-keepalive-:3306 x1
IOManager reader thread #1 x2
...
```
Of the 19 live `ChildFirstClassLoader` instances in that JVM, **8 are retained by lingering threads**, and the biggest single group is these pools:
| retaining thread | count | classloaders pinned |
|---|---|---|
| `connection-pool-:3306 housekeeper` | 4 | **4** |
| `java-sdk-progress-listener-callback-thread` | 1 | 1 |
| `FileChannelManagerImpl-io` / `IOManagerAsync` shutdown hooks | 2 | 2 |
| `Thread-11` | 1 | 1 |
The retention path, taken from the dump:
```
java.lang.Thread ("connection-pool-:3306 housekeeper") <- GC root (ROOT_THREAD_OBJECT)
.inheritedAccessControlContext
-> java.security.AccessControlContext .context
-> java.security.ProtectionDomain[] [0]
-> java.security.ProtectionDomain .classloader
-> org.apache.flink.util.ChildFirstClassLoader <- pinned
```
A thread created while user-code frames are on the stack inherits an `AccessControlContext` whose `ProtectionDomain` references the job's classloader. So the thread does not merely leak itself — it pins the entire user classloader and everything it loaded.
`blc-keepalive-:3306` (the binlog client keepalive thread) also survives on the idle TaskManager. It did not show up on a retention path in this particular dump, but it is the same class of problem and probably worth fixing together.
### Impact
In a long-running session cluster this is unbounded. Our TaskManagers climb to the 768 MB Metaspace cap and have to be rotated; only a TaskManager restart returns the memory. Measured across 3,008 samples on 48 TaskManager instances, several climb monotonically from ~5 % to 86–92 % and never recover.
This is one of three retention mechanisms we found in the same dump; the others are Hadoop's `ReflectionUtils.CONSTRUCTOR_CACHE` and a JDK `SoftReference` cache, reported separately. The connection pools are the one that clearly belongs to Flink CDC.
### What to reproduce
1. Session cluster, MySQL CDC source job.
2. Let it run, then stop/cancel it (or let it be rescheduled to another TaskManager).
3. On a TaskManager that now holds **no** tasks: `jcmd Thread.print | grep housekeeper` — the pool housekeeper threads are still there.
4. `jcmd VM.classloader_stats` — the job's `ChildFirstClassLoader` is still live, and Metaspace is not returned.
Note for step 3/4: the official Flink image ships a JRE, so `jcmd` has to be brought in; and HotSpot attach requires the JVM's own uid, so `su` to the `flink` user first rather than running as root.
### Suggested direction
The pools need a lifecycle tied to the source. Options that come to mind, in rough order of containment:
1. Close the pool when the last reader/enumerator using a given `ConnectionPoolId` is closed (reference-count the `ConnectionPoolId`), rather than relying on a singleton that is never cleared.
2. Failing that, call `JdbcConnectionPools.getInstance(...).clear()` from the source reader / enumerator `close()` path — coarse, but it at least gives `clear()` a production caller.
3. Independently, creating the pool inside a `Thread` whose context is the user classloader is what turns "a leaked thread" into "a leaked classloader". Creating HikariCP's threads under a neutral context, or setting a thread factory that does not inherit the AccessControlContext, would limit the blast radius even if a pool is left open.
### Are you willing to submit a PR?
- [X] I'm willing to submit a PR! I can also re-run the heap dump to verify a fix — the check is simply that the `connection-pool-* housekeeper` threads are gone from an idle TaskManager and that the corresponding `ChildFirstClassLoader` count drops.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start by reading JdbcConnectionPools.java in both flink-cdc-base/.../relational/connection and flink-connector-mysql-cdc/.../source/connection, then inspect JdbcDataSourceDialect, JdbcConnectionFactory, and the MySQL reader and enumerator close paths. Run GenericConnectionPoolTest.java and trace how production code manages each ConnectionPoolId. Done means pools close when their source lifecycle ends, housekeeper threads disappear from idle TaskManagers, and the corresponding ChildFirstClassLoader instances can be collected.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java, mysql
- Domain
- backend, databases
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100