fix(agents): share runtimes across connection sessions

This commit is contained in:
t8y2 committed 2026-07-13 15:13:48 +08:00
1 parent 1280450b98
commit 67541232a2
67 files changed
+2904 -354

No files matched your search

@@ -106,7 +106,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
Connection conn = requireConnected();
uncheckedVoid(() -> beforeQueryExecution(conn, options.getTimeoutSecs()));
return JdbcExecutor.INSTANCE.execute(
return JdbcExecutor.current().execute(
conn,
sql,
schema,
@@ -122,7 +122,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
Connection conn = requireConnected();
uncheckedVoid(() -> beforeQueryExecution(conn, options.getTimeoutSecs()));
return JdbcExecutor.INSTANCE.executePage(
return JdbcExecutor.current().executePage(
conn,
sql,
schema,
@@ -134,19 +134,19 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
@Override
public QueryPageResult fetchQueryPage(String sessionId, int pageSize) {
return JdbcExecutor.INSTANCE.fetchPage(sessionId, pageSize);
return JdbcExecutor.current().fetchPage(sessionId, pageSize);
}
@Override
public boolean closeQuerySession(String sessionId) {
return JdbcExecutor.INSTANCE.closeQuerySession(sessionId);
return JdbcExecutor.current().closeQuerySession(sessionId);
}
@Override
public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
Connection conn = requireConnected();
uncheckedVoid(() -> beforeQueryExecution(conn, options.getTimeoutSecs()));
return JdbcExecutor.INSTANCE.startTableRead(
return JdbcExecutor.current().startTableRead(
conn,
sql,
schema,
@@ -158,12 +158,12 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
@Override
public QueryPageResult fetchTableReadPage(String sessionId, int pageSize) {
return JdbcExecutor.INSTANCE.fetchTableReadPage(sessionId, pageSize);
return JdbcExecutor.current().fetchTableReadPage(sessionId, pageSize);
}
@Override
public boolean closeTableReadSession(String sessionId) {
return JdbcExecutor.INSTANCE.closeTableReadSession(sessionId);
return JdbcExecutor.current().closeTableReadSession(sessionId);
}
@Override
@@ -0,0 +1,27 @@
package com.dbx.agent;
final class AgentExecutionContext {
private static final ThreadLocal<JdbcExecutor> JDBC_EXECUTOR = new ThreadLocal<>();
private AgentExecutionContext() {
}
static JdbcExecutor jdbcExecutor() {
JdbcExecutor executor = JDBC_EXECUTOR.get();
return executor == null ? JdbcExecutor.INSTANCE : executor;
}
static <T> T withJdbcExecutor(JdbcExecutor executor, DatabaseAgent.ThrowingSupplier<T> supplier) throws Exception {
JdbcExecutor previous = JDBC_EXECUTOR.get();
JDBC_EXECUTOR.set(executor);
try {
return supplier.get();
} finally {
if (previous == null) {
JDBC_EXECUTOR.remove();
} else {
JDBC_EXECUTOR.set(previous);
}
}
}
}
@@ -6,9 +6,14 @@ import java.util.List;
public final class AgentProtocol {
public static final int PROTOCOL_VERSION = 1;
public static final int MULTI_SESSION_PROTOCOL_VERSION = 2;
public static final String METHOD_HANDSHAKE = "handshake";
public static final String METHOD_CONNECT = "connect";
public static final String METHOD_OPEN_SESSION = "open_session";
public static final String METHOD_CLOSE_SESSION = "close_session";
public static final String METHOD_VALIDATE_SESSION = "validate_session";
public static final String METHOD_CANCEL_SESSION = "cancel_session";
public static final String METHOD_TEST_CONNECTION = "test_connection";
public static final String METHOD_VALIDATE_CONNECTION = "validate_connection";
public static final String METHOD_LIST_DATABASES = "list_databases";
@@ -67,6 +72,7 @@ public final class AgentProtocol {
public static final String CAPABILITY_TRANSACTION = "transaction";
public static final String CAPABILITY_DDL = "ddl";
public static final String CAPABILITY_KV = "kv";
public static final String CAPABILITY_MULTI_SESSION = "multi_session";
public static final List<String> CAPABILITIES = Collections.unmodifiableList(Arrays.asList(
CAPABILITY_CONNECT,
@@ -120,6 +126,20 @@ public final class AgentProtocol {
METHOD_SHUTDOWN
));
public static final List<String> MULTI_SESSION_METHODS;
static {
List<String> methods = new java.util.ArrayList<>(COMMON_METHODS);
int insertAt = methods.indexOf(METHOD_CONNECT) + 1;
methods.addAll(insertAt, Arrays.asList(
METHOD_OPEN_SESSION,
METHOD_CLOSE_SESSION,
METHOD_VALIDATE_SESSION,
METHOD_CANCEL_SESSION
));
MULTI_SESSION_METHODS = Collections.unmodifiableList(methods);
}
public static final List<String> MONGO_LEGACY_METHODS = Collections.unmodifiableList(Arrays.asList(
MONGO_METHOD_LIST_DATABASES,
MONGO_METHOD_LIST_COLLECTIONS,
@@ -151,6 +171,12 @@ public final class AgentProtocol {
return new HandshakeResult(PROTOCOL_VERSION, PROTOCOL_VERSION, CAPABILITIES);
}
public static HandshakeResult multiSessionHandshakeResult() {
List<String> capabilities = new java.util.ArrayList<>(CAPABILITIES);
capabilities.add(CAPABILITY_MULTI_SESSION);
return new HandshakeResult(MULTI_SESSION_PROTOCOL_VERSION, MULTI_SESSION_PROTOCOL_VERSION, capabilities);
}
public static final class HandshakeResult {
private final int protocolVersion;
private final int agentProtocolVersion;
@@ -47,46 +47,46 @@ public abstract class BaseDatabaseAgent implements DatabaseAgent {
@Override
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
Connection conn = requireConnected();
return JdbcExecutor.INSTANCE.executePage(
return JdbcExecutor.current().executePage(
conn,
sql,
schema,
this::setSchemaSQL,
options,
JdbcExecutor.INSTANCE::defaultResultValue
JdbcExecutor.current()::defaultResultValue
);
}
@Override
public QueryPageResult fetchQueryPage(String sessionId, int pageSize) {
return JdbcExecutor.INSTANCE.fetchPage(sessionId, pageSize);
return JdbcExecutor.current().fetchPage(sessionId, pageSize);
}
@Override
public boolean closeQuerySession(String sessionId) {
return JdbcExecutor.INSTANCE.closeQuerySession(sessionId);
return JdbcExecutor.current().closeQuerySession(sessionId);
}
@Override
public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
return JdbcExecutor.INSTANCE.startTableRead(
return JdbcExecutor.current().startTableRead(
requireConnected(),
sql,
schema,
this::setSchemaSQL,
options,
JdbcExecutor.INSTANCE::defaultResultValue
JdbcExecutor.current()::defaultResultValue
);
}
@Override
public QueryPageResult fetchTableReadPage(String sessionId, int pageSize) {
return JdbcExecutor.INSTANCE.fetchTableReadPage(sessionId, pageSize);
return JdbcExecutor.current().fetchTableReadPage(sessionId, pageSize);
}
@Override
public boolean closeTableReadSession(String sessionId) {
return JdbcExecutor.INSTANCE.closeTableReadSession(sessionId);
return JdbcExecutor.current().closeTableReadSession(sessionId);
}
@Override
@@ -102,22 +102,22 @@ public interface DatabaseAgent {
if (conn == null) {
throw new IllegalStateException("Not connected");
}
return JdbcExecutor.INSTANCE.executePage(
return AgentExecutionContext.jdbcExecutor().executePage(
conn,
sql,
schema,
this::setSchemaSQL,
options,
JdbcExecutor.INSTANCE::defaultResultValue
AgentExecutionContext.jdbcExecutor()::defaultResultValue
);
}
default QueryPageResult fetchQueryPage(String sessionId, int pageSize) {
return JdbcExecutor.INSTANCE.fetchPage(sessionId, pageSize);
return AgentExecutionContext.jdbcExecutor().fetchPage(sessionId, pageSize);
}
default boolean closeQuerySession(String sessionId) {
return JdbcExecutor.INSTANCE.closeQuerySession(sessionId);
return AgentExecutionContext.jdbcExecutor().closeQuerySession(sessionId);
}
default QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
@@ -125,22 +125,22 @@ public interface DatabaseAgent {
if (conn == null) {
throw new IllegalStateException("Not connected");
}
return JdbcExecutor.INSTANCE.startTableRead(
return AgentExecutionContext.jdbcExecutor().startTableRead(
conn,
sql,
schema,
this::setSchemaSQL,
options,
JdbcExecutor.INSTANCE::defaultResultValue
AgentExecutionContext.jdbcExecutor()::defaultResultValue
);
}
default QueryPageResult fetchTableReadPage(String sessionId, int pageSize) {
return JdbcExecutor.INSTANCE.fetchTableReadPage(sessionId, pageSize);
return AgentExecutionContext.jdbcExecutor().fetchTableReadPage(sessionId, pageSize);
}
default boolean closeTableReadSession(String sessionId) {
return JdbcExecutor.INSTANCE.closeTableReadSession(sessionId);
return AgentExecutionContext.jdbcExecutor().closeTableReadSession(sessionId);
}
/**
@@ -24,8 +24,13 @@ public final class JdbcExecutor {
private final ConcurrentHashMap<String, QuerySession> sessions = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, QuerySession> tableReadSessions = new ConcurrentHashMap<>();
private final java.util.Set<Statement> activeStatements = ConcurrentHashMap.newKeySet();
private JdbcExecutor() {
public JdbcExecutor() {
}
public static JdbcExecutor current() {
return AgentExecutionContext.jdbcExecutor();
}
public QueryResult execute(Connection conn, String sql, String schema, Function<String, String> setSchemaSql) {
@@ -72,6 +77,8 @@ public final class JdbcExecutor {
applySchema(conn, schema, setSchemaSql);
try (Statement stmt = conn.createStatement()) {
activeStatements.add(stmt);
try {
int effectiveMaxRows = Math.max(maxRows, 1);
stmt.setMaxRows(effectiveMaxRows + 1);
applyQueryTimeout(stmt, timeoutSecs);
@@ -96,6 +103,9 @@ public final class JdbcExecutor {
elapsed,
false
);
} finally {
activeStatements.remove(stmt);
}
}
});
}
@@ -199,6 +209,7 @@ public final class JdbcExecutor {
applySchema(conn, schema, setSchemaSql);
Statement stmt = conn.createStatement();
activeStatements.add(stmt);
try {
applyQueryTimeout(stmt, options.getTimeoutSecs());
if (options.getFetchSize() != null && options.getFetchSize() > 0) {
@@ -210,6 +221,7 @@ public final class JdbcExecutor {
long elapsed = System.currentTimeMillis() - start;
if (!hasResultSet) {
int updateCount = stmt.getUpdateCount();
activeStatements.remove(stmt);
stmt.close();
return new QueryPageResult(
Collections.emptyList(),
@@ -249,6 +261,7 @@ public final class JdbcExecutor {
targetSessions.put(sessionId, session);
return readSessionPage(targetSessions, session, options.getPageSize(), elapsed);
} catch (Exception e) {
activeStatements.remove(stmt);
try {
stmt.close();
} catch (Exception ignored) {
@@ -284,6 +297,20 @@ public final class JdbcExecutor {
closeAllSessions(tableReadSessions);
}
public void cancelActiveStatements() {
for (Statement statement : activeStatements) {
try {
statement.cancel();
} catch (SQLException ignored) {
}
}
// Paged result sets can remain idle between fetch requests, so no
// Statement is executing when cancellation arrives. Close those
// session-owned cursors as part of the same cancellation boundary.
closeAllQuerySessions();
closeAllTableReadSessions();
}
public int expireIdleQuerySessions() {
return expireIdleQuerySessions(System.currentTimeMillis(), QUERY_SESSION_IDLE_TIMEOUT_MILLIS);
}
@@ -504,6 +531,7 @@ public final class JdbcExecutor {
return false;
}
synchronized (session) {
activeStatements.remove(session.statement);
try {
session.resultSet.close();
} catch (Exception ignored) {
@@ -23,6 +23,7 @@ public final class JsonRpcServer {
private static final long CONNECTION_VALIDATION_INTERVAL_MILLIS = 5_000L;
private final DatabaseAgent agent;
private final JdbcExecutor jdbcExecutor;
private final Gson gson = new GsonBuilder()
// JDBC DECIMAL/NUMERIC values can exceed JavaScript Number precision after JSON-RPC parsing.
.registerTypeAdapter(
@@ -39,6 +40,7 @@ public final class JsonRpcServer {
public JsonRpcServer(DatabaseAgent agent) {
this.agent = agent;
this.jdbcExecutor = new JdbcExecutor();
}
public void run() {
@@ -84,6 +86,14 @@ public final class JsonRpcServer {
}
}
Object dispatchForRuntime(String method, JsonObject params) throws Exception {
return AgentExecutionContext.withJdbcExecutor(jdbcExecutor, () -> dispatch(method, params));
}
void cancelActiveStatements() {
jdbcExecutor.cancelActiveStatements();
}
private Object dispatch(String method, JsonObject params) throws Exception {
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
return AgentProtocol.handshakeResult();
@@ -243,18 +253,17 @@ public final class JsonRpcServer {
return agent.executeBatch(statements, stringOrNull(params, "schema"));
}
if (AgentProtocol.METHOD_DISCONNECT.equals(method)) {
JdbcExecutor.INSTANCE.closeAllQuerySessions();
JdbcExecutor.INSTANCE.closeAllTableReadSessions();
jdbcExecutor.closeAllQuerySessions();
jdbcExecutor.closeAllTableReadSessions();
agent.disconnect();
lastConnectParams = null;
return Collections.singletonMap("ok", true);
}
if (AgentProtocol.METHOD_SHUTDOWN.equals(method)) {
JdbcExecutor.INSTANCE.closeAllQuerySessions();
JdbcExecutor.INSTANCE.closeAllTableReadSessions();
jdbcExecutor.closeAllQuerySessions();
jdbcExecutor.closeAllTableReadSessions();
agent.disconnect();
lastConnectParams = null;
System.exit(0);
return Collections.singletonMap("ok", true);
}
throw new IllegalArgumentException("Unknown method: " + method);
@@ -300,8 +309,8 @@ public final class JsonRpcServer {
return;
}
JdbcExecutor.INSTANCE.closeAllQuerySessions();
JdbcExecutor.INSTANCE.closeAllTableReadSessions();
jdbcExecutor.closeAllQuerySessions();
jdbcExecutor.closeAllTableReadSessions();
try {
agent.disconnect();
} catch (Exception ignored) {
@@ -0,0 +1,180 @@
package com.dbx.agent;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
public final class MultiSessionJsonRpcServer {
private static final String LEGACY_SESSION_ID = "__legacy__";
private static final int MAX_SESSIONS = 256;
private final Supplier<? extends DatabaseAgent> agentFactory;
private final Map<String, Session> sessions = new ConcurrentHashMap<>();
private final ExecutorService requests = Executors.newCachedThreadPool();
private final Gson gson = new Gson();
private final Object outputLock = new Object();
public MultiSessionJsonRpcServer(Supplier<? extends DatabaseAgent> agentFactory) {
this.agentFactory = agentFactory;
}
public void run() {
synchronized (outputLock) {
System.out.println("{\"ready\":true}");
System.out.flush();
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in))) {
String line;
while ((line = reader.readLine()) != null) {
JsonObject request = JsonParser.parseString(line).getAsJsonObject();
String method = request.get("method").getAsString();
if (AgentProtocol.METHOD_SHUTDOWN.equals(method)) {
writeResponse(handleRequest(request));
closeAllSessions();
requests.shutdown();
return;
}
requests.submit(() -> writeResponse(handleRequest(request)));
}
} catch (Exception e) {
closeAllSessions();
throw new RuntimeException(e);
}
}
String handleRequest(String line) {
return gson.toJson(handleRequest(JsonParser.parseString(line).getAsJsonObject()));
}
private JsonObject handleRequest(JsonObject request) {
JsonElement id = request.get("id");
String method = request.get("method").getAsString();
JsonObject params = request.has("params") && request.get("params").isJsonObject()
? request.getAsJsonObject("params")
: new JsonObject();
JsonObject response = new JsonObject();
response.addProperty("jsonrpc", "2.0");
response.add("id", id);
try {
Object result;
if (AgentProtocol.METHOD_HANDSHAKE.equals(method)) {
result = AgentProtocol.multiSessionHandshakeResult();
} else if (AgentProtocol.METHOD_OPEN_SESSION.equals(method)) {
result = openSession(requiredSessionId(params), params);
} else if (AgentProtocol.METHOD_CLOSE_SESSION.equals(method)) {
result = closeSession(requiredSessionId(params));
} else if (AgentProtocol.METHOD_VALIDATE_SESSION.equals(method)) {
result = session(requiredSessionId(params)).handle("validate_connection", params);
} else if (AgentProtocol.METHOD_CANCEL_SESSION.equals(method)) {
session(requiredSessionId(params)).cancel();
result = Collections.singletonMap("ok", true);
} else if (AgentProtocol.METHOD_TEST_CONNECTION.equals(method)) {
result = new JsonRpcServer(agentFactory.get()).dispatchForRuntime(method, params);
} else if (AgentProtocol.METHOD_CONNECT.equals(method)) {
closeSession(LEGACY_SESSION_ID);
result = openSession(LEGACY_SESSION_ID, params);
} else if (AgentProtocol.METHOD_DISCONNECT.equals(method)) {
result = closeSession(LEGACY_SESSION_ID);
} else if (AgentProtocol.METHOD_SHUTDOWN.equals(method)) {
result = Collections.singletonMap("ok", true);
} else {
String sessionId = params.has("agentSessionId") ? params.get("agentSessionId").getAsString() : LEGACY_SESSION_ID;
result = session(sessionId).handle(method, params);
}
response.add("result", gson.toJsonTree(result));
} catch (Throwable error) {
JsonObject rpcError = new JsonObject();
rpcError.addProperty("code", -1);
rpcError.addProperty("message", error.getMessage() == null ? error.toString() : error.getMessage());
response.add("error", rpcError);
}
return response;
}
private Object openSession(String sessionId, JsonObject params) throws Exception {
if (sessions.size() >= MAX_SESSIONS && !sessions.containsKey(sessionId)) {
throw new IllegalStateException("Agent session limit reached: " + MAX_SESSIONS);
}
Session session = new Session(new JsonRpcServer(agentFactory.get()));
Session existing = sessions.putIfAbsent(sessionId, session);
if (existing != null) {
throw new IllegalStateException("Agent session already exists: " + sessionId);
}
try {
return session.handle(AgentProtocol.METHOD_CONNECT, params);
} catch (Exception error) {
sessions.remove(sessionId, session);
session.close();
throw error;
}
}
private Object closeSession(String sessionId) {
Session session = sessions.remove(sessionId);
if (session != null) {
session.close();
}
return Collections.singletonMap("ok", true);
}
private Session session(String sessionId) {
Session session = sessions.get(sessionId);
if (session == null) {
throw new IllegalStateException("Agent session not found: " + sessionId);
}
return session;
}
private void closeAllSessions() {
for (String sessionId : sessions.keySet()) {
closeSession(sessionId);
}
}
private static String requiredSessionId(JsonObject params) {
if (!params.has("agentSessionId") || params.get("agentSessionId").getAsString().trim().isEmpty()) {
throw new IllegalArgumentException("agentSessionId is required");
}
return params.get("agentSessionId").getAsString();
}
private void writeResponse(JsonObject response) {
synchronized (outputLock) {
System.out.println(gson.toJson(response));
System.out.flush();
}
}
private static final class Session {
private final JsonRpcServer server;
private Session(JsonRpcServer server) {
this.server = server;
}
private synchronized Object handle(String method, JsonObject params) throws Exception {
return server.dispatchForRuntime(method, params);
}
private synchronized void close() {
try {
server.dispatchForRuntime(AgentProtocol.METHOD_DISCONNECT, new JsonObject());
} catch (Exception ignored) {
}
}
private void cancel() {
server.cancelActiveStatements();
}
}
}
@@ -30,7 +30,7 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
@Override
public QueryResult executeQuery(String sql, String schema, ExecuteQueryOptions options) {
return JdbcExecutor.INSTANCE.execute(
return JdbcExecutor.current().execute(
requireConnected(),
sql,
schema,
@@ -44,7 +44,7 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
@Override
public QueryPageResult executeQueryPage(String sql, String schema, QueryPageOptions options) {
return JdbcExecutor.INSTANCE.executePage(
return JdbcExecutor.current().executePage(
requireConnected(),
sql,
schema,
@@ -56,7 +56,7 @@ public abstract class PostgresLikeAgent extends AbstractJdbcAgent {
@Override
public QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
return JdbcExecutor.INSTANCE.startTableRead(
return JdbcExecutor.current().startTableRead(
requireConnected(),
sql,
schema,
@@ -0,0 +1,98 @@
{
"protocolVersion": 2,
"handshakeMethod": "handshake",
"handshakeResponseFields": [
"protocolVersion",
"agentProtocolVersion",
"capabilities"
],
"allCapabilities": [
"connect",
"test_connection",
"metadata",
"query",
"paged_query",
"transaction",
"ddl",
"kv",
"multi_session"
],
"capabilities": [
"connect",
"test_connection",
"metadata",
"query",
"paged_query",
"transaction",
"ddl",
"multi_session"
],
"defaultSqlCapabilities": [
"connect",
"test_connection",
"metadata",
"query",
"paged_query",
"transaction",
"ddl",
"multi_session"
],
"commonMethods": [
"handshake",
"connect",
"open_session",
"close_session",
"validate_session",
"cancel_session",
"test_connection",
"validate_connection",
"list_databases",
"list_schemas",
"list_tables",
"list_objects",
"list_data_types",
"completion_assistant_search_v1",
"get_object_source",
"get_table_ddl",
"get_columns",
"list_indexes",
"list_foreign_keys",
"list_triggers",
"execute_query",
"execute_query_page",
"fetch_query_page",
"close_query_session",
"start_table_read",
"fetch_table_read_page",
"close_table_read_session",
"get_explain_info",
"execute_batch",
"execute_transaction",
"disconnect",
"shutdown"
],
"mongoLegacyMethods": [
"list_databases",
"list_collections",
"find_documents",
"find_documents_extended_json",
"count_documents",
"server_version",
"create_index",
"drop_indexes",
"drop_collection",
"insert_document",
"update_document",
"update_documents",
"delete_document",
"delete_documents"
],
"kvMethods": [
"kv_list_prefix",
"kv_get",
"kv_put",
"kv_delete"
],
"sessionField": "agentSessionId",
"cursorSessionField": "sessionId"
}