This is an automated email from the ASF dual-hosted git repository.
davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git
The following commit(s) were added to refs/heads/dev by this push:
new b253248ab7 [Fix][Connector-V2] Clean up stale SFTP pooled sessions
(#11180)
b253248ab7 is described below
commit b253248ab7ba8985ccb9b7f750c01888fb614bfb
Author: Daniel <[email protected]>
AuthorDate: Tue Jun 30 20:06:52 2026 +0800
[Fix][Connector-V2] Clean up stale SFTP pooled sessions (#11180)
---
.../file/sftp/system/SFTPConnectionPool.java | 127 +++++++++-----
.../seatunnel/file/sftp/system/SFTPFileSystem.java | 18 +-
.../file/sftp/system/SFTPConnectionPoolTest.java | 183 +++++++++++++++++++++
3 files changed, 283 insertions(+), 45 deletions(-)
diff --git
a/seatunnel-connectors-v2/connector-file/connector-file-sftp/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sftp/system/SFTPConnectionPool.java
b/seatunnel-connectors-v2/connector-file/connector-file-sftp/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sftp/system/SFTPConnectionPool.java
index 4906c8061a..677d56c9e9 100644
---
a/seatunnel-connectors-v2/connector-file/connector-file-sftp/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sftp/system/SFTPConnectionPool.java
+++
b/seatunnel-connectors-v2/connector-file/connector-file-sftp/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sftp/system/SFTPConnectionPool.java
@@ -31,6 +31,7 @@ import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
+import java.util.Map;
import java.util.Set;
public class SFTPConnectionPool {
@@ -53,6 +54,9 @@ public class SFTPConnectionPool {
}
synchronized ChannelSftp getFromPool(ConnectionInfo info) throws
IOException {
+ if (con2infoMap == null) {
+ throw new IOException("SFTP connection pool has been closed.");
+ }
Set<ChannelSftp> cons = idleConnections.get(info);
ChannelSftp channel;
@@ -60,7 +64,10 @@ public class SFTPConnectionPool {
Iterator<ChannelSftp> it = cons.iterator();
if (it.hasNext()) {
channel = it.next();
- idleConnections.remove(info);
+ it.remove();
+ if (cons.isEmpty()) {
+ idleConnections.remove(info);
+ }
return channel;
} else {
throw new IOException("Connection pool error.");
@@ -69,42 +76,50 @@ public class SFTPConnectionPool {
return null;
}
- synchronized void returnToPool(ChannelSftp channel) {
+ synchronized boolean returnToPool(ChannelSftp channel) {
+ if (con2infoMap == null) {
+ return false;
+ }
ConnectionInfo info = con2infoMap.get(channel);
+ if (info == null) {
+ return false;
+ }
HashSet<ChannelSftp> cons = idleConnections.get(info);
if (cons == null) {
cons = new HashSet<ChannelSftp>();
idleConnections.put(info, cons);
}
cons.add(channel);
+ return true;
}
/** Shutdown the connection pool and close all open connections. */
- synchronized void shutdown() {
- if (this.con2infoMap == null) {
- return; // already shutdown in case it is called
+ void shutdown() {
+ Map<ChannelSftp, ConnectionInfo> connectionsToClose;
+ synchronized (this) {
+ if (this.con2infoMap == null) {
+ return; // already shutdown in case it is called
+ }
+ LOG.info("Inside shutdown, con2infoMap size=" +
con2infoMap.size());
+
+ // Shutdown must close every tracked connection regardless of
live-count drift.
+ connectionsToClose = new HashMap<ChannelSftp,
ConnectionInfo>(con2infoMap);
+ this.maxConnection = 0;
+ this.liveConnectionCount = 0;
+ this.idleConnections = null;
+ this.con2infoMap = null;
}
- LOG.info("Inside shutdown, con2infoMap size=" + con2infoMap.size());
- this.maxConnection = 0;
- Set<ChannelSftp> cons = con2infoMap.keySet();
- if (cons != null && cons.size() > 0) {
- // make a copy since we need to modify the underlying Map
- Set<ChannelSftp> copy = new HashSet<ChannelSftp>(cons);
- // Initiate disconnect from all outstanding connections
- for (ChannelSftp con : copy) {
- try {
- disconnect(con);
- } catch (IOException ioe) {
- ConnectionInfo info = con2infoMap.get(con);
- LOG.error(
- "Error encountered while closing connection to " +
info.getHost(), ioe);
- }
+ for (Map.Entry<ChannelSftp, ConnectionInfo> entry :
connectionsToClose.entrySet()) {
+ try {
+ closeChannel(entry.getKey());
+ } catch (IOException ioe) {
+ LOG.error(
+ "Error encountered while closing connection to "
+ + entry.getValue().getHost(),
+ ioe);
}
}
- // make sure no further connections can be returned.
- this.idleConnections = null;
- this.con2infoMap = null;
}
public synchronized int getMaxConnection() {
@@ -125,11 +140,9 @@ public class SFTPConnectionPool {
if (channel.isConnected()) {
return channel;
} else {
+ removeTrackedChannel(channel);
+ closeChannel(channel);
channel = null;
- synchronized (this) {
- --liveConnectionCount;
- con2infoMap.remove(channel);
- }
}
}
@@ -155,6 +168,9 @@ public class SFTPConnectionPool {
session = jsch.getSession(user, host, port);
}
+ // JSch creates a session reader thread; make it daemon so leaked
sessions cannot keep
+ // Spark local-mode JVMs alive after a batch job has already
finished.
+ session.setDaemonThread(true);
session.setPassword(password);
java.util.Properties config = new java.util.Properties();
@@ -182,31 +198,62 @@ public class SFTPConnectionPool {
// close connection if too many active connections
boolean closeConnection = false;
synchronized (this) {
- if (liveConnectionCount > maxConnection) {
+ if (con2infoMap == null || !con2infoMap.containsKey(channel)) {
+ closeConnection = true;
+ } else if (liveConnectionCount > maxConnection) {
--liveConnectionCount;
con2infoMap.remove(channel);
closeConnection = true;
}
}
if (closeConnection) {
- if (channel.isConnected()) {
- try {
- Session session = channel.getSession();
- channel.disconnect();
- session.disconnect();
- } catch (JSchException e) {
- throw new
IOException(StringUtils.stringifyException(e));
- }
+ closeChannel(channel);
+ } else if (!returnToPool(channel)) {
+ closeChannel(channel);
+ }
+ }
+ }
+
+ /**
+ * Remove bookkeeping for a channel that can no longer be reused before we
create a replacement
+ * connection.
+ */
+ synchronized void removeTrackedChannel(ChannelSftp channel) {
+ if (con2infoMap == null) {
+ return;
+ }
+ ConnectionInfo info = con2infoMap.remove(channel);
+ if (info != null) {
+ Set<ChannelSftp> cons = idleConnections.get(info);
+ if (cons != null) {
+ cons.remove(channel);
+ if (cons.isEmpty()) {
+ idleConnections.remove(info);
}
+ }
+ }
+ if (liveConnectionCount > 0) {
+ liveConnectionCount--;
+ }
+ }
- } else {
- returnToPool(channel);
+ /** Close both the SFTP channel and its backing SSH session when they are
still open. */
+ private void closeChannel(ChannelSftp channel) throws IOException {
+ try {
+ Session session = channel.getSession();
+ if (channel.isConnected()) {
+ channel.disconnect();
}
+ if (session != null && session.isConnected()) {
+ session.disconnect();
+ }
+ } catch (JSchException e) {
+ throw new IOException(StringUtils.stringifyException(e));
}
}
public int getIdleCount() {
- return this.idleConnections.size();
+ return this.idleConnections == null ? 0 : this.idleConnections.size();
}
public int getLiveConnCount() {
@@ -214,7 +261,7 @@ public class SFTPConnectionPool {
}
public int getConnPoolSize() {
- return this.con2infoMap.size();
+ return this.con2infoMap == null ? 0 : this.con2infoMap.size();
}
/**
diff --git
a/seatunnel-connectors-v2/connector-file/connector-file-sftp/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sftp/system/SFTPFileSystem.java
b/seatunnel-connectors-v2/connector-file/connector-file-sftp/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sftp/system/SFTPFileSystem.java
index 99bf417763..0990726aa0 100644
---
a/seatunnel-connectors-v2/connector-file/connector-file-sftp/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sftp/system/SFTPFileSystem.java
+++
b/seatunnel-connectors-v2/connector-file/connector-file-sftp/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sftp/system/SFTPFileSystem.java
@@ -115,7 +115,7 @@ public class SFTPFileSystem extends FileSystem {
}
int connectionMax = conf.getInt(FS_SFTP_CONNECTION_MAX,
DEFAULT_MAX_CONNECTION);
- connectionPool = new SFTPConnectionPool(connectionMax, connectionMax);
+ connectionPool = new SFTPConnectionPool(connectionMax, 0);
}
private ChannelSftp connect() throws IOException {
@@ -548,8 +548,11 @@ public class SFTPFileSystem extends FileSystem {
new FSDataOutputStream(os, statistics) {
@Override
public void close() throws IOException {
- super.close();
- disconnect(client);
+ try {
+ super.close();
+ } finally {
+ disconnect(client);
+ }
}
};
@@ -651,7 +654,12 @@ public class SFTPFileSystem extends FileSystem {
@Override
public void close() throws IOException {
- super.close();
- connectionPool.shutdown();
+ try {
+ super.close();
+ } finally {
+ if (connectionPool != null) {
+ connectionPool.shutdown();
+ }
+ }
}
}
diff --git
a/seatunnel-connectors-v2/connector-file/connector-file-sftp/src/test/java/org/apache/seatunnel/connectors/seatunnel/file/sftp/system/SFTPConnectionPoolTest.java
b/seatunnel-connectors-v2/connector-file/connector-file-sftp/src/test/java/org/apache/seatunnel/connectors/seatunnel/file/sftp/system/SFTPConnectionPoolTest.java
new file mode 100644
index 0000000000..cf26cbfbe5
--- /dev/null
+++
b/seatunnel-connectors-v2/connector-file/connector-file-sftp/src/test/java/org/apache/seatunnel/connectors/seatunnel/file/sftp/system/SFTPConnectionPoolTest.java
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.connectors.seatunnel.file.sftp.system;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import com.jcraft.jsch.ChannelSftp;
+import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.Session;
+
+import java.lang.reflect.Field;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Tests the SFTP connection pool bookkeeping that continuous discovery relies
on during cleanup.
+ */
+class SFTPConnectionPoolTest {
+
+ /**
+ * Keep sibling idle channels tracked after one channel is borrowed from
the shared pool key.
+ */
+ @Test
+ void getFromPoolShouldKeepOtherIdleChannelsTracked() throws Exception {
+ SFTPConnectionPool connectionPool = new SFTPConnectionPool(2, 0);
+ SFTPConnectionPool.ConnectionInfo connectionInfo =
+ new SFTPConnectionPool.ConnectionInfo("host", 22, "user");
+ ChannelSftp firstChannel = new TestChannelSftp(true,
Mockito.mock(Session.class));
+ ChannelSftp secondChannel = new TestChannelSftp(true,
Mockito.mock(Session.class));
+
+ HashSet<ChannelSftp> idleChannels = new HashSet<>();
+ idleChannels.add(firstChannel);
+ idleChannels.add(secondChannel);
+ idleConnections(connectionPool).put(connectionInfo, idleChannels);
+ trackedConnections(connectionPool).put(firstChannel, connectionInfo);
+ trackedConnections(connectionPool).put(secondChannel, connectionInfo);
+
+ ChannelSftp borrowedChannel =
connectionPool.getFromPool(connectionInfo);
+
+ Assertions.assertTrue(borrowedChannel == firstChannel ||
borrowedChannel == secondChannel);
+ Set<ChannelSftp> remainingIdleChannels =
+ idleConnections(connectionPool).get(connectionInfo);
+ Assertions.assertNotNull(remainingIdleChannels);
+ Assertions.assertEquals(1, remainingIdleChannels.size());
+
Assertions.assertFalse(remainingIdleChannels.contains(borrowedChannel));
+ }
+
+ /** Always disconnect the SSH session when a stale channel is being
permanently closed. */
+ @Test
+ void disconnectShouldCloseSessionForDisconnectedChannel() throws Exception
{
+ SFTPConnectionPool connectionPool = new SFTPConnectionPool(0, 1);
+ SFTPConnectionPool.ConnectionInfo connectionInfo =
+ new SFTPConnectionPool.ConnectionInfo("host", 22, "user");
+ Session session = Mockito.mock(Session.class);
+ Mockito.when(session.isConnected()).thenReturn(true);
+ TestChannelSftp channel = new TestChannelSftp(false, session);
+ trackedConnections(connectionPool).put(channel, connectionInfo);
+
+ connectionPool.disconnect(channel);
+
+ Mockito.verify(session).disconnect();
+ Assertions.assertFalse(channel.wasDisconnected());
+ Assertions.assertEquals(0, connectionPool.getLiveConnCount());
+ Assertions.assertEquals(0, connectionPool.getConnPoolSize());
+ }
+
+ /** Shutdown must close tracked sessions even when live-count bookkeeping
has drifted. */
+ @Test
+ void shutdownShouldCloseTrackedChannelsRegardlessOfLiveCount() throws
Exception {
+ SFTPConnectionPool connectionPool = new SFTPConnectionPool(2, 0);
+ SFTPConnectionPool.ConnectionInfo connectionInfo =
+ new SFTPConnectionPool.ConnectionInfo("host", 22, "user");
+ Session session = Mockito.mock(Session.class);
+ Mockito.when(session.isConnected()).thenReturn(true);
+ TestChannelSftp channel = new TestChannelSftp(true, session);
+ trackedConnections(connectionPool).put(channel, connectionInfo);
+
+ connectionPool.shutdown();
+
+ Assertions.assertTrue(channel.wasDisconnected());
+ Mockito.verify(session).disconnect();
+ }
+
+ /** Late disconnects after shutdown must close the channel instead of
touching closed maps. */
+ @Test
+ void disconnectAfterShutdownShouldCloseChannel() throws Exception {
+ SFTPConnectionPool connectionPool = new SFTPConnectionPool(1, 0);
+ Session session = Mockito.mock(Session.class);
+ Mockito.when(session.isConnected()).thenReturn(true);
+ TestChannelSftp channel = new TestChannelSftp(true, session);
+
+ connectionPool.shutdown();
+ connectionPool.disconnect(channel);
+
+ Assertions.assertTrue(channel.wasDisconnected());
+ Mockito.verify(session).disconnect();
+ Assertions.assertEquals(0, connectionPool.getIdleCount());
+ Assertions.assertEquals(0, connectionPool.getConnPoolSize());
+ }
+
+ /** Unknown channels are not reusable pool entries, so they must be closed
immediately. */
+ @Test
+ void disconnectShouldCloseUntrackedChannel() throws Exception {
+ SFTPConnectionPool connectionPool = new SFTPConnectionPool(1, 1);
+ Session session = Mockito.mock(Session.class);
+ Mockito.when(session.isConnected()).thenReturn(true);
+ TestChannelSftp channel = new TestChannelSftp(true, session);
+
+ connectionPool.disconnect(channel);
+
+ Assertions.assertTrue(channel.wasDisconnected());
+ Mockito.verify(session).disconnect();
+ Assertions.assertEquals(0, connectionPool.getIdleCount());
+ }
+
+ /** Small concrete ChannelSftp stub that keeps Mockito away from final
JSch internals. */
+ private static final class TestChannelSftp extends ChannelSftp {
+ private final boolean connected;
+ private final Session session;
+ private boolean disconnected;
+
+ private TestChannelSftp(boolean connected, Session session) {
+ this.connected = connected;
+ this.session = session;
+ }
+
+ @Override
+ public boolean isConnected() {
+ return connected;
+ }
+
+ @Override
+ public void disconnect() {
+ disconnected = true;
+ }
+
+ @Override
+ public Session getSession() throws JSchException {
+ return session;
+ }
+
+ private boolean wasDisconnected() {
+ return disconnected;
+ }
+ }
+
+ /** Read the private idle map to assert that the pool does not lose
sibling channels. */
+ @SuppressWarnings("unchecked")
+ private static Map<SFTPConnectionPool.ConnectionInfo,
HashSet<ChannelSftp>> idleConnections(
+ SFTPConnectionPool connectionPool) throws Exception {
+ Field field =
SFTPConnectionPool.class.getDeclaredField("idleConnections");
+ field.setAccessible(true);
+ return (Map<SFTPConnectionPool.ConnectionInfo, HashSet<ChannelSftp>>)
+ field.get(connectionPool);
+ }
+
+ /** Read the private tracked-channel map to seed deterministic pool state
for unit tests. */
+ @SuppressWarnings("unchecked")
+ private static Map<ChannelSftp, SFTPConnectionPool.ConnectionInfo>
trackedConnections(
+ SFTPConnectionPool connectionPool) throws Exception {
+ Field field = SFTPConnectionPool.class.getDeclaredField("con2infoMap");
+ field.setAccessible(true);
+ return (Map<ChannelSftp, SFTPConnectionPool.ConnectionInfo>)
field.get(connectionPool);
+ }
+}