Compare commits
7 Commits
main
...
93296eb810
| Author | SHA1 | Date | |
|---|---|---|---|
| 93296eb810 | |||
| 47a2706f11 | |||
| e146653de7 | |||
| 880f810ba2 | |||
| b4bdc392ee | |||
| 02b8fdd3ae | |||
| 2f67d90144 |
42
README.md
42
README.md
@@ -10,6 +10,8 @@ Easy-Agents 是一个轻量、可扩展的 Java AI 应用开发框架,覆盖
|
||||
- MCP 客户端能力(调用、拦截、缓存与管理)
|
||||
- 文档读取与切分、向量存储与检索
|
||||
- 工作流执行引擎(Flow)与 Easy-Agents 适配支持
|
||||
- 基于 Calcite 的 SQL 编译、方言适配与流式 JDBC 查询
|
||||
- 基于 Quartz JDBC JobStore 的嵌入式分布式定时调度
|
||||
|
||||
## 模块说明
|
||||
|
||||
@@ -26,6 +28,8 @@ Easy-Agents 是一个轻量、可扩展的 Java AI 应用开发框架,覆盖
|
||||
- `easy-agents-mcp`:MCP 集成。
|
||||
- `easy-agents-skill`:标准 Agent Skills 包模型、安全校验、资源存储与 ZIP 双向编解码。
|
||||
- `easy-agents-flow`:流程编排核心引擎。
|
||||
- `easy-agents-federation-sql`:高性能 SQL 联邦查询内核与可扩展数据库 Adapter。
|
||||
- `easy-agents-scheduler`:业务无关的调度 API、Quartz Provider 与独立 Spring Boot Starter。
|
||||
- `easy-agents-support`:Flow 与 Easy-Agents 适配模块。
|
||||
- `easy-agents-spring-boot-starter`:Spring Boot 自动配置支持。
|
||||
- `easy-agents-samples`:示例工程。
|
||||
@@ -76,7 +80,9 @@ public static void main(String[] args) {
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-bom</artifactId>
|
||||
<version>0.0.1</version>
|
||||
<version>1.2.0-RC</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
@@ -92,3 +98,37 @@ public static void main(String[] args) {
|
||||
</dependency>
|
||||
</dependencies>
|
||||
```
|
||||
|
||||
## 嵌入式分布式调度
|
||||
|
||||
Spring Boot 项目可直接引入独立 Starter:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-scheduler-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
引用方需要先从 `easy-agents-scheduler-quartz` 构件的 `quartz-schema/` 目录选择 MySQL、PostgreSQL 或 H2 脚本,并纳入自己的 Flyway、Liquibase 或初始化流程。Starter 不会自动创建、删除或修改 Quartz 表。
|
||||
|
||||
最小配置:
|
||||
|
||||
```yaml
|
||||
easy-agents:
|
||||
scheduler:
|
||||
enabled: true
|
||||
# 多 DataSource 时必须指定 Bean 名称
|
||||
data-source-bean-name: dataSource
|
||||
quartz:
|
||||
scheduler-name: easyAgentsScheduler
|
||||
instance-id: AUTO
|
||||
clustered: true
|
||||
table-prefix: QRTZ_
|
||||
thread-count: 8
|
||||
shutdown-wait-timeout-millis: 30000
|
||||
```
|
||||
|
||||
业务方将 `ScheduleHandler` 注册为 Spring Bean,并通过 `ScheduleService` 创建 Cron 或一次性任务。调度触发采用至少一次语义,Handler 需要使用 `scheduleId + scheduledFireTime` 或立即触发的 `invocationId` 实现业务幂等。应用关闭超过等待上限后会向 Handler 线程发送协作式中断;长耗时 Handler 必须正确响应线程中断,忽略中断的业务代码仍可能继续占用 Quartz Worker。完整建表说明见 `easy-agents-scheduler/easy-agents-scheduler-quartz/SCHEMA.md`。
|
||||
|
||||
当前 Provider 固定使用 Quartz `2.5.2`,`easy-agents-bom` 已同步管理该传递依赖。若业务项目还引入了其他 BOM 或显式 Quartz 版本,接入时应执行 `mvn dependency:tree -Dincludes=org.quartz-scheduler:quartz`,确认最终解析版本仍为 `2.5.2`。
|
||||
|
||||
@@ -11,12 +11,48 @@
|
||||
|
||||
<name>easy-agents-bom</name>
|
||||
<artifactId>easy-agents-bom</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>17</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.quartz-scheduler</groupId>
|
||||
<artifactId>quartz</artifactId>
|
||||
<version>${quartz.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-federation-sql-core</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-federation-sql-adapter-jdbc</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-scheduler-core</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-scheduler-quartz</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-scheduler-spring-boot-starter</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
|
||||
<dependency>
|
||||
@@ -95,6 +131,31 @@
|
||||
<artifactId>easy-agents-rag-retrieval</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-federation-sql-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-federation-sql-adapter-jdbc</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-scheduler-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-scheduler-quartz</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-scheduler-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!--image model start-->
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
|
||||
@@ -43,11 +43,6 @@ public class StoreOptions extends Metadata {
|
||||
public void setEmbeddingOptions(EmbeddingOptions embeddingOptions) {
|
||||
throw new IllegalStateException("Can not set embeddingOptions to the default instance.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setTimeoutMillis(Long timeoutMillis) {
|
||||
throw new IllegalStateException("Can not set timeoutMillis to the default instance.");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -70,11 +65,6 @@ public class StoreOptions extends Metadata {
|
||||
*/
|
||||
private EmbeddingOptions embeddingOptions = EmbeddingOptions.DEFAULT;
|
||||
|
||||
/**
|
||||
* Optional upper bound for one store operation.
|
||||
*/
|
||||
private Long timeoutMillis;
|
||||
|
||||
|
||||
public String getCollectionName() {
|
||||
return collectionName;
|
||||
@@ -121,17 +111,6 @@ public class StoreOptions extends Metadata {
|
||||
this.embeddingOptions = embeddingOptions;
|
||||
}
|
||||
|
||||
public Long getTimeoutMillis() {
|
||||
return timeoutMillis;
|
||||
}
|
||||
|
||||
public void setTimeoutMillis(Long timeoutMillis) {
|
||||
if (timeoutMillis != null && timeoutMillis <= 0L) {
|
||||
throw new IllegalArgumentException("timeoutMillis must be greater than zero");
|
||||
}
|
||||
this.timeoutMillis = timeoutMillis;
|
||||
}
|
||||
|
||||
|
||||
public static StoreOptions ofCollectionName(String collectionName) {
|
||||
StoreOptions storeOptions = new StoreOptions();
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
package com.easyagents.core.store;
|
||||
|
||||
/**
|
||||
* Indicates that a store operation exhausted its caller-provided time budget.
|
||||
*/
|
||||
public class StoreTimeoutException extends RuntimeException {
|
||||
|
||||
public StoreTimeoutException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public StoreTimeoutException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package com.easyagents.document.core.async;
|
||||
|
||||
import com.easyagents.core.util.StringUtil;
|
||||
import com.easyagents.document.core.exception.DocumentParseException;
|
||||
import com.easyagents.document.core.exception.DocumentAsyncTaskNotFoundException;
|
||||
import com.easyagents.document.core.entity.ParseResponse;
|
||||
import com.easyagents.document.core.entity.ParseTaskInfo;
|
||||
import com.easyagents.document.core.entity.ParseTaskStatus;
|
||||
@@ -136,7 +135,7 @@ public class DocumentAsyncTaskManager {
|
||||
}
|
||||
DocumentAsyncTaskRecord record = repository.find(taskId);
|
||||
if (record == null) {
|
||||
throw new DocumentAsyncTaskNotFoundException(taskId);
|
||||
throw new DocumentParseException("Document async task not found: " + taskId);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.easyagents.document.core.exception;
|
||||
|
||||
/**
|
||||
* 进程内异步文档任务不存在。
|
||||
*
|
||||
* <p>本地 Office 解析任务允许使用内存仓库;进程重启后,调用方可以
|
||||
* 通过该异常识别执行实例已经丢失,并从持久化业务任务重新提交。</p>
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-09-02
|
||||
*/
|
||||
public class DocumentAsyncTaskNotFoundException extends DocumentParseException {
|
||||
|
||||
private final String taskId;
|
||||
|
||||
public DocumentAsyncTaskNotFoundException(String taskId) {
|
||||
super("Document async task not found: " + taskId);
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已丢失的任务 ID。
|
||||
*
|
||||
* @return 任务 ID
|
||||
*/
|
||||
public String getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import com.easyagents.document.core.entity.ParseResponse;
|
||||
import com.easyagents.document.core.entity.ParseResult;
|
||||
import com.easyagents.document.core.entity.ParseTaskInfo;
|
||||
import com.easyagents.document.core.entity.ParseTaskStatus;
|
||||
import com.easyagents.document.core.exception.DocumentAsyncTaskNotFoundException;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
@@ -19,21 +18,6 @@ import java.util.concurrent.Executor;
|
||||
*/
|
||||
public class DocumentAsyncTaskManagerTest {
|
||||
|
||||
@Test
|
||||
public void shouldExposeMissingInMemoryTaskAsRecoverableSignal() {
|
||||
DocumentAsyncTaskManager manager = new DocumentAsyncTaskManager(
|
||||
new InMemoryDocumentAsyncTaskRepository(),
|
||||
Runnable::run
|
||||
);
|
||||
|
||||
try {
|
||||
manager.queryTaskInfo("lost-task");
|
||||
Assert.fail("expected DocumentAsyncTaskNotFoundException");
|
||||
} catch (DocumentAsyncTaskNotFoundException error) {
|
||||
Assert.assertEquals("lost-task", error.getTaskId());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldTrackTaskLifecycleAndResult() {
|
||||
Executor directExecutor = new Executor() {
|
||||
|
||||
@@ -14,7 +14,6 @@ import com.easyagents.document.core.entity.ParseRequest;
|
||||
import com.easyagents.document.core.entity.ParseResponse;
|
||||
import com.easyagents.document.core.entity.ParseResult;
|
||||
import com.easyagents.document.core.entity.XlsxParseRequest;
|
||||
import com.easyagents.document.core.exception.DocumentParseException;
|
||||
import com.easyagents.document.core.support.AbstractAsyncDocumentParseService;
|
||||
import com.easyagents.document.xlsx.XlsxDocumentProvider;
|
||||
import com.easyagents.document.xlsx.model.XlsxCellArtifact;
|
||||
@@ -53,10 +52,6 @@ import java.util.concurrent.Executors;
|
||||
public class MineruXlsxDocumentParseService extends AbstractAsyncDocumentParseService<XlsxParseRequest> implements XlsxDocumentProvider {
|
||||
|
||||
public static final String PROVIDER_NAME = "mineru";
|
||||
private static final byte[] OLE2_SIGNATURE = new byte[] {
|
||||
(byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0,
|
||||
(byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
|
||||
};
|
||||
|
||||
private final MineruProperties properties;
|
||||
private final MineruClient client;
|
||||
@@ -150,9 +145,6 @@ public class MineruXlsxDocumentParseService extends AbstractAsyncDocumentParseSe
|
||||
|
||||
@Override
|
||||
protected ParseResponse doParse(XlsxParseRequest request, DocumentAsyncTaskUpdater updater) {
|
||||
for (ParseFile file : request.getFiles()) {
|
||||
validateXlsxContent(file);
|
||||
}
|
||||
ParseResponse response = new ParseResponse();
|
||||
List<ParseResult> results = new ArrayList<ParseResult>();
|
||||
String backend = null;
|
||||
@@ -219,50 +211,6 @@ public class MineruXlsxDocumentParseService extends AbstractAsyncDocumentParseSe
|
||||
return aggregate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 POI 打开工作簿前校验 XLSX 容器签名,避免向用户暴露底层格式异常。
|
||||
*
|
||||
* @param file 待解析文件
|
||||
*/
|
||||
private void validateXlsxContent(ParseFile file) {
|
||||
byte[] content = file == null ? null : file.getContent();
|
||||
if (hasZipSignature(content)) {
|
||||
return;
|
||||
}
|
||||
String fileName = file == null || !StringUtil.hasText(file.getFileName())
|
||||
? "当前文件"
|
||||
: "文件“" + file.getFileName() + "”";
|
||||
String reason = startsWith(content, OLE2_SIGNATURE)
|
||||
? "可能是旧版 XLS 或已加密文件"
|
||||
: "文件内容与 .xlsx 扩展名不一致或文件已损坏";
|
||||
throw new DocumentParseException(
|
||||
fileName + "不是标准 XLSX," + reason
|
||||
+ "。请解除保护后用 Excel/WPS 另存为 XLSX(修改文件后缀无效)"
|
||||
);
|
||||
}
|
||||
|
||||
private boolean hasZipSignature(byte[] content) {
|
||||
return content != null
|
||||
&& content.length >= 4
|
||||
&& content[0] == 'P'
|
||||
&& content[1] == 'K'
|
||||
&& ((content[2] == 3 && content[3] == 4)
|
||||
|| (content[2] == 5 && content[3] == 6)
|
||||
|| (content[2] == 7 && content[3] == 8));
|
||||
}
|
||||
|
||||
private boolean startsWith(byte[] content, byte[] signature) {
|
||||
if (content == null || content.length < signature.length) {
|
||||
return false;
|
||||
}
|
||||
for (int index = 0; index < signature.length; index++) {
|
||||
if (content[index] != signature[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private SheetExtraction extractSheet(XSSFSheet sheet,
|
||||
int sheetIndex,
|
||||
DataFormatter formatter,
|
||||
|
||||
@@ -16,7 +16,6 @@ import com.easyagents.document.core.entity.ParseTaskStatus;
|
||||
import com.easyagents.document.core.entity.XlsxParseRequest;
|
||||
import com.easyagents.document.core.exception.DocumentParseException;
|
||||
import com.easyagents.document.xlsx.model.XlsxParseArtifact;
|
||||
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
|
||||
import org.apache.poi.ss.usermodel.ClientAnchor;
|
||||
import org.apache.poi.xssf.usermodel.XSSFDrawing;
|
||||
import org.apache.poi.xssf.usermodel.XSSFSheet;
|
||||
@@ -120,31 +119,6 @@ public class MineruXlsxDocumentParseServiceTest {
|
||||
Assert.assertEquals("image/jpeg", result.getImages().get(0).getMimeType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRejectLegacyXlsContentWithActionableMessage() throws Exception {
|
||||
RecordingClient client = new RecordingClient(defaultProperties());
|
||||
MineruMapper mapper = new MineruMapper(defaultProperties());
|
||||
MineruXlsxDocumentParseService service = new MineruXlsxDocumentParseService(
|
||||
defaultProperties(),
|
||||
client,
|
||||
mapper,
|
||||
new DocumentAsyncTaskManager(new InMemoryDocumentAsyncTaskRepository(), directExecutor())
|
||||
);
|
||||
XlsxParseRequest request = new XlsxParseRequest();
|
||||
request.addFile(ParseFile.of("legacy.xlsx", buildLegacyWorkbookBytes()));
|
||||
|
||||
DocumentParseException error = Assert.assertThrows(
|
||||
DocumentParseException.class,
|
||||
() -> service.parse(request)
|
||||
);
|
||||
|
||||
Assert.assertEquals(
|
||||
"文件“legacy.xlsx”不是标准 XLSX,可能是旧版 XLS 或已加密文件。"
|
||||
+ "请解除保护后用 Excel/WPS 另存为 XLSX(修改文件后缀无效)",
|
||||
error.getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldAppendImageReferenceForImageOnlySheet() throws Exception {
|
||||
RecordingClient client = new RecordingClient(defaultProperties());
|
||||
@@ -281,15 +255,6 @@ public class MineruXlsxDocumentParseServiceTest {
|
||||
return writeWorkbook(workbook);
|
||||
}
|
||||
|
||||
private byte[] buildLegacyWorkbookBytes() throws Exception {
|
||||
try (HSSFWorkbook workbook = new HSSFWorkbook();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
workbook.createSheet("Sheet1").createRow(0).createCell(0).setCellValue("旧版表格");
|
||||
workbook.write(outputStream);
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private void addPicture(XSSFWorkbook workbook,
|
||||
XSSFSheet sheet,
|
||||
int rowIndex,
|
||||
|
||||
156
easy-agents-federation-sql/README.md
Normal file
156
easy-agents-federation-sql/README.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Easy-Agents Federation SQL
|
||||
|
||||
基于 Apache Calcite 的 SQL 编译、方言转换、数据源绑定与流式 JDBC 查询底座。
|
||||
|
||||
## 模块
|
||||
|
||||
- `easy-agents-federation-sql-core`:公共 API、Calcite 编译、单源/联邦自动路由、计划缓存、数据源 Runtime、准入、指标与取消。
|
||||
- `easy-agents-federation-sql-adapter-jdbc`:默认 JDBC Adapter,也是信创数据库 Adapter 的实现示例。
|
||||
|
||||
业务项目通常只需依赖 JDBC Adapter,它会传递依赖 Core:
|
||||
|
||||
```xml
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-bom</artifactId>
|
||||
<version>1.2.0-RC</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-federation-sql-adapter-jdbc</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## 公共入口
|
||||
|
||||
- `FederationSqlEngines.builder()`:组装 Engine、Resolver、策略与准入控制器。
|
||||
- `engine.sources()`:探测、绑定、预热、更新或移除数据源 Definition。
|
||||
- `engine.compile()` / `engine.execute()`:高级的节点本地计划模式。
|
||||
- `engine.query()`:推荐入口,在接收请求的节点完成编译或缓存命中并立即执行。
|
||||
- `engine.explain()`:显式返回 Calcite 计划;`PHYSICAL` 级别还会请求各数据库的非 `ANALYZE` Explain。
|
||||
- `engine.cancel(queryId)`:取消准入等待、JDBC 执行或游标消费中的节点本地查询;编译阶段收到取消后不会继续执行。
|
||||
|
||||
`FederationSqlPlan` 是 Engine 签发的只读接口,只能交回签发它的 Engine 执行。
|
||||
|
||||
`FederationSourceDefinition` 始终描述一个物理数据源。一次查询可见的单源或虚拟联邦范围由调用方使用 `FederationQueryScopeDefinition` 声明;Core 不持久化虚拟数据源,也不保存凭据。
|
||||
|
||||
## 最小使用示例
|
||||
|
||||
```java
|
||||
SourceId sourceId = new SourceId("main");
|
||||
FederationSourceDefinition definition = new FederationSourceDefinition(
|
||||
sourceId,
|
||||
1,
|
||||
JdbcFederationSqlAdapterProvider.ADAPTER_ID,
|
||||
List.of(new JdbcSchemaDefinition("APP", null, "public")),
|
||||
Map.of()
|
||||
);
|
||||
|
||||
try (FederationSqlEngine engine = FederationSqlEngines.builder()
|
||||
.dataSourceResolver(current -> {
|
||||
HikariDataSource pool = createPool(current.sourceId());
|
||||
RuntimeFingerprint fingerprint = detectFingerprint(pool);
|
||||
return FederationDataSourceHandles.owned(pool, fingerprint, pool::close);
|
||||
})
|
||||
.maximumPlanCacheEntries(1024)
|
||||
.maximumPlanCacheWeightBytes(64L * 1024L * 1024L)
|
||||
.planCacheTimeToLive(Duration.ofMinutes(30))
|
||||
.build()) {
|
||||
engine.sources().apply(definition, SourceApplyOptions.prewarmNow());
|
||||
|
||||
SqlQueryCommand command = SqlQueryCommand.of(
|
||||
"SELECT NAME FROM APP.PERSON WHERE ID = ?",
|
||||
sourceId,
|
||||
1,
|
||||
List.of(new SqlParameter(Types.INTEGER, 1))
|
||||
);
|
||||
try (FederationResultCursor cursor = engine.query(command)) {
|
||||
while (cursor.next()) {
|
||||
System.out.println(cursor.row());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`createPool`、凭据存储和 `detectFingerprint` 由调用方实现。Core 管理 Handle/Runtime 生命周期;连接复用、超时、泄漏检测和预热连接数由 HikariCP 等连接池负责。
|
||||
|
||||
## 虚拟联邦查询
|
||||
|
||||
调用方先分别登记 MySQL 与 PostgreSQL 的物理 `FederationSourceDefinition`,再为一次查询组装逻辑 Binding:
|
||||
|
||||
```java
|
||||
FederationQueryScopeDefinition scope = FederationQueryScopeDefinition.virtual(
|
||||
"sales-analysis",
|
||||
7,
|
||||
Map.of(
|
||||
"SALES", FederationSourceBindingDefinition.of(
|
||||
new SourceId("mysql-sales"), 12, Map.of("APP", "APP")
|
||||
),
|
||||
"CRM", FederationSourceBindingDefinition.of(
|
||||
new SourceId("pg-crm"), 5, Map.of("APP", "APP")
|
||||
)
|
||||
),
|
||||
"SALES",
|
||||
FederationExecutionPolicy.basic()
|
||||
);
|
||||
|
||||
String sql = """
|
||||
SELECT c.ID, SUM(o.AMOUNT) AS TOTAL
|
||||
FROM CRM.APP.CUSTOMER c
|
||||
JOIN SALES.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID
|
||||
GROUP BY c.ID
|
||||
ORDER BY TOTAL DESC
|
||||
""";
|
||||
|
||||
try (FederationResultCursor cursor = engine.query(
|
||||
SqlQueryCommand.of(sql, scope, List.of())
|
||||
)) {
|
||||
while (cursor.next()) {
|
||||
System.out.println(cursor.row());
|
||||
}
|
||||
FederationQueryMetricsSnapshot metrics = cursor.metrics();
|
||||
}
|
||||
```
|
||||
|
||||
查询模式按 Calcite 校验后实际引用的物理 `SourceId` 数量决定。多 Binding Scope 中只引用一个源的 SQL 仍完整下推;引用多个源时,Core 生成目标方言 Fragment,并使用有界 Calcite 本地算子汇总。
|
||||
|
||||
调用方已有表列统计快照时,可以通过 `tableStatisticsProvider(...)` 注入行数、行宽、列基数、空值率和唯一键。Provider 的 `snapshot()` 必须一次性返回同时冻结版本、数据和有效期的 `FederationStatisticsSnapshot`,编译阶段不得主动执行 `COUNT(*)`;版本变化会隔离旧计划缓存,计划缓存期限也不会超过统计快照的最早失效时间。统计完整且未过期时,等值 `INNER JOIN` 会把估算搬运量较小的一侧作为本地 Hash Table 构建端;统计缺失、不完整或过期时保持稳定的保守顺序。逻辑 Explain 的每个 Fragment 会返回估算是否可用、扫描/输出行数、行宽、搬运字节、统计来源/采集时间和下推算子。
|
||||
|
||||
首批联邦算子覆盖等值 `INNER JOIN`、`LEFT JOIN`、`UNION ALL`、`COUNT/SUM/MIN/MAX/AVG`、普通 `GROUP BY`、CTE、排序和分页。非等值 Join、联邦本地字符比较/排序/分组/`MIN/MAX`、`UNION DISTINCT`、窗口函数、磁盘 Spill 与跨库事务快照会明确拒绝。字符型本地算子需要调用方先统一排序规则,后续再由 Adapter 提供可验证的 Collation 能力。Calcite 本地时间表示只保证毫秒精度;映射精度超过 3 位或运行时检测到亚毫秒值时会明确拒绝。驱动以 `ANY/OTHER` 返回的标准 JDBC 时区标量会保留纳秒并统一为 UTC Offset。
|
||||
|
||||
结果采用标准流式 Cursor 语义:Fragment 或本地算子可能在调用方已读取若干行后失败,已交付的行无法撤回。调用方只能在 `next()` 正常返回 `false` 后将本次结果视为完整成功;需要不可逆副作用时应先完整消费并自行提交,或提供补偿机制。
|
||||
|
||||
## Explain 与指标
|
||||
|
||||
普通 `query` 不会访问数据库 Optimizer。只有显式调用物理 Explain 才会产生额外数据库往返:
|
||||
|
||||
```java
|
||||
SqlCompileRequest compile = SqlCompileRequest.of(sql, scope);
|
||||
SqlExplainResult logical = engine.explain(
|
||||
new SqlExplainRequest(compile, SqlExplainLevel.LOGICAL)
|
||||
);
|
||||
SqlExplainResult physical = engine.explain(new SqlExplainRequest(compile));
|
||||
```
|
||||
|
||||
`physical.fragments()` 为每个 Fragment 返回目标方言 SQL、参数映射和数据库原生计划。MySQL/PostgreSQL Adapter 尽力归一化扫描方式、候选索引、选中索引、估算行数与过滤条件;数据库没有返回的字段保持空值。为避免原生计划回显敏感常量,Explain 不接受实际参数值,只按 `SqlCompileRequest` 声明的 JDBC 类型绑定 `NULL`,因此索引选择可能与真实参数计划不同。
|
||||
|
||||
`FederationResultCursor.metrics()` 可在消费过程中读取,并在耗尽或关闭后定稿,包含模式、计划缓存命中、编译、准入等待、连接获取、数据库执行、本地算子、首行与完整消费耗时,以及最终行/字节、中间搬运行/字节、截断、超时、错误分类和各 Fragment 统计。Adapter 无法安全估算字节时对应字段为 `-1`,不会用 `0` 冒充已测量值。
|
||||
|
||||
查询总时限取 Engine、Query Scope 和请求 JDBC timeout 中的最小值。硬时限会覆盖连接池等待后的 JDBC 执行和游标消费,并尝试同时 `cancel`、关闭全部活动 Statement/Cursor;连接池自身仍需配置有限的 connection timeout,以约束 Statement 创建前的连接获取阶段。
|
||||
|
||||
## Adapter 扩展
|
||||
|
||||
实现 `FederationSqlAdapterProvider` 并通过 Java `ServiceLoader` 注册。Adapter 直接提供 Calcite `Schema`、`SqlDialect`、类型系统、运算符表、Planner Rule 和参数 `SqlDataTypeSpec`,无需额外中间态。重复 `adapterId` 会在启动时拒绝。
|
||||
|
||||
## 分布式边界
|
||||
|
||||
Definition、revision 和墓碑可以由调用方存入 Redis 等共享状态系统,并通过 `FederationSourceStateProvider` 下发。连接池、Calcite Schema、计划与活动查询均为节点本地对象,不应序列化或跨节点共享。负载均衡请求应携带 `minimumRevision`,落后节点会先同步或返回明确的未就绪错误。
|
||||
|
||||
当前联邦路径以最多两个实际物理源和内存内有界汇总为基线。各源使用独立只读连接,不提供跨数据库全局快照一致性;应通过 `FederationExecutionPolicy` 为中间行数、字节数、Fragment 数和总时限设置硬上限。
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-federation-sql</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>easy-agents-federation-sql-adapter-jdbc</artifactId>
|
||||
<name>easy-agents-federation-sql-adapter-jdbc</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-federation-sql-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.calcite</groupId>
|
||||
<artifactId>calcite-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<version>8.4.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.5</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.easyagents.federation.sql.adapter.jdbc;
|
||||
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import com.easyagents.federation.sql.execute.StatementLifecycle;
|
||||
import java.sql.SQLTimeoutException;
|
||||
|
||||
/**
|
||||
* 按查询注册表已经确定的终态分类 JDBC 执行与读取异常。
|
||||
*/
|
||||
final class JdbcFailureClassifier {
|
||||
|
||||
/**
|
||||
* 工具类无需实例化。
|
||||
*/
|
||||
private JdbcFailureClassifier() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JDBC 异常转换为稳定的查询错误,优先保留先到达的取消或超时终态。
|
||||
*
|
||||
* @param lifecycle 查询 Statement 生命周期
|
||||
* @param cause JDBC 或驱动异常
|
||||
* @param timeoutMessage 超时提示
|
||||
* @param cancellationMessage 取消提示
|
||||
* @param failureMessage 普通执行失败提示
|
||||
* @return 分类后的统一异常
|
||||
*/
|
||||
static FederationSqlException classify(
|
||||
StatementLifecycle lifecycle,
|
||||
Throwable cause,
|
||||
String timeoutMessage,
|
||||
String cancellationMessage,
|
||||
String failureMessage
|
||||
) {
|
||||
FederationSqlErrorCode errorCode;
|
||||
String message;
|
||||
if (lifecycle.timeoutRequested()) {
|
||||
errorCode = FederationSqlErrorCode.QUERY_TIMEOUT;
|
||||
message = timeoutMessage;
|
||||
} else if (lifecycle.cancellationRequested()) {
|
||||
// Statement.cancel() 后部分驱动会抛 SQLTimeoutException,已登记的取消终态必须优先。
|
||||
errorCode = FederationSqlErrorCode.QUERY_CANCELLED;
|
||||
message = cancellationMessage;
|
||||
} else if (cause instanceof SQLTimeoutException) {
|
||||
errorCode = FederationSqlErrorCode.QUERY_TIMEOUT;
|
||||
message = timeoutMessage;
|
||||
} else {
|
||||
errorCode = FederationSqlErrorCode.EXECUTION_FAILED;
|
||||
message = failureMessage;
|
||||
}
|
||||
return new FederationSqlException(errorCode, message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.easyagents.federation.sql.adapter.jdbc;
|
||||
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import com.easyagents.federation.sql.execute.FederationColumn;
|
||||
import com.easyagents.federation.sql.execute.FederationFragmentExecutionContext;
|
||||
import com.easyagents.federation.sql.execute.FederationFragmentExecutor;
|
||||
import com.easyagents.federation.sql.execute.FederationResultCursor;
|
||||
import com.easyagents.federation.sql.execute.SqlParameter;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLTimeoutException;
|
||||
import java.sql.SQLTransientConnectionException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 直接使用 PreparedStatement 执行目标数据库 SQL 的流式 JDBC 执行器。
|
||||
*/
|
||||
final class JdbcFederationFragmentExecutor implements FederationFragmentExecutor {
|
||||
|
||||
/**
|
||||
* 获取连接、应用只读限制、绑定参数并返回持有全部资源的流式游标。
|
||||
*
|
||||
* @param context 执行上下文
|
||||
* @return 流式游标
|
||||
*/
|
||||
@Override
|
||||
public FederationResultCursor execute(FederationFragmentExecutionContext context) {
|
||||
Connection connection = null;
|
||||
PreparedStatement statement = null;
|
||||
boolean registered = false;
|
||||
boolean connectionAcquired = false;
|
||||
try {
|
||||
context.executionGuard().ensureAllowed();
|
||||
long connectionStarted = System.nanoTime();
|
||||
connection = context.dataSource().getConnection();
|
||||
connectionAcquired = true;
|
||||
context.observer().connectionAcquired(System.nanoTime() - connectionStarted);
|
||||
context.executionGuard().ensureAllowed();
|
||||
configureConnection(connection, context);
|
||||
statement = connection.prepareStatement(
|
||||
context.sql(),
|
||||
ResultSet.TYPE_FORWARD_ONLY,
|
||||
ResultSet.CONCUR_READ_ONLY
|
||||
);
|
||||
applyOptions(statement, context);
|
||||
bindParameters(statement, context.parameters());
|
||||
context.statementLifecycle().register(statement);
|
||||
registered = true;
|
||||
context.executionGuard().ensureAllowed();
|
||||
long executionStarted = System.nanoTime();
|
||||
ResultSet resultSet = statement.executeQuery();
|
||||
context.executionGuard().ensureAllowed();
|
||||
context.observer().databaseExecutionCompleted(System.nanoTime() - executionStarted);
|
||||
List<FederationColumn> columns = readColumns(resultSet.getMetaData());
|
||||
return new JdbcFederationResultCursor(
|
||||
context.queryId(),
|
||||
columns,
|
||||
resultSet,
|
||||
statement,
|
||||
connection,
|
||||
context.statementLifecycle(),
|
||||
context.executionGuard(),
|
||||
context.observer()
|
||||
);
|
||||
} catch (SQLException | RuntimeException exception) {
|
||||
if (!connectionAcquired) {
|
||||
// 连接池等待可能跨过统一截止时间;总超时或显式取消应保持为查询终态。
|
||||
context.executionGuard().ensureAllowed();
|
||||
}
|
||||
if (registered) {
|
||||
context.statementLifecycle().unregister(statement);
|
||||
}
|
||||
closeAfterFailure(statement, connection, exception);
|
||||
if (exception instanceof FederationSqlException federationSqlException) {
|
||||
throw federationSqlException;
|
||||
}
|
||||
boolean connectionTimedOut = !connectionAcquired
|
||||
&& (exception instanceof SQLTransientConnectionException
|
||||
|| exception instanceof SQLTimeoutException);
|
||||
if (connectionTimedOut) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.CONNECTION_ACQUISITION_TIMEOUT,
|
||||
"timed out while acquiring a JDBC connection",
|
||||
exception
|
||||
);
|
||||
}
|
||||
if (!connectionAcquired) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.CONNECTION_ACQUISITION_FAILED,
|
||||
"failed to acquire a JDBC connection",
|
||||
exception
|
||||
);
|
||||
}
|
||||
throw JdbcFailureClassifier.classify(
|
||||
context.statementLifecycle(),
|
||||
exception,
|
||||
"JDBC query timed out",
|
||||
"JDBC query was cancelled",
|
||||
"JDBC query execution failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyOptions(
|
||||
PreparedStatement statement,
|
||||
FederationFragmentExecutionContext context
|
||||
) throws SQLException {
|
||||
int fetchSize = effectiveFetchSize(context);
|
||||
if (fetchSize != 0) {
|
||||
statement.setFetchSize(fetchSize);
|
||||
}
|
||||
if (context.options().maxRows() > 0) {
|
||||
statement.setMaxRows(context.options().maxRows());
|
||||
}
|
||||
int queryTimeout = context.executionGuard().boundedQueryTimeoutSeconds(
|
||||
context.options().queryTimeoutSeconds()
|
||||
);
|
||||
if (queryTimeout > 0) {
|
||||
statement.setQueryTimeout(queryTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
private static void configureConnection(
|
||||
Connection connection,
|
||||
FederationFragmentExecutionContext context
|
||||
) throws SQLException {
|
||||
if (!connection.isReadOnly()) {
|
||||
connection.setReadOnly(true);
|
||||
}
|
||||
String product = context.compatibility().databaseProduct().toLowerCase(Locale.ROOT);
|
||||
// PostgreSQL 只有在事务模式下才会按正 fetchSize 使用服务端游标。
|
||||
if (product.contains("postgres") && context.options().fetchSize() > 0
|
||||
&& connection.getAutoCommit()) {
|
||||
connection.setAutoCommit(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static int effectiveFetchSize(FederationFragmentExecutionContext context) {
|
||||
String product = context.compatibility().databaseProduct().toLowerCase(Locale.ROOT);
|
||||
if (product.contains("mysql")
|
||||
&& "legacy".equalsIgnoreCase(context.adapterOptions().get("mysqlStreamingMode"))) {
|
||||
// Connector/J 旧式逐行流需要显式 MIN_VALUE;默认仍使用正 fetchSize + useCursorFetch。
|
||||
return Integer.MIN_VALUE;
|
||||
}
|
||||
return context.options().fetchSize();
|
||||
}
|
||||
|
||||
private static void bindParameters(PreparedStatement statement, List<SqlParameter> parameters)
|
||||
throws SQLException {
|
||||
for (int index = 0; index < parameters.size(); index++) {
|
||||
SqlParameter parameter = parameters.get(index);
|
||||
int jdbcIndex = index + 1;
|
||||
if (parameter.value() == null) {
|
||||
statement.setNull(jdbcIndex, parameter.jdbcType());
|
||||
} else {
|
||||
statement.setObject(jdbcIndex, parameter.value(), parameter.jdbcType());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<FederationColumn> readColumns(ResultSetMetaData metadata) throws SQLException {
|
||||
List<FederationColumn> columns = new ArrayList<>(metadata.getColumnCount());
|
||||
for (int index = 1; index <= metadata.getColumnCount(); index++) {
|
||||
columns.add(new FederationColumn(
|
||||
index,
|
||||
metadata.getColumnLabel(index),
|
||||
metadata.getColumnType(index),
|
||||
metadata.getColumnTypeName(index),
|
||||
metadata.isNullable(index) != ResultSetMetaData.columnNoNulls
|
||||
));
|
||||
}
|
||||
return List.copyOf(columns);
|
||||
}
|
||||
|
||||
private static void closeAfterFailure(
|
||||
PreparedStatement statement,
|
||||
Connection connection,
|
||||
Throwable original
|
||||
) {
|
||||
closeAndSuppress(statement, original);
|
||||
closeAndSuppress(connection, original);
|
||||
}
|
||||
|
||||
private static void closeAndSuppress(AutoCloseable closeable, Throwable original) {
|
||||
if (closeable == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
closeable.close();
|
||||
} catch (Exception closeException) {
|
||||
original.addSuppressed(closeException);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package com.easyagents.federation.sql.adapter.jdbc;
|
||||
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import com.easyagents.federation.sql.execute.FederationFragmentExplainContext;
|
||||
import com.easyagents.federation.sql.execute.FederationFragmentExplainer;
|
||||
import com.easyagents.federation.sql.execute.FederationPhysicalExplain;
|
||||
import com.easyagents.federation.sql.execute.SqlParameter;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLTimeoutException;
|
||||
import java.sql.SQLTransientConnectionException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* MySQL、PostgreSQL 和 H2 的非 ANALYZE 物理 Explain 实现。
|
||||
*/
|
||||
final class JdbcFederationFragmentExplainer implements FederationFragmentExplainer {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public FederationPhysicalExplain explain(FederationFragmentExplainContext context) {
|
||||
String product = normalize(context.compatibility().databaseProduct());
|
||||
String explainSql = explainSql(product, context.sql());
|
||||
if (explainSql == null) {
|
||||
return FederationPhysicalExplain.unavailable(
|
||||
"physical Explain is not implemented for "
|
||||
+ context.compatibility().databaseProduct()
|
||||
);
|
||||
}
|
||||
Connection acquired = acquireConnection(context);
|
||||
try (Connection connection = acquired) {
|
||||
context.executionGuard().ensureAllowed();
|
||||
if (!connection.isReadOnly()) {
|
||||
connection.setReadOnly(true);
|
||||
}
|
||||
try (PreparedStatement statement = connection.prepareStatement(explainSql)) {
|
||||
int queryTimeout = context.executionGuard().boundedQueryTimeoutSeconds(
|
||||
context.queryTimeoutSeconds()
|
||||
);
|
||||
if (queryTimeout > 0) {
|
||||
statement.setQueryTimeout(queryTimeout);
|
||||
}
|
||||
bind(statement, context.parameters());
|
||||
context.executionGuard().ensureAllowed();
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
context.executionGuard().ensureAllowed();
|
||||
String nativePlan = readPlan(resultSet);
|
||||
return normalizePlan(product, nativePlan);
|
||||
}
|
||||
}
|
||||
} catch (SQLException | RuntimeException exception) {
|
||||
if (exception instanceof FederationSqlException federationSqlException) {
|
||||
throw federationSqlException;
|
||||
}
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.EXPLAIN_FAILED,
|
||||
"physical database Explain failed",
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在统一截止时间约束下获取物理 Explain 连接。
|
||||
*
|
||||
* @param context 分片 Explain 上下文
|
||||
* @return 已获取连接
|
||||
* @throws FederationSqlException 获取超时、失败或查询已终止时抛出
|
||||
*/
|
||||
private static Connection acquireConnection(FederationFragmentExplainContext context) {
|
||||
try {
|
||||
context.executionGuard().ensureAllowed();
|
||||
return context.dataSource().getConnection();
|
||||
} catch (SQLException | RuntimeException exception) {
|
||||
if (exception instanceof FederationSqlException federationSqlException) {
|
||||
throw federationSqlException;
|
||||
}
|
||||
context.executionGuard().ensureAllowed();
|
||||
FederationSqlErrorCode code = exception instanceof SQLTimeoutException
|
||||
|| exception instanceof SQLTransientConnectionException
|
||||
? FederationSqlErrorCode.CONNECTION_ACQUISITION_TIMEOUT
|
||||
: FederationSqlErrorCode.CONNECTION_ACQUISITION_FAILED;
|
||||
String message = code == FederationSqlErrorCode.CONNECTION_ACQUISITION_TIMEOUT
|
||||
? "timed out while acquiring a JDBC connection for physical Explain"
|
||||
: "failed to acquire a JDBC connection for physical Explain";
|
||||
throw new FederationSqlException(code, message, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String explainSql(String product, String sql) {
|
||||
if (product.contains("mysql")) {
|
||||
return "EXPLAIN FORMAT=JSON " + sql;
|
||||
}
|
||||
if (product.contains("postgres")) {
|
||||
return "EXPLAIN (FORMAT JSON, ANALYZE FALSE, COSTS TRUE, VERBOSE FALSE, BUFFERS FALSE) "
|
||||
+ sql;
|
||||
}
|
||||
if (product.equals("h2")) {
|
||||
return "EXPLAIN " + sql;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void bind(PreparedStatement statement, List<SqlParameter> parameters)
|
||||
throws SQLException {
|
||||
for (int index = 0; index < parameters.size(); index++) {
|
||||
SqlParameter parameter = parameters.get(index);
|
||||
if (parameter.value() == null) {
|
||||
statement.setNull(index + 1, parameter.jdbcType());
|
||||
} else {
|
||||
statement.setObject(index + 1, parameter.value(), parameter.jdbcType());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String readPlan(ResultSet resultSet) throws SQLException {
|
||||
StringBuilder plan = new StringBuilder();
|
||||
ResultSetMetaData metadata = resultSet.getMetaData();
|
||||
while (resultSet.next()) {
|
||||
if (!plan.isEmpty()) {
|
||||
plan.append('\n');
|
||||
}
|
||||
for (int column = 1; column <= metadata.getColumnCount(); column++) {
|
||||
if (column > 1) {
|
||||
plan.append('\t');
|
||||
}
|
||||
Object value = resultSet.getObject(column);
|
||||
if (value != null) {
|
||||
plan.append(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return plan.toString();
|
||||
}
|
||||
|
||||
private static FederationPhysicalExplain normalizePlan(String product, String nativePlan) {
|
||||
if (!nativePlan.isBlank() && (product.contains("mysql") || product.contains("postgres"))) {
|
||||
try {
|
||||
JsonNode root = JSON.readTree(nativePlan);
|
||||
return product.contains("mysql")
|
||||
? normalizeMysql(root, nativePlan)
|
||||
: normalizePostgresql(root, nativePlan);
|
||||
} catch (Exception ignored) {
|
||||
// 原生计划仍可用;归一化失败不会伪造索引结论。
|
||||
}
|
||||
}
|
||||
return new FederationPhysicalExplain(
|
||||
true,
|
||||
nativePlan,
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"native plan is available; normalized index fields are unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
private static FederationPhysicalExplain normalizeMysql(JsonNode root, String nativePlan) {
|
||||
JsonNode table = findObjectWithField(root, "access_type");
|
||||
if (table == null) {
|
||||
return nativeOnly(nativePlan, "MySQL plan contains no normalized table access node");
|
||||
}
|
||||
List<String> candidates = stringValues(table.get("possible_keys"));
|
||||
return new FederationPhysicalExplain(
|
||||
true,
|
||||
nativePlan,
|
||||
"table",
|
||||
text(table, "access_type"),
|
||||
candidates,
|
||||
text(table, "key"),
|
||||
longValue(table, "rows_examined_per_scan", "rows"),
|
||||
firstText(table, "attached_condition", "index_condition"),
|
||||
"normalized from MySQL JSON Explain"
|
||||
);
|
||||
}
|
||||
|
||||
private static FederationPhysicalExplain normalizePostgresql(JsonNode root, String nativePlan) {
|
||||
JsonNode plan = root.isArray() && !root.isEmpty() ? root.get(0).get("Plan") : root.get("Plan");
|
||||
JsonNode scan = findObjectWithField(plan, "Index Name");
|
||||
if (scan == null) {
|
||||
scan = findObjectWithTextSuffix(plan, "Node Type", "Scan");
|
||||
}
|
||||
if (scan == null) {
|
||||
return nativeOnly(nativePlan, "PostgreSQL plan contains no normalized plan node");
|
||||
}
|
||||
return new FederationPhysicalExplain(
|
||||
true,
|
||||
nativePlan,
|
||||
text(scan, "Node Type"),
|
||||
text(scan, "Node Type"),
|
||||
List.of(),
|
||||
text(scan, "Index Name"),
|
||||
longValue(scan, "Plan Rows"),
|
||||
firstText(scan, "Index Cond", "Filter", "Join Filter"),
|
||||
"normalized from PostgreSQL JSON Explain"
|
||||
);
|
||||
}
|
||||
|
||||
private static FederationPhysicalExplain nativeOnly(String nativePlan, String diagnostic) {
|
||||
return new FederationPhysicalExplain(
|
||||
true,
|
||||
nativePlan,
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
diagnostic
|
||||
);
|
||||
}
|
||||
|
||||
private static JsonNode findObjectWithField(JsonNode node, String field) {
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
if (node.isObject() && node.has(field)) {
|
||||
return node;
|
||||
}
|
||||
Iterator<JsonNode> children = node.elements();
|
||||
while (children.hasNext()) {
|
||||
JsonNode found = findObjectWithField(children.next(), field);
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonNode findObjectWithTextSuffix(
|
||||
JsonNode node,
|
||||
String field,
|
||||
String suffix
|
||||
) {
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
if (node.isObject()) {
|
||||
String value = text(node, field);
|
||||
if (value != null && value.endsWith(suffix)) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
Iterator<JsonNode> children = node.elements();
|
||||
while (children.hasNext()) {
|
||||
JsonNode found = findObjectWithTextSuffix(children.next(), field, suffix);
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<String> stringValues(JsonNode node) {
|
||||
if (node == null || node.isNull()) {
|
||||
return List.of();
|
||||
}
|
||||
if (node.isArray()) {
|
||||
List<String> values = new ArrayList<>();
|
||||
node.forEach(value -> values.add(value.asText()));
|
||||
return List.copyOf(values);
|
||||
}
|
||||
return List.of(node.asText());
|
||||
}
|
||||
|
||||
private static String firstText(JsonNode node, String... fields) {
|
||||
for (String field : fields) {
|
||||
String value = text(node, field);
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
JsonNode value = node == null ? null : node.get(field);
|
||||
return value == null || value.isNull() ? null : value.asText();
|
||||
}
|
||||
|
||||
private static Long longValue(JsonNode node, String... fields) {
|
||||
for (String field : fields) {
|
||||
JsonNode value = node == null ? null : node.get(field);
|
||||
if (value != null && value.isNumber()) {
|
||||
return value.longValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String normalize(String product) {
|
||||
return product == null ? "" : product.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package com.easyagents.federation.sql.adapter.jdbc;
|
||||
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import com.easyagents.federation.sql.execute.FederationColumn;
|
||||
import com.easyagents.federation.sql.execute.FederationExecutionGuard;
|
||||
import com.easyagents.federation.sql.execute.FederationExecutionObserver;
|
||||
import com.easyagents.federation.sql.execute.FederationResultCursor;
|
||||
import com.easyagents.federation.sql.execute.QueryId;
|
||||
import com.easyagents.federation.sql.execute.StatementLifecycle;
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.FilterReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.Reader;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* 持有 ResultSet、Statement、Connection 和 Engine 资源的 JDBC 流式游标。
|
||||
*/
|
||||
final class JdbcFederationResultCursor implements FederationResultCursor {
|
||||
|
||||
private final QueryId queryId;
|
||||
private final List<FederationColumn> columns;
|
||||
private final ResultSet resultSet;
|
||||
private final PreparedStatement statement;
|
||||
private final Connection connection;
|
||||
private final StatementLifecycle statementLifecycle;
|
||||
private final FederationExecutionGuard executionGuard;
|
||||
private final FederationExecutionObserver observer;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private final AtomicBoolean firstRowObserved = new AtomicBoolean();
|
||||
private final long resultSetCreatedNanos = System.nanoTime();
|
||||
|
||||
/**
|
||||
* 创建 JDBC 流式游标。
|
||||
*
|
||||
* @param queryId 查询标识
|
||||
* @param columns 结果列
|
||||
* @param resultSet JDBC ResultSet
|
||||
* @param statement JDBC Statement
|
||||
* @param connection JDBC Connection
|
||||
* @param statementLifecycle Statement 生命周期回调
|
||||
* @param executionGuard 查询取消与截止时间检查器
|
||||
* @param observer Fragment 执行阶段观察器
|
||||
*/
|
||||
JdbcFederationResultCursor(
|
||||
QueryId queryId,
|
||||
List<FederationColumn> columns,
|
||||
ResultSet resultSet,
|
||||
PreparedStatement statement,
|
||||
Connection connection,
|
||||
StatementLifecycle statementLifecycle,
|
||||
FederationExecutionGuard executionGuard,
|
||||
FederationExecutionObserver observer
|
||||
) {
|
||||
this.queryId = queryId;
|
||||
this.columns = List.copyOf(columns);
|
||||
this.resultSet = resultSet;
|
||||
this.statement = statement;
|
||||
this.connection = connection;
|
||||
this.statementLifecycle = statementLifecycle;
|
||||
this.executionGuard = executionGuard;
|
||||
this.observer = observer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建不采集阶段指标的兼容 JDBC 游标。
|
||||
*
|
||||
* @param queryId 查询标识
|
||||
* @param columns 结果列
|
||||
* @param resultSet JDBC ResultSet
|
||||
* @param statement JDBC Statement
|
||||
* @param connection JDBC Connection
|
||||
* @param statementLifecycle Statement 生命周期
|
||||
*/
|
||||
JdbcFederationResultCursor(
|
||||
QueryId queryId,
|
||||
List<FederationColumn> columns,
|
||||
ResultSet resultSet,
|
||||
PreparedStatement statement,
|
||||
Connection connection,
|
||||
StatementLifecycle statementLifecycle
|
||||
) {
|
||||
this(
|
||||
queryId,
|
||||
columns,
|
||||
resultSet,
|
||||
statement,
|
||||
connection,
|
||||
statementLifecycle,
|
||||
FederationExecutionGuard.none(),
|
||||
FederationExecutionObserver.none()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回查询标识。
|
||||
*
|
||||
* @return 查询标识
|
||||
*/
|
||||
@Override
|
||||
public QueryId queryId() {
|
||||
return queryId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回结果列。
|
||||
*
|
||||
* @return 结果列
|
||||
*/
|
||||
@Override
|
||||
public List<FederationColumn> columns() {
|
||||
return columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动到下一行;读取结束时保留资源直至调用方关闭游标。
|
||||
*
|
||||
* @return 是否存在下一行
|
||||
*/
|
||||
@Override
|
||||
public boolean next() {
|
||||
ensureOpen();
|
||||
try {
|
||||
boolean present = resultSet.next();
|
||||
ensureAllowedAfterRead();
|
||||
if (present && firstRowObserved.compareAndSet(false, true)) {
|
||||
observer.firstRowAvailable(System.nanoTime() - resultSetCreatedNanos);
|
||||
}
|
||||
return present;
|
||||
} catch (SQLException exception) {
|
||||
closeWithSuppressed(exception);
|
||||
throw JdbcFailureClassifier.classify(
|
||||
statementLifecycle,
|
||||
exception,
|
||||
"JDBC result read timed out",
|
||||
"JDBC query was cancelled",
|
||||
"failed to advance JDBC result cursor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前行指定列。
|
||||
*
|
||||
* @param columnIndex 从 1 开始的列序号
|
||||
* @return 列值
|
||||
*/
|
||||
@Override
|
||||
public Object getObject(int columnIndex) {
|
||||
ensureOpen();
|
||||
try {
|
||||
Object value = resultSet.getObject(columnIndex);
|
||||
ensureAllowedAfterRead();
|
||||
return value;
|
||||
} catch (SQLException exception) {
|
||||
closeWithSuppressed(exception);
|
||||
throw JdbcFailureClassifier.classify(
|
||||
statementLifecycle,
|
||||
exception,
|
||||
"JDBC result read timed out",
|
||||
"JDBC query was cancelled",
|
||||
"failed to read JDBC result column " + columnIndex
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 JDBC 流读取二进制列。
|
||||
*
|
||||
* @param columnIndex 从 1 开始的列序号
|
||||
* @return 二进制流;SQL NULL 返回 null
|
||||
*/
|
||||
@Override
|
||||
public InputStream getBinaryStream(int columnIndex) {
|
||||
ensureOpen();
|
||||
try {
|
||||
InputStream stream = resultSet.getBinaryStream(columnIndex);
|
||||
ensureAllowedAfterRead();
|
||||
return stream == null ? null : new GuardedInputStream(stream, columnIndex);
|
||||
} catch (SQLException exception) {
|
||||
throw readFailure(columnIndex, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 JDBC 流读取字符列。
|
||||
*
|
||||
* @param columnIndex 从 1 开始的列序号
|
||||
* @return 字符流;SQL NULL 返回 null
|
||||
*/
|
||||
@Override
|
||||
public Reader getCharacterStream(int columnIndex) {
|
||||
ensureOpen();
|
||||
try {
|
||||
Reader reader = resultSet.getCharacterStream(columnIndex);
|
||||
ensureAllowedAfterRead();
|
||||
return reader == null ? null : new GuardedReader(reader, columnIndex);
|
||||
} catch (SQLException exception) {
|
||||
throw readFailure(columnIndex, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制当前行;Engine 不缓存返回行。
|
||||
*
|
||||
* @return 当前行列值
|
||||
*/
|
||||
@Override
|
||||
public List<Object> row() {
|
||||
ensureOpen();
|
||||
List<Object> row = new ArrayList<>(columns.size());
|
||||
for (int index = 1; index <= columns.size(); index++) {
|
||||
row.add(getObject(index));
|
||||
}
|
||||
return Collections.unmodifiableList(row);
|
||||
}
|
||||
|
||||
private void ensureOpen() {
|
||||
// 异步关闭可能先于消费线程到达,优先保留取消或超时终态语义。
|
||||
try {
|
||||
executionGuard.ensureAllowed();
|
||||
} catch (RuntimeException exception) {
|
||||
closeWithSuppressed(exception);
|
||||
throw exception;
|
||||
}
|
||||
if (closed.get()) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.EXECUTION_FAILED,
|
||||
"result cursor is closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureAllowedAfterRead() {
|
||||
try {
|
||||
executionGuard.ensureAllowed();
|
||||
} catch (RuntimeException exception) {
|
||||
closeWithSuppressed(exception);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等关闭 JDBC 资源并最终释放准入许可与 Runtime lease。
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
FederationSqlException failure = null;
|
||||
try {
|
||||
resultSet.close();
|
||||
} catch (SQLException exception) {
|
||||
failure = closeFailure("ResultSet", exception);
|
||||
}
|
||||
try {
|
||||
statementLifecycle.unregister(statement);
|
||||
} catch (RuntimeException exception) {
|
||||
failure = append(failure, closeFailure("Statement lifecycle", exception));
|
||||
}
|
||||
try {
|
||||
statement.close();
|
||||
} catch (SQLException exception) {
|
||||
failure = append(failure, closeFailure("PreparedStatement", exception));
|
||||
}
|
||||
try {
|
||||
connection.close();
|
||||
} catch (SQLException exception) {
|
||||
failure = append(failure, closeFailure("Connection", exception));
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private void closeWithSuppressed(Throwable original) {
|
||||
try {
|
||||
close();
|
||||
} catch (RuntimeException closeException) {
|
||||
original.addSuppressed(closeException);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将流式列读取异常映射为统一错误并确定性关闭 JDBC 资源。
|
||||
*
|
||||
* @param columnIndex 列序号
|
||||
* @param exception JDBC 或流读取异常
|
||||
* @return 统一 Federation 异常
|
||||
*/
|
||||
private FederationSqlException readFailure(int columnIndex, Throwable exception) {
|
||||
closeWithSuppressed(exception);
|
||||
return JdbcFailureClassifier.classify(
|
||||
statementLifecycle,
|
||||
exception,
|
||||
"JDBC result read timed out",
|
||||
"JDBC query was cancelled",
|
||||
"failed to stream JDBC result column " + columnIndex
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对二进制列的每次实际读取执行查询终态检查。
|
||||
*/
|
||||
private final class GuardedInputStream extends FilterInputStream {
|
||||
|
||||
private final int columnIndex;
|
||||
|
||||
/**
|
||||
* 创建受查询生命周期保护的二进制流。
|
||||
*
|
||||
* @param delegate JDBC 驱动流
|
||||
* @param columnIndex 列序号
|
||||
*/
|
||||
private GuardedInputStream(InputStream delegate, int columnIndex) {
|
||||
super(delegate);
|
||||
this.columnIndex = columnIndex;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
ensureOpen();
|
||||
try {
|
||||
int value = super.read();
|
||||
ensureAllowedAfterRead();
|
||||
return value;
|
||||
} catch (IOException exception) {
|
||||
throw readFailure(columnIndex, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public int read(byte[] buffer, int offset, int length) throws IOException {
|
||||
ensureOpen();
|
||||
try {
|
||||
int read = super.read(buffer, offset, length);
|
||||
ensureAllowedAfterRead();
|
||||
return read;
|
||||
} catch (IOException exception) {
|
||||
throw readFailure(columnIndex, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public long skip(long count) throws IOException {
|
||||
ensureOpen();
|
||||
try {
|
||||
long skipped = super.skip(count);
|
||||
ensureAllowedAfterRead();
|
||||
return skipped;
|
||||
} catch (IOException exception) {
|
||||
throw readFailure(columnIndex, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对字符列的每次实际读取执行查询终态检查。
|
||||
*/
|
||||
private final class GuardedReader extends FilterReader {
|
||||
|
||||
private final int columnIndex;
|
||||
|
||||
/**
|
||||
* 创建受查询生命周期保护的字符流。
|
||||
*
|
||||
* @param delegate JDBC 驱动 Reader
|
||||
* @param columnIndex 列序号
|
||||
*/
|
||||
private GuardedReader(Reader delegate, int columnIndex) {
|
||||
super(delegate);
|
||||
this.columnIndex = columnIndex;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
ensureOpen();
|
||||
try {
|
||||
int value = super.read();
|
||||
ensureAllowedAfterRead();
|
||||
return value;
|
||||
} catch (IOException exception) {
|
||||
throw readFailure(columnIndex, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public int read(char[] buffer, int offset, int length) throws IOException {
|
||||
ensureOpen();
|
||||
try {
|
||||
int read = super.read(buffer, offset, length);
|
||||
ensureAllowedAfterRead();
|
||||
return read;
|
||||
} catch (IOException exception) {
|
||||
throw readFailure(columnIndex, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public long skip(long count) throws IOException {
|
||||
ensureOpen();
|
||||
try {
|
||||
long skipped = super.skip(count);
|
||||
ensureAllowedAfterRead();
|
||||
return skipped;
|
||||
} catch (IOException exception) {
|
||||
throw readFailure(columnIndex, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static FederationSqlException closeFailure(String resource, Exception cause) {
|
||||
return new FederationSqlException(
|
||||
FederationSqlErrorCode.RESOURCE_CLOSE_FAILED,
|
||||
"failed to close JDBC " + resource,
|
||||
cause
|
||||
);
|
||||
}
|
||||
|
||||
private static FederationSqlException append(
|
||||
FederationSqlException failure,
|
||||
FederationSqlException next
|
||||
) {
|
||||
if (failure == null) {
|
||||
return next;
|
||||
}
|
||||
failure.addSuppressed(next);
|
||||
return failure;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package com.easyagents.federation.sql.adapter.jdbc;
|
||||
|
||||
import com.easyagents.federation.sql.adapter.AdapterCompatibility;
|
||||
import com.easyagents.federation.sql.adapter.AdapterCompatibilityStatus;
|
||||
import com.easyagents.federation.sql.adapter.AdapterDialectContext;
|
||||
import com.easyagents.federation.sql.adapter.AdapterHints;
|
||||
import com.easyagents.federation.sql.adapter.AdapterSchemaContext;
|
||||
import com.easyagents.federation.sql.adapter.FederationSqlAdapterProvider;
|
||||
import com.easyagents.federation.sql.adapter.FederationStatisticsCollector;
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import com.easyagents.federation.sql.execute.FederationFragmentExecutor;
|
||||
import com.easyagents.federation.sql.execute.FederationFragmentExplainer;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.apache.calcite.adapter.jdbc.JdbcConvention;
|
||||
import org.apache.calcite.adapter.jdbc.JdbcSchema;
|
||||
import org.apache.calcite.schema.Schema;
|
||||
import org.apache.calcite.schema.Schemas;
|
||||
import org.apache.calcite.sql.SqlDialect;
|
||||
import org.apache.calcite.sql.SqlDialectFactoryImpl;
|
||||
import org.apache.calcite.sql.dialect.AnsiSqlDialect;
|
||||
import org.apache.calcite.sql.dialect.MysqlSqlDialect;
|
||||
|
||||
/**
|
||||
* MySQL、PostgreSQL、Oracle 及显式实验 ANSI 数据库的默认 JDBC Adapter。
|
||||
*/
|
||||
public final class JdbcFederationSqlAdapterProvider implements FederationSqlAdapterProvider {
|
||||
|
||||
/** 默认 JDBC Adapter 标识。 */
|
||||
public static final String ADAPTER_ID = "jdbc";
|
||||
/** 允许未知数据库采用实验 ANSI 方言的 Definition 选项。 */
|
||||
public static final String EXPERIMENTAL_ANSI_OPTION = "experimentalAnsi";
|
||||
|
||||
private static final Set<String> SUPPORTED_PRODUCTS = Set.of(
|
||||
"mysql",
|
||||
"postgresql",
|
||||
"oracle",
|
||||
"h2"
|
||||
);
|
||||
|
||||
private final FederationFragmentExecutor executor = new JdbcFederationFragmentExecutor();
|
||||
private final FederationFragmentExplainer explainer = new JdbcFederationFragmentExplainer();
|
||||
private final FederationStatisticsCollector statisticsCollector =
|
||||
new JdbcFederationStatisticsCollector();
|
||||
|
||||
/**
|
||||
* 创建默认 JDBC Adapter Provider。
|
||||
*/
|
||||
public JdbcFederationSqlAdapterProvider() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回默认 Adapter 标识。
|
||||
*
|
||||
* @return {@value #ADAPTER_ID}
|
||||
*/
|
||||
@Override
|
||||
public String adapterId() {
|
||||
return ADAPTER_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于数据库产品名判断内建或实验 ANSI 支持。
|
||||
*
|
||||
* @param metadata JDBC 元数据
|
||||
* @param hints Adapter 提示
|
||||
* @return 是否支持
|
||||
* @throws SQLException 元数据读取失败
|
||||
*/
|
||||
@Override
|
||||
public boolean supports(DatabaseMetaData metadata, AdapterHints hints) throws SQLException {
|
||||
return SUPPORTED_PRODUCTS.contains(normalize(metadata.getDatabaseProductName()))
|
||||
|| hints.enabled(EXPERIMENTAL_ANSI_OPTION);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回与实际验证证据一致的兼容性状态。
|
||||
*
|
||||
* @param metadata JDBC 元数据
|
||||
* @param hints Adapter 提示
|
||||
* @return 兼容性说明
|
||||
* @throws SQLException 元数据读取失败
|
||||
*/
|
||||
@Override
|
||||
public AdapterCompatibility compatibility(DatabaseMetaData metadata, AdapterHints hints) throws SQLException {
|
||||
String product = metadata.getDatabaseProductName();
|
||||
String normalized = normalize(product);
|
||||
AdapterCompatibilityStatus status;
|
||||
String diagnostic;
|
||||
if ("h2".equals(normalized)) {
|
||||
status = AdapterCompatibilityStatus.VERIFIED;
|
||||
diagnostic = "verified by module-level H2 integration tests";
|
||||
} else if (SUPPORTED_PRODUCTS.contains(normalized)) {
|
||||
status = AdapterCompatibilityStatus.CODE_SUPPORTED_UNVERIFIED;
|
||||
diagnostic = "dialect is supported by code; verify against the target database version before production";
|
||||
} else if (hints.enabled(EXPERIMENTAL_ANSI_OPTION)) {
|
||||
status = AdapterCompatibilityStatus.CODE_SUPPORTED_UNVERIFIED;
|
||||
diagnostic = "experimental ANSI mode is enabled for an unrecognized database";
|
||||
} else {
|
||||
status = AdapterCompatibilityStatus.UNSUPPORTED;
|
||||
diagnostic = "database product is not recognized";
|
||||
}
|
||||
return new AdapterCompatibility(
|
||||
status,
|
||||
product,
|
||||
metadata.getDatabaseProductVersion(),
|
||||
metadata.getDriverName(),
|
||||
metadata.getDriverVersion(),
|
||||
diagnostic
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建复用已探测 Dialect 和调用方 DataSource 的 JdbcSchema。
|
||||
*
|
||||
* @param context Schema 上下文
|
||||
* @return Calcite JdbcSchema
|
||||
*/
|
||||
@Override
|
||||
public Schema createSchema(AdapterSchemaContext context) {
|
||||
if (!(context.schemaDefinition() instanceof JdbcSchemaDefinition definition)) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.INVALID_ARGUMENT,
|
||||
"jdbc adapter requires JdbcSchemaDefinition"
|
||||
);
|
||||
}
|
||||
JdbcConvention convention = JdbcConvention.of(
|
||||
context.dialect(),
|
||||
Schemas.subSchemaExpression(
|
||||
context.parentSchema(),
|
||||
definition.logicalName(),
|
||||
JdbcSchema.class
|
||||
),
|
||||
context.sourceDefinition().sourceId().value() + "." + definition.logicalName()
|
||||
);
|
||||
Schema schema = new JdbcSchema(
|
||||
context.handle().dataSource(),
|
||||
context.dialect(),
|
||||
convention,
|
||||
definition.catalog(),
|
||||
definition.physicalSchema()
|
||||
);
|
||||
// MySQL 表名可区分大小写而列名始终不区分大小写,需分别建模。
|
||||
return context.dialect() instanceof MysqlSqlDialect
|
||||
? new MysqlCaseInsensitiveColumnSchema(schema)
|
||||
: schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Calcite 官方 DialectFactory 选择方言,未知数据库仅在显式 ANSI 模式下放行。
|
||||
*
|
||||
* @param context 方言上下文
|
||||
* @return SqlDialect
|
||||
* @throws SQLException 元数据读取失败
|
||||
*/
|
||||
@Override
|
||||
public SqlDialect createDialect(AdapterDialectContext context) throws SQLException {
|
||||
String product = normalize(context.metadata().getDatabaseProductName());
|
||||
if (!SUPPORTED_PRODUCTS.contains(product)) {
|
||||
AdapterHints hints = new AdapterHints(context.sourceDefinition().adapterOptions());
|
||||
if (hints.enabled(EXPERIMENTAL_ANSI_OPTION)) {
|
||||
return AnsiSqlDialect.DEFAULT;
|
||||
}
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.ADAPTER_UNSUPPORTED,
|
||||
"database product is not supported by jdbc adapter: "
|
||||
+ context.metadata().getDatabaseProductName()
|
||||
);
|
||||
}
|
||||
return SqlDialectFactoryImpl.INSTANCE.create(context.metadata());
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回直接 JDBC 流式执行器。
|
||||
*
|
||||
* @return Fragment 执行器
|
||||
*/
|
||||
@Override
|
||||
public FederationFragmentExecutor fragmentExecutor() {
|
||||
return executor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 MySQL、PostgreSQL 和 H2 的显式物理 Explain 实现。
|
||||
*
|
||||
* @return JDBC 物理 Explain SPI
|
||||
*/
|
||||
@Override
|
||||
public Optional<FederationFragmentExplainer> fragmentExplainer() {
|
||||
return Optional.of(explainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 MySQL 与 PostgreSQL 的内建目录统计采集器。
|
||||
*
|
||||
* <p>Oracle、H2 和实验 ANSI 数据库当前返回空统计,由引擎使用默认成本估算。</p>
|
||||
*
|
||||
* @return JDBC 统计采集 SPI
|
||||
*/
|
||||
@Override
|
||||
public Optional<FederationStatisticsCollector> statisticsCollector() {
|
||||
return Optional.of(statisticsCollector);
|
||||
}
|
||||
|
||||
private static String normalize(String productName) {
|
||||
return productName == null ? "" : productName.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
package com.easyagents.federation.sql.adapter.jdbc;
|
||||
|
||||
import com.easyagents.federation.sql.adapter.FederationStatisticsCollectionContext;
|
||||
import com.easyagents.federation.sql.adapter.FederationStatisticsCollector;
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import com.easyagents.federation.sql.federation.FederationColumnStatistics;
|
||||
import com.easyagents.federation.sql.federation.FederationStatisticsSnapshot;
|
||||
import com.easyagents.federation.sql.federation.FederationStatisticsStatus;
|
||||
import com.easyagents.federation.sql.federation.FederationTableStatistics;
|
||||
import com.easyagents.federation.sql.source.FederationSchemaDefinition;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* MySQL 与 PostgreSQL 的批量 JDBC 目录统计采集器。
|
||||
*/
|
||||
final class JdbcFederationStatisticsCollector implements FederationStatisticsCollector {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(
|
||||
JdbcFederationStatisticsCollector.class
|
||||
);
|
||||
|
||||
/**
|
||||
* 根据 JDBC 数据库产品分派内建统计采集逻辑。
|
||||
*
|
||||
* @param context 统计采集上下文
|
||||
* @return 表统计映射
|
||||
* @throws SQLException 目录或 JDBC 元数据读取失败
|
||||
*/
|
||||
@Override
|
||||
public Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> collect(
|
||||
FederationStatisticsCollectionContext context
|
||||
) throws SQLException {
|
||||
String product = normalize(
|
||||
context.connection().getMetaData().getDatabaseProductName()
|
||||
);
|
||||
List<JdbcSchemaDefinition> schemas = jdbcSchemas(context);
|
||||
return switch (product) {
|
||||
case "mysql" -> collectMysql(context, schemas);
|
||||
case "postgresql" -> collectPostgresql(context, schemas);
|
||||
default -> Map.of();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量读取 MySQL INFORMATION_SCHEMA 表统计和主键。
|
||||
*
|
||||
* @param context 采集上下文
|
||||
* @param schemas JDBC Schema 映射
|
||||
* @return MySQL 表统计
|
||||
* @throws SQLException 目录读取失败
|
||||
*/
|
||||
private Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> collectMysql(
|
||||
FederationStatisticsCollectionContext context,
|
||||
List<JdbcSchemaDefinition> schemas
|
||||
) throws SQLException {
|
||||
Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> statistics =
|
||||
new LinkedHashMap<>();
|
||||
for (JdbcSchemaDefinition schema : schemas) {
|
||||
String catalog = textOr(schema.catalog(), context.connection().getCatalog());
|
||||
if (catalog == null || catalog.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
Map<String, ColumnLayout> layouts = readColumnLayouts(
|
||||
context.connection().getMetaData(),
|
||||
catalog,
|
||||
schema.physicalSchema()
|
||||
);
|
||||
Map<String, List<String>> primaryKeys = readMysqlPrimaryKeys(
|
||||
context,
|
||||
catalog
|
||||
);
|
||||
String sql = "SELECT TABLE_NAME, TABLE_ROWS, AVG_ROW_LENGTH "
|
||||
+ "FROM INFORMATION_SCHEMA.TABLES "
|
||||
+ "WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'";
|
||||
try (PreparedStatement statement = context.connection().prepareStatement(sql)) {
|
||||
statement.setQueryTimeout(context.queryTimeoutSeconds());
|
||||
statement.setString(1, catalog);
|
||||
try (ResultSet result = statement.executeQuery()) {
|
||||
while (result.next()) {
|
||||
String table = result.getString("TABLE_NAME");
|
||||
ColumnLayout layout = layouts.getOrDefault(
|
||||
normalize(table),
|
||||
ColumnLayout.empty()
|
||||
);
|
||||
long averageWidth = result.getLong("AVG_ROW_LENGTH");
|
||||
if (averageWidth <= 0L) {
|
||||
averageWidth = layout.fallbackWidthBytes();
|
||||
}
|
||||
FederationStatisticsSnapshot.TableKey key =
|
||||
new FederationStatisticsSnapshot.TableKey(
|
||||
context.sourceDefinition().sourceId(),
|
||||
schema.logicalName(),
|
||||
table
|
||||
);
|
||||
statistics.put(key, new FederationTableStatistics(
|
||||
Math.max(0D, result.getDouble("TABLE_ROWS")),
|
||||
Math.max(1L, averageWidth),
|
||||
context.collectedAt(),
|
||||
"database-catalog:mysql",
|
||||
Map.of(),
|
||||
uniqueKey(primaryKeys.get(normalize(table))),
|
||||
context.expiresAt(),
|
||||
FederationStatisticsStatus.PARTIAL
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Map.copyOf(statistics);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量读取 PostgreSQL 表行数、列分布和主键统计。
|
||||
*
|
||||
* @param context 采集上下文
|
||||
* @param schemas JDBC Schema 映射
|
||||
* @return PostgreSQL 表统计
|
||||
* @throws SQLException 表级目录读取失败
|
||||
*/
|
||||
private Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics>
|
||||
collectPostgresql(
|
||||
FederationStatisticsCollectionContext context,
|
||||
List<JdbcSchemaDefinition> schemas
|
||||
) throws SQLException {
|
||||
Map<String, JdbcSchemaDefinition> schemasByPhysical = new LinkedHashMap<>();
|
||||
Map<String, ColumnLayout> layouts = new LinkedHashMap<>();
|
||||
for (JdbcSchemaDefinition schema : schemas) {
|
||||
String physical = textOr(schema.physicalSchema(), context.connection().getSchema());
|
||||
if (physical == null || physical.isBlank()) {
|
||||
physical = "public";
|
||||
}
|
||||
schemasByPhysical.putIfAbsent(normalize(physical), schema);
|
||||
Map<String, ColumnLayout> schemaLayouts = readColumnLayouts(
|
||||
context.connection().getMetaData(),
|
||||
schema.catalog(),
|
||||
physical
|
||||
);
|
||||
String resolvedPhysical = physical;
|
||||
schemaLayouts.forEach((table, layout) -> layouts.put(
|
||||
tableKey(resolvedPhysical, table),
|
||||
layout
|
||||
));
|
||||
}
|
||||
if (schemasByPhysical.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, TableEstimate> estimates = readPostgresqlTableEstimates(
|
||||
context,
|
||||
schemasByPhysical.keySet()
|
||||
);
|
||||
Map<String, Map<String, FederationColumnStatistics>> columns;
|
||||
try {
|
||||
columns = readPostgresqlColumnStatistics(
|
||||
context,
|
||||
schemasByPhysical.keySet(),
|
||||
estimates
|
||||
);
|
||||
} catch (SQLException exception) {
|
||||
LOG.warn(
|
||||
"PostgreSQL column statistics are unavailable; retaining table estimates, sourceId={}",
|
||||
context.sourceDefinition().sourceId(),
|
||||
exception
|
||||
);
|
||||
columns = Map.of();
|
||||
}
|
||||
Map<String, List<String>> primaryKeys;
|
||||
try {
|
||||
primaryKeys = readPostgresqlPrimaryKeys(
|
||||
context,
|
||||
schemasByPhysical.keySet()
|
||||
);
|
||||
} catch (SQLException exception) {
|
||||
LOG.warn(
|
||||
"PostgreSQL primary-key statistics are unavailable, sourceId={}",
|
||||
context.sourceDefinition().sourceId(),
|
||||
exception
|
||||
);
|
||||
primaryKeys = Map.of();
|
||||
}
|
||||
|
||||
Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> statistics =
|
||||
new LinkedHashMap<>();
|
||||
for (Map.Entry<String, TableEstimate> entry : estimates.entrySet()) {
|
||||
TableEstimate estimate = entry.getValue();
|
||||
JdbcSchemaDefinition schema = schemasByPhysical.get(normalize(estimate.schema()));
|
||||
if (schema == null) {
|
||||
continue;
|
||||
}
|
||||
ColumnLayout layout = layouts.getOrDefault(entry.getKey(), ColumnLayout.empty());
|
||||
Map<String, FederationColumnStatistics> tableColumns = columns.getOrDefault(
|
||||
entry.getKey(),
|
||||
Map.of()
|
||||
);
|
||||
long averageWidth = Math.max(
|
||||
layout.fallbackWidthBytes(),
|
||||
averageColumnWidth(tableColumns)
|
||||
);
|
||||
boolean complete = !layout.columns().isEmpty()
|
||||
&& containsAllIgnoreCase(tableColumns.keySet(), layout.columns());
|
||||
FederationStatisticsSnapshot.TableKey key =
|
||||
new FederationStatisticsSnapshot.TableKey(
|
||||
context.sourceDefinition().sourceId(),
|
||||
schema.logicalName(),
|
||||
estimate.table()
|
||||
);
|
||||
statistics.put(key, new FederationTableStatistics(
|
||||
estimate.estimatedRows(),
|
||||
Math.max(1L, averageWidth),
|
||||
context.collectedAt(),
|
||||
"database-catalog:postgresql",
|
||||
tableColumns,
|
||||
uniqueKey(primaryKeys.get(entry.getKey())),
|
||||
context.expiresAt(),
|
||||
complete
|
||||
? FederationStatisticsStatus.COMPLETE
|
||||
: FederationStatisticsStatus.PARTIAL
|
||||
));
|
||||
}
|
||||
return Map.copyOf(statistics);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 Definition 中的 JDBC Schema 映射并拒绝不匹配的定义类型。
|
||||
*
|
||||
* @param context 采集上下文
|
||||
* @return JDBC Schema 定义
|
||||
*/
|
||||
private List<JdbcSchemaDefinition> jdbcSchemas(
|
||||
FederationStatisticsCollectionContext context
|
||||
) {
|
||||
List<JdbcSchemaDefinition> schemas = new ArrayList<>();
|
||||
for (FederationSchemaDefinition schema : context.sourceDefinition().schemas()) {
|
||||
if (!(schema instanceof JdbcSchemaDefinition jdbcSchema)) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.INVALID_ARGUMENT,
|
||||
"jdbc statistics collector requires JdbcSchemaDefinition"
|
||||
);
|
||||
}
|
||||
schemas.add(jdbcSchema);
|
||||
}
|
||||
return List.copyOf(schemas);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 JDBC 元数据按 Schema 批量读取字段布局。
|
||||
*
|
||||
* @param metadata JDBC 元数据
|
||||
* @param catalog 物理 Catalog
|
||||
* @param schema 物理 Schema
|
||||
* @return 按规范化表名索引的字段布局
|
||||
* @throws SQLException 元数据读取失败
|
||||
*/
|
||||
private Map<String, ColumnLayout> readColumnLayouts(
|
||||
DatabaseMetaData metadata,
|
||||
String catalog,
|
||||
String schema
|
||||
) throws SQLException {
|
||||
Map<String, MutableColumnLayout> layouts = new LinkedHashMap<>();
|
||||
try (ResultSet result = metadata.getColumns(catalog, schema, "%", "%")) {
|
||||
while (result.next()) {
|
||||
String table = normalize(result.getString("TABLE_NAME"));
|
||||
MutableColumnLayout layout = layouts.computeIfAbsent(
|
||||
table,
|
||||
ignored -> new MutableColumnLayout()
|
||||
);
|
||||
layout.columns.add(result.getString("COLUMN_NAME"));
|
||||
layout.fallbackWidthBytes = saturatedAdd(
|
||||
layout.fallbackWidthBytes,
|
||||
estimatedJdbcWidth(result.getInt("DATA_TYPE"))
|
||||
);
|
||||
}
|
||||
}
|
||||
Map<String, ColumnLayout> frozen = new LinkedHashMap<>();
|
||||
layouts.forEach((table, layout) -> frozen.put(
|
||||
table,
|
||||
new ColumnLayout(
|
||||
Math.max(1L, layout.fallbackWidthBytes),
|
||||
Set.copyOf(layout.columns)
|
||||
)
|
||||
));
|
||||
return Map.copyOf(frozen);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次查询一个 MySQL Catalog 的全部主键字段。
|
||||
*
|
||||
* @param context 采集上下文
|
||||
* @param catalog 物理 Catalog
|
||||
* @return 按规范化表名索引的有序主键
|
||||
* @throws SQLException 目录读取失败
|
||||
*/
|
||||
private Map<String, List<String>> readMysqlPrimaryKeys(
|
||||
FederationStatisticsCollectionContext context,
|
||||
String catalog
|
||||
) throws SQLException {
|
||||
String sql = "SELECT TABLE_NAME, COLUMN_NAME "
|
||||
+ "FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE "
|
||||
+ "WHERE TABLE_SCHEMA = ? AND CONSTRAINT_NAME = 'PRIMARY' "
|
||||
+ "ORDER BY TABLE_NAME, ORDINAL_POSITION";
|
||||
Map<String, List<String>> keys = new LinkedHashMap<>();
|
||||
try (PreparedStatement statement = context.connection().prepareStatement(sql)) {
|
||||
statement.setQueryTimeout(context.queryTimeoutSeconds());
|
||||
statement.setString(1, catalog);
|
||||
try (ResultSet result = statement.executeQuery()) {
|
||||
while (result.next()) {
|
||||
keys.computeIfAbsent(
|
||||
normalize(result.getString("TABLE_NAME")),
|
||||
ignored -> new ArrayList<>()
|
||||
).add(result.getString("COLUMN_NAME"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return freezeLists(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 PostgreSQL 表级近似行数。
|
||||
*
|
||||
* @param context 采集上下文
|
||||
* @param schemas 物理 Schema
|
||||
* @return 表级估算
|
||||
* @throws SQLException 目录读取失败
|
||||
*/
|
||||
private Map<String, TableEstimate> readPostgresqlTableEstimates(
|
||||
FederationStatisticsCollectionContext context,
|
||||
Set<String> schemas
|
||||
) throws SQLException {
|
||||
String sql = "SELECT n.nspname AS schema_name, c.relname AS table_name, "
|
||||
+ "GREATEST(c.reltuples, 0)::double precision AS estimated_rows "
|
||||
+ "FROM pg_catalog.pg_class c "
|
||||
+ "JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace "
|
||||
+ "WHERE c.relkind IN ('r', 'p') AND lower(n.nspname) IN ("
|
||||
+ placeholders(schemas.size()) + ")";
|
||||
Map<String, TableEstimate> estimates = new LinkedHashMap<>();
|
||||
try (PreparedStatement statement = context.connection().prepareStatement(sql)) {
|
||||
statement.setQueryTimeout(context.queryTimeoutSeconds());
|
||||
bind(statement, schemas);
|
||||
try (ResultSet result = statement.executeQuery()) {
|
||||
while (result.next()) {
|
||||
String schema = result.getString("schema_name");
|
||||
String table = result.getString("table_name");
|
||||
estimates.put(
|
||||
tableKey(schema, table),
|
||||
new TableEstimate(
|
||||
schema,
|
||||
table,
|
||||
Math.max(0D, result.getDouble("estimated_rows"))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Map.copyOf(estimates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 PostgreSQL 列分布统计。
|
||||
*
|
||||
* @param context 采集上下文
|
||||
* @param schemas 物理 Schema
|
||||
* @param estimates 已读取的表级估算
|
||||
* @return 按物理表索引的列统计
|
||||
* @throws SQLException 目录读取失败
|
||||
*/
|
||||
private Map<String, Map<String, FederationColumnStatistics>>
|
||||
readPostgresqlColumnStatistics(
|
||||
FederationStatisticsCollectionContext context,
|
||||
Set<String> schemas,
|
||||
Map<String, TableEstimate> estimates
|
||||
) throws SQLException {
|
||||
String sql = "SELECT schemaname, tablename, attname, null_frac, n_distinct, avg_width "
|
||||
+ "FROM pg_catalog.pg_stats WHERE lower(schemaname) IN ("
|
||||
+ placeholders(schemas.size()) + ")";
|
||||
Map<String, Map<String, FederationColumnStatistics>> columns = new LinkedHashMap<>();
|
||||
try (PreparedStatement statement = context.connection().prepareStatement(sql)) {
|
||||
statement.setQueryTimeout(context.queryTimeoutSeconds());
|
||||
bind(statement, schemas);
|
||||
try (ResultSet result = statement.executeQuery()) {
|
||||
while (result.next()) {
|
||||
String key = tableKey(
|
||||
result.getString("schemaname"),
|
||||
result.getString("tablename")
|
||||
);
|
||||
TableEstimate table = estimates.get(key);
|
||||
if (table == null) {
|
||||
continue;
|
||||
}
|
||||
double rawDistinct = result.getDouble("n_distinct");
|
||||
double distinct = rawDistinct < 0D
|
||||
? Math.abs(rawDistinct) * table.estimatedRows()
|
||||
: rawDistinct;
|
||||
if (!Double.isFinite(distinct)) {
|
||||
distinct = 0D;
|
||||
}
|
||||
columns.computeIfAbsent(key, ignored -> new LinkedHashMap<>()).put(
|
||||
result.getString("attname"),
|
||||
new FederationColumnStatistics(
|
||||
Math.max(0D, distinct),
|
||||
Math.max(0D, Math.min(1D, result.getDouble("null_frac"))),
|
||||
Math.max(0L, result.getLong("avg_width"))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<String, Map<String, FederationColumnStatistics>> frozen = new LinkedHashMap<>();
|
||||
columns.forEach((table, values) -> frozen.put(table, Map.copyOf(values)));
|
||||
return Map.copyOf(frozen);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次读取多个 PostgreSQL Schema 的主键字段。
|
||||
*
|
||||
* @param context 采集上下文
|
||||
* @param schemas 物理 Schema
|
||||
* @return 按物理表索引的主键字段
|
||||
* @throws SQLException 目录读取失败
|
||||
*/
|
||||
private Map<String, List<String>> readPostgresqlPrimaryKeys(
|
||||
FederationStatisticsCollectionContext context,
|
||||
Set<String> schemas
|
||||
) throws SQLException {
|
||||
String sql = "SELECT n.nspname AS schema_name, c.relname AS table_name, "
|
||||
+ "a.attname AS column_name "
|
||||
+ "FROM pg_catalog.pg_index i "
|
||||
+ "JOIN pg_catalog.pg_class c ON c.oid = i.indrelid "
|
||||
+ "JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace "
|
||||
+ "JOIN pg_catalog.pg_attribute a "
|
||||
+ "ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey) "
|
||||
+ "WHERE i.indisprimary AND lower(n.nspname) IN ("
|
||||
+ placeholders(schemas.size()) + ") "
|
||||
+ "ORDER BY n.nspname, c.relname, a.attnum";
|
||||
Map<String, List<String>> keys = new LinkedHashMap<>();
|
||||
try (PreparedStatement statement = context.connection().prepareStatement(sql)) {
|
||||
statement.setQueryTimeout(context.queryTimeoutSeconds());
|
||||
bind(statement, schemas);
|
||||
try (ResultSet result = statement.executeQuery()) {
|
||||
while (result.next()) {
|
||||
keys.computeIfAbsent(
|
||||
tableKey(
|
||||
result.getString("schema_name"),
|
||||
result.getString("table_name")
|
||||
),
|
||||
ignored -> new ArrayList<>()
|
||||
).add(result.getString("column_name"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return freezeLists(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将可选主键转换为唯一键列表。
|
||||
*
|
||||
* @param primaryKey 主键字段
|
||||
* @return 零个或一个唯一键
|
||||
*/
|
||||
private List<List<String>> uniqueKey(List<String> primaryKey) {
|
||||
return primaryKey == null || primaryKey.isEmpty()
|
||||
? List.of()
|
||||
: List.of(List.copyOf(primaryKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总列平均宽度并防止 long 溢出。
|
||||
*
|
||||
* @param columns 列统计
|
||||
* @return 至少为 1 的平均宽度
|
||||
*/
|
||||
private long averageColumnWidth(Map<String, FederationColumnStatistics> columns) {
|
||||
long width = 0L;
|
||||
for (FederationColumnStatistics column : columns.values()) {
|
||||
width = saturatedAdd(width, column.averageWidthBytes());
|
||||
}
|
||||
return Math.max(1L, width);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断列统计是否覆盖全部字段。
|
||||
*
|
||||
* @param available 已有列统计名称
|
||||
* @param required JDBC 字段名称
|
||||
* @return 完整覆盖时为 true
|
||||
*/
|
||||
private boolean containsAllIgnoreCase(Set<String> available, Set<String> required) {
|
||||
Set<String> normalized = new LinkedHashSet<>();
|
||||
available.forEach(value -> normalized.add(normalize(value)));
|
||||
return required.stream().map(this::normalize).allMatch(normalized::contains);
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算 JDBC 类型的保守内存宽度。
|
||||
*
|
||||
* @param jdbcType JDBC 类型
|
||||
* @return 估算字节数
|
||||
*/
|
||||
private long estimatedJdbcWidth(int jdbcType) {
|
||||
return switch (jdbcType) {
|
||||
case Types.BOOLEAN, Types.BIT, Types.TINYINT -> 1L;
|
||||
case Types.SMALLINT -> 2L;
|
||||
case Types.INTEGER, Types.REAL, Types.FLOAT, Types.DATE -> 4L;
|
||||
case Types.BIGINT, Types.DOUBLE, Types.TIMESTAMP,
|
||||
Types.TIMESTAMP_WITH_TIMEZONE, Types.TIME,
|
||||
Types.TIME_WITH_TIMEZONE -> 8L;
|
||||
case Types.DECIMAL, Types.NUMERIC -> 16L;
|
||||
case Types.BINARY, Types.VARBINARY, Types.LONGVARBINARY,
|
||||
Types.BLOB, Types.CLOB, Types.NCLOB,
|
||||
Types.LONGVARCHAR, Types.LONGNVARCHAR -> 64L;
|
||||
default -> 32L;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成固定数量的 PreparedStatement 占位符。
|
||||
*
|
||||
* @param size 占位符数量
|
||||
* @return 逗号分隔占位符
|
||||
*/
|
||||
private String placeholders(int size) {
|
||||
return String.join(", ", Collections.nCopies(size, "?"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按稳定顺序绑定规范化 Schema。
|
||||
*
|
||||
* @param statement PreparedStatement
|
||||
* @param schemas Schema 集合
|
||||
* @throws SQLException 参数绑定失败
|
||||
*/
|
||||
private void bind(PreparedStatement statement, Set<String> schemas) throws SQLException {
|
||||
int index = 1;
|
||||
for (String schema : schemas) {
|
||||
statement.setString(index++, normalize(schema));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 冻结可变列表映射。
|
||||
*
|
||||
* @param source 可变列表映射
|
||||
* @return 不可变列表映射
|
||||
*/
|
||||
private Map<String, List<String>> freezeLists(Map<String, List<String>> source) {
|
||||
Map<String, List<String>> frozen = new LinkedHashMap<>();
|
||||
source.forEach((key, value) -> frozen.put(key, List.copyOf(value)));
|
||||
return Map.copyOf(frozen);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成大小写不敏感的物理表索引键。
|
||||
*
|
||||
* @param schema 物理 Schema
|
||||
* @param table 物理表
|
||||
* @return 稳定索引键
|
||||
*/
|
||||
private String tableKey(String schema, String table) {
|
||||
return normalize(schema) + '\u0000' + normalize(table);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回首个非空文本。
|
||||
*
|
||||
* @param primary 首选值
|
||||
* @param fallback 备用值
|
||||
* @return 可空结果
|
||||
*/
|
||||
private String textOr(String primary, String fallback) {
|
||||
return primary == null || primary.isBlank() ? fallback : primary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化数据库产品名或标识符。
|
||||
*
|
||||
* @param value 原始值
|
||||
* @return 小写非空值
|
||||
*/
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 饱和 long 加法。
|
||||
*
|
||||
* @param left 左值
|
||||
* @param right 右值
|
||||
* @return 不溢出的和
|
||||
*/
|
||||
private long saturatedAdd(long left, long right) {
|
||||
return Long.MAX_VALUE - left < right ? Long.MAX_VALUE : left + right;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单表字段布局。
|
||||
*
|
||||
* @param fallbackWidthBytes JDBC 类型估算行宽
|
||||
* @param columns 字段名称
|
||||
*/
|
||||
private record ColumnLayout(long fallbackWidthBytes, Set<String> columns) {
|
||||
|
||||
/**
|
||||
* 创建空布局。
|
||||
*
|
||||
* @return 保守空布局
|
||||
*/
|
||||
private static ColumnLayout empty() {
|
||||
return new ColumnLayout(1L, Set.of());
|
||||
}
|
||||
}
|
||||
|
||||
/** 可变字段布局构造器。 */
|
||||
private static final class MutableColumnLayout {
|
||||
private long fallbackWidthBytes;
|
||||
private final Set<String> columns = new LinkedHashSet<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgreSQL 表级估算。
|
||||
*
|
||||
* @param schema 物理 Schema
|
||||
* @param table 物理表
|
||||
* @param estimatedRows 估算行数
|
||||
*/
|
||||
private record TableEstimate(String schema, String table, double estimatedRows) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.easyagents.federation.sql.adapter.jdbc;
|
||||
|
||||
import com.easyagents.federation.sql.source.FederationSchemaDefinition;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* JDBC Catalog/Schema 到逻辑 Schema 的映射定义。
|
||||
*
|
||||
* @param logicalName SQL 中使用的逻辑 Schema 名称
|
||||
* @param catalog 物理 Catalog,可为空
|
||||
* @param physicalSchema 物理 Schema,可为空
|
||||
*/
|
||||
public record JdbcSchemaDefinition(
|
||||
String logicalName,
|
||||
String catalog,
|
||||
String physicalSchema
|
||||
) implements FederationSchemaDefinition {
|
||||
|
||||
/**
|
||||
* 校验逻辑名称并保留可空物理 Catalog/Schema。
|
||||
*/
|
||||
public JdbcSchemaDefinition {
|
||||
if (logicalName == null || logicalName.isBlank()) {
|
||||
throw new IllegalArgumentException("logicalName must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 JDBC Schema 映射的稳定校验和材料。
|
||||
*
|
||||
* @return 稳定材料
|
||||
*/
|
||||
@Override
|
||||
public List<String> checksumFields() {
|
||||
return Arrays.asList(catalog, physicalSchema);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package com.easyagents.federation.sql.adapter.jdbc;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.apache.calcite.adapter.jdbc.JdbcSchema;
|
||||
import org.apache.calcite.adapter.jdbc.JdbcTable;
|
||||
import org.apache.calcite.config.CalciteConnectionConfig;
|
||||
import org.apache.calcite.plan.RelOptTable;
|
||||
import org.apache.calcite.rel.RelNode;
|
||||
import org.apache.calcite.rel.type.RelDataType;
|
||||
import org.apache.calcite.rel.type.RelDataTypeFactory;
|
||||
import org.apache.calcite.rel.type.RelDataTypeField;
|
||||
import org.apache.calcite.rel.type.RelRecordType;
|
||||
import org.apache.calcite.schema.Schema;
|
||||
import org.apache.calcite.schema.SchemaVersion;
|
||||
import org.apache.calcite.schema.Statistic;
|
||||
import org.apache.calcite.schema.Table;
|
||||
import org.apache.calcite.schema.TranslatableTable;
|
||||
import org.apache.calcite.schema.Wrapper;
|
||||
import org.apache.calcite.schema.impl.DelegatingSchema;
|
||||
import org.apache.calcite.schema.lookup.IgnoreCaseLookup;
|
||||
import org.apache.calcite.schema.lookup.LikePattern;
|
||||
import org.apache.calcite.schema.lookup.Lookup;
|
||||
import org.apache.calcite.sql.SqlCall;
|
||||
import org.apache.calcite.sql.SqlNode;
|
||||
|
||||
/**
|
||||
* 保留 MySQL 表名精确匹配,同时让列名遵循 MySQL 的大小写不敏感语义。
|
||||
*/
|
||||
final class MysqlCaseInsensitiveColumnSchema extends DelegatingSchema {
|
||||
|
||||
private final Lookup<Table> tableLookup;
|
||||
|
||||
/**
|
||||
* 创建 MySQL 列名语义包装器。
|
||||
*
|
||||
* @param schema 原始 JDBC Schema
|
||||
*/
|
||||
MysqlCaseInsensitiveColumnSchema(Schema schema) {
|
||||
super(Objects.requireNonNull(schema, "schema"));
|
||||
Lookup<Table> sourceLookup = schema.tables();
|
||||
if (schema instanceof JdbcSchema jdbcSchema) {
|
||||
sourceLookup = new ExactJdbcTableLookup(jdbcSchema, sourceLookup);
|
||||
}
|
||||
this.tableLookup = sourceLookup.map((table, ignoredName) -> wrap(table));
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回保持原始表名 Lookup 规则的包装表集合。
|
||||
*
|
||||
* @return 包装后的表 Lookup
|
||||
*/
|
||||
@Override
|
||||
public Lookup<Table> tables() {
|
||||
return tableLookup;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按原始 Schema 规则精确获取表,再包装列类型。
|
||||
*
|
||||
* @param name 表名
|
||||
* @return 包装表;不存在时返回 null
|
||||
*/
|
||||
@Override
|
||||
public Table getTable(String name) {
|
||||
return tableLookup.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 Schema 快照保留相同的列名语义。
|
||||
*
|
||||
* @param version Schema 版本
|
||||
* @return 包装后的快照
|
||||
*/
|
||||
@Override
|
||||
public Schema snapshot(SchemaVersion version) {
|
||||
return new MysqlCaseInsensitiveColumnSchema(schema.snapshot(version));
|
||||
}
|
||||
|
||||
private static Table wrap(Table table) {
|
||||
return table instanceof MysqlCaseInsensitiveColumnTable
|
||||
? table
|
||||
: new MysqlCaseInsensitiveColumnTable(table);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Calcite 的表名查找收紧为 JDBC 元数据层面的精确查找。
|
||||
*/
|
||||
private static final class ExactJdbcTableLookup extends IgnoreCaseLookup<Table> {
|
||||
|
||||
private final JdbcSchema jdbcSchema;
|
||||
private final Lookup<Table> delegate;
|
||||
private volatile boolean searchEscapeLoaded;
|
||||
private String searchEscape;
|
||||
|
||||
private ExactJdbcTableLookup(JdbcSchema jdbcSchema, Lookup<Table> delegate) {
|
||||
this.jdbcSchema = Objects.requireNonNull(jdbcSchema, "jdbcSchema");
|
||||
this.delegate = Objects.requireNonNull(delegate, "delegate");
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义 JDBC LIKE 通配字符后读取,并校验驱动返回的真实物理表名。
|
||||
*
|
||||
* @param name 精确表名
|
||||
* @return 精确匹配的表;不存在时返回 null
|
||||
*/
|
||||
@Override
|
||||
public Table get(String name) {
|
||||
Table table = delegate.get(escapePattern(name));
|
||||
if (table == null) {
|
||||
return null;
|
||||
}
|
||||
JdbcTable jdbcTable = table instanceof JdbcTable direct
|
||||
? direct
|
||||
: table instanceof Wrapper wrapper
|
||||
? wrapper.unwrap(JdbcTable.class)
|
||||
: null;
|
||||
return jdbcTable != null && name.equals(jdbcTable.jdbcTableName) ? table : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回符合 Calcite LIKE 语义的表名,过滤 JDBC 对下划线的额外通配匹配。
|
||||
*
|
||||
* @param pattern 表名模式
|
||||
* @return 匹配名称集合
|
||||
*/
|
||||
@Override
|
||||
public Set<String> getNames(LikePattern pattern) {
|
||||
return delegate.getNames(pattern).stream()
|
||||
.filter(pattern.matcher()::apply)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
private String escapePattern(String name) {
|
||||
String escape = searchEscape();
|
||||
if (escape == null || escape.isEmpty()) {
|
||||
if (name.indexOf('_') >= 0 || name.indexOf('%') >= 0) {
|
||||
throw new IllegalStateException(
|
||||
"MySQL JDBC driver does not expose a metadata search escape"
|
||||
);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
return name
|
||||
.replace(escape, escape + escape)
|
||||
.replace("_", escape + "_")
|
||||
.replace("%", escape + "%");
|
||||
}
|
||||
|
||||
private String searchEscape() {
|
||||
if (searchEscapeLoaded) {
|
||||
return searchEscape;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (!searchEscapeLoaded) {
|
||||
try (Connection connection = jdbcSchema.getDataSource().getConnection()) {
|
||||
searchEscape = connection.getMetaData().getSearchStringEscape();
|
||||
searchEscapeLoaded = true;
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException(
|
||||
"Failed to read MySQL JDBC metadata search escape",
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
return searchEscape;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅调整行类型的字段查找规则,关系转换继续交由原始 JDBC Table 完成。
|
||||
*/
|
||||
private static final class MysqlCaseInsensitiveColumnTable
|
||||
implements TranslatableTable, Wrapper {
|
||||
|
||||
private final Table delegate;
|
||||
|
||||
private MysqlCaseInsensitiveColumnTable(Table delegate) {
|
||||
this.delegate = Objects.requireNonNull(delegate, "delegate");
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回列名大小写不敏感的结构类型。
|
||||
*
|
||||
* @param typeFactory Calcite 类型工厂
|
||||
* @return 包装后的结构类型
|
||||
*/
|
||||
@Override
|
||||
public RelDataType getRowType(RelDataTypeFactory typeFactory) {
|
||||
return new CaseInsensitiveRelRecordType(delegate.getRowType(typeFactory));
|
||||
}
|
||||
|
||||
/**
|
||||
* 复用原始表统计信息。
|
||||
*
|
||||
* @return 表统计信息
|
||||
*/
|
||||
@Override
|
||||
public Statistic getStatistic() {
|
||||
return delegate.getStatistic();
|
||||
}
|
||||
|
||||
/**
|
||||
* 复用原始 JDBC 表类型。
|
||||
*
|
||||
* @return JDBC 表类型
|
||||
*/
|
||||
@Override
|
||||
public Schema.TableType getJdbcTableType() {
|
||||
return delegate.getJdbcTableType();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断列是否为预聚合列。
|
||||
*
|
||||
* @param column 列名
|
||||
* @return 原始表判断结果
|
||||
*/
|
||||
@Override
|
||||
public boolean isRolledUp(String column) {
|
||||
return delegate.isRolledUp(column);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断预聚合列能否用于聚合表达式。
|
||||
*
|
||||
* @param column 列名
|
||||
* @param call SQL 调用
|
||||
* @param parent 父节点
|
||||
* @param config Calcite 连接配置
|
||||
* @return 原始表判断结果
|
||||
*/
|
||||
@Override
|
||||
public boolean rolledUpColumnValidInsideAgg(
|
||||
String column,
|
||||
SqlCall call,
|
||||
SqlNode parent,
|
||||
CalciteConnectionConfig config
|
||||
) {
|
||||
return delegate.rolledUpColumnValidInsideAgg(column, call, parent, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 交由原始 JDBC Table 生成关系节点,保留 JDBC Convention 与 SQL 下推。
|
||||
*
|
||||
* @param context 关系转换上下文
|
||||
* @param relOptTable 规划器表
|
||||
* @return 关系节点
|
||||
* @throws IllegalStateException 原始表不支持关系转换
|
||||
*/
|
||||
@Override
|
||||
public RelNode toRel(RelOptTable.ToRelContext context, RelOptTable relOptTable) {
|
||||
if (!(delegate instanceof TranslatableTable translatableTable)) {
|
||||
throw new IllegalStateException("MySQL JDBC table does not support relational translation");
|
||||
}
|
||||
return translatableTable.toRel(context, relOptTable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解包包装器或原始 JDBC Table 能力。
|
||||
*
|
||||
* @param type 目标类型
|
||||
* @param <C> 目标类型参数
|
||||
* @return 匹配实例;不存在时返回 null
|
||||
*/
|
||||
@Override
|
||||
public <C> C unwrap(Class<C> type) {
|
||||
if (type.isInstance(this)) {
|
||||
return type.cast(this);
|
||||
}
|
||||
if (type.isInstance(delegate)) {
|
||||
return type.cast(delegate);
|
||||
}
|
||||
return delegate instanceof Wrapper wrapper ? wrapper.unwrap(type) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 始终以大小写不敏感方式解析 MySQL 列名的记录类型。
|
||||
*/
|
||||
private static final class CaseInsensitiveRelRecordType extends RelRecordType {
|
||||
|
||||
private CaseInsensitiveRelRecordType(RelDataType delegate) {
|
||||
super(delegate.getStructKind(), delegate.getFieldList(), delegate.isNullable());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 MySQL 规则查找字段。
|
||||
*
|
||||
* @param fieldName 字段名
|
||||
* @param caseSensitive Calcite 请求的匹配规则;MySQL 列名语义下忽略
|
||||
* @param elideRecord 是否递归省略嵌套记录层级
|
||||
* @return 匹配字段;不存在时返回 null
|
||||
*/
|
||||
@Override
|
||||
public RelDataTypeField getField(
|
||||
String fieldName,
|
||||
boolean caseSensitive,
|
||||
boolean elideRecord
|
||||
) {
|
||||
return super.getField(fieldName, false, elideRecord);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.easyagents.federation.sql.adapter.jdbc.JdbcFederationSqlAdapterProvider
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.easyagents.federation.sql.adapter.jdbc;
|
||||
|
||||
import com.easyagents.federation.sql.adapter.AdapterCompatibilityStatus;
|
||||
import com.easyagents.federation.sql.adapter.AdapterDialectContext;
|
||||
import com.easyagents.federation.sql.adapter.AdapterHints;
|
||||
import com.easyagents.federation.sql.source.FederationSourceDefinition;
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.calcite.sql.SqlDialect;
|
||||
import org.apache.calcite.sql.dialect.MysqlSqlDialect;
|
||||
import org.apache.calcite.sql.dialect.OracleSqlDialect;
|
||||
import org.apache.calcite.sql.dialect.PostgresqlSqlDialect;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* 默认 JDBC Adapter 的数据库识别与 Calcite 方言选择契约测试。
|
||||
*/
|
||||
public class JdbcDialectSelectionTest {
|
||||
|
||||
/**
|
||||
* 验证 MySQL、PostgreSQL 和 Oracle 使用对应 Calcite 官方方言。
|
||||
*
|
||||
* @throws Exception 元数据读取失败
|
||||
*/
|
||||
@Test
|
||||
public void shouldSelectBuiltInCalciteDialects() throws Exception {
|
||||
JdbcFederationSqlAdapterProvider adapter = new JdbcFederationSqlAdapterProvider();
|
||||
SqlDialect mysql = assertDialect(adapter, "MySQL", "`", MysqlSqlDialect.class);
|
||||
SqlDialect postgresql = assertDialect(adapter, "PostgreSQL", "\"", PostgresqlSqlDialect.class);
|
||||
assertDialect(adapter, "Oracle", "\"", OracleSqlDialect.class);
|
||||
Assert.assertEquals(mysql.isCaseSensitive(), adapter.parserConfig(mysql).caseSensitive());
|
||||
Assert.assertTrue(adapter.parserConfig(postgresql).caseSensitive());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未知数据库默认拒绝,显式 ANSI 模式才以未验证状态放行。
|
||||
*
|
||||
* @throws Exception 元数据读取失败
|
||||
*/
|
||||
@Test
|
||||
public void shouldRequireExplicitAnsiModeForUnknownDatabase() throws Exception {
|
||||
JdbcFederationSqlAdapterProvider adapter = new JdbcFederationSqlAdapterProvider();
|
||||
DatabaseMetaData metadata = metadata("UnknownDB", "\"");
|
||||
Assert.assertFalse(adapter.supports(metadata, new AdapterHints(Map.of())));
|
||||
AdapterHints experimental = new AdapterHints(Map.of(
|
||||
JdbcFederationSqlAdapterProvider.EXPERIMENTAL_ANSI_OPTION,
|
||||
"true"
|
||||
));
|
||||
Assert.assertTrue(adapter.supports(metadata, experimental));
|
||||
Assert.assertEquals(
|
||||
AdapterCompatibilityStatus.CODE_SUPPORTED_UNVERIFIED,
|
||||
adapter.compatibility(metadata, experimental).status()
|
||||
);
|
||||
}
|
||||
|
||||
private static SqlDialect assertDialect(
|
||||
JdbcFederationSqlAdapterProvider adapter,
|
||||
String product,
|
||||
String quote,
|
||||
Class<? extends SqlDialect> expectedType
|
||||
) throws Exception {
|
||||
DatabaseMetaData metadata = metadata(product, quote);
|
||||
Assert.assertTrue(adapter.supports(metadata, new AdapterHints(Map.of())));
|
||||
SqlDialect dialect = adapter.createDialect(new AdapterDialectContext(metadata, definition(Map.of())));
|
||||
Assert.assertTrue(expectedType.isInstance(dialect));
|
||||
Assert.assertEquals(
|
||||
AdapterCompatibilityStatus.CODE_SUPPORTED_UNVERIFIED,
|
||||
adapter.compatibility(metadata, new AdapterHints(Map.of())).status()
|
||||
);
|
||||
return dialect;
|
||||
}
|
||||
|
||||
private static FederationSourceDefinition definition(Map<String, String> options) {
|
||||
return new FederationSourceDefinition(
|
||||
new SourceId("source"),
|
||||
1,
|
||||
JdbcFederationSqlAdapterProvider.ADAPTER_ID,
|
||||
List.of(new JdbcSchemaDefinition("app", null, null)),
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
private static DatabaseMetaData metadata(String product, String quote) {
|
||||
return (DatabaseMetaData) Proxy.newProxyInstance(
|
||||
JdbcDialectSelectionTest.class.getClassLoader(),
|
||||
new Class<?>[] {DatabaseMetaData.class},
|
||||
(proxy, method, arguments) -> switch (method.getName()) {
|
||||
case "getDatabaseProductName" -> product;
|
||||
case "getDatabaseProductVersion" -> "test-version";
|
||||
case "getDatabaseMajorVersion" -> 1;
|
||||
case "getDatabaseMinorVersion" -> 0;
|
||||
case "getDriverName" -> "test-driver";
|
||||
case "getDriverVersion" -> "1";
|
||||
case "getIdentifierQuoteString" -> quote;
|
||||
case "nullsAreSortedHigh" -> true;
|
||||
case "nullsAreSortedAtEnd", "nullsAreSortedAtStart", "nullsAreSortedLow" -> false;
|
||||
case "storesUpperCaseIdentifiers", "storesUpperCaseQuotedIdentifiers" -> false;
|
||||
case "storesLowerCaseIdentifiers", "storesLowerCaseQuotedIdentifiers" -> false;
|
||||
case "storesMixedCaseIdentifiers", "storesMixedCaseQuotedIdentifiers" -> true;
|
||||
case "supportsMixedCaseIdentifiers", "supportsMixedCaseQuotedIdentifiers" -> true;
|
||||
default -> defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (!type.isPrimitive()) {
|
||||
return null;
|
||||
}
|
||||
if (type == boolean.class) {
|
||||
return false;
|
||||
}
|
||||
if (type == int.class) {
|
||||
return 0;
|
||||
}
|
||||
if (type == long.class) {
|
||||
return 0L;
|
||||
}
|
||||
if (type == short.class) {
|
||||
return (short) 0;
|
||||
}
|
||||
if (type == byte.class) {
|
||||
return (byte) 0;
|
||||
}
|
||||
if (type == float.class) {
|
||||
return 0F;
|
||||
}
|
||||
if (type == double.class) {
|
||||
return 0D;
|
||||
}
|
||||
if (type == char.class) {
|
||||
return '\0';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
package com.easyagents.federation.sql.adapter.jdbc;
|
||||
|
||||
import com.easyagents.federation.sql.api.FederationSqlEngine;
|
||||
import com.easyagents.federation.sql.api.FederationSqlEngines;
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import com.easyagents.federation.sql.api.SqlQueryCommand;
|
||||
import com.easyagents.federation.sql.compile.FederationSqlPlan;
|
||||
import com.easyagents.federation.sql.compile.SqlCompileRequest;
|
||||
import com.easyagents.federation.sql.compile.SqlExplainLevel;
|
||||
import com.easyagents.federation.sql.compile.SqlExplainRequest;
|
||||
import com.easyagents.federation.sql.compile.SqlExplainResult;
|
||||
import com.easyagents.federation.sql.execute.FederationQueryMetricsSnapshot;
|
||||
import com.easyagents.federation.sql.execute.FederationResultCursor;
|
||||
import com.easyagents.federation.sql.execute.SqlParameter;
|
||||
import com.easyagents.federation.sql.federation.FederationExecutionPolicy;
|
||||
import com.easyagents.federation.sql.federation.FederationLogicalTableDefinition;
|
||||
import com.easyagents.federation.sql.federation.FederationQueryMode;
|
||||
import com.easyagents.federation.sql.federation.FederationQueryScopeDefinition;
|
||||
import com.easyagents.federation.sql.federation.FederationSourceBindingDefinition;
|
||||
import com.easyagents.federation.sql.federation.FederationStatisticsSnapshot;
|
||||
import com.easyagents.federation.sql.federation.FederationTableStatistics;
|
||||
import com.easyagents.federation.sql.federation.FederationTableStatisticsProvider;
|
||||
import com.easyagents.federation.sql.source.FederationDataSourceHandles;
|
||||
import com.easyagents.federation.sql.source.FederationSourceDefinition;
|
||||
import com.easyagents.federation.sql.source.RuntimeFingerprint;
|
||||
import com.easyagents.federation.sql.source.SourceApplyOptions;
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.sql.Connection;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.After;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* 多个独立 JDBC DataSource 的基础联邦查询集成测试。
|
||||
*/
|
||||
public class JdbcFederatedQueryEngineTest {
|
||||
|
||||
private static final SourceId SALES_SOURCE = new SourceId("sales-source");
|
||||
private static final SourceId BILLING_SOURCE = new SourceId("billing-source");
|
||||
private static final SourceId REGION_SOURCE = new SourceId("region-source");
|
||||
|
||||
private JdbcDataSource sales;
|
||||
private JdbcDataSource billing;
|
||||
private JdbcDataSource region;
|
||||
private FederationSqlEngine engine;
|
||||
private FederationQueryScopeDefinition scope;
|
||||
private final AtomicReference<String> statisticsVersion =
|
||||
new AtomicReference<>("stats-v1");
|
||||
private final AtomicLong salesRowCount = new AtomicLong(3);
|
||||
|
||||
/**
|
||||
* 创建三个独立 H2 数据库并登记物理数据源。
|
||||
*
|
||||
* @throws Exception 数据库初始化失败
|
||||
*/
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
Instant statisticsCollectedAt = Instant.now();
|
||||
sales = dataSource("sales");
|
||||
billing = dataSource("billing");
|
||||
region = dataSource("region");
|
||||
execute(sales,
|
||||
"CREATE TABLE CUSTOMER (ID INT PRIMARY KEY, NAME VARCHAR(64) NOT NULL)",
|
||||
"INSERT INTO CUSTOMER VALUES (1, 'Alice'), (2, 'Bob'), (3, 'Carol')",
|
||||
"CREATE TABLE TIME_EVENT (ID INT PRIMARY KEY, EVENT_TIME TIME(6) WITH TIME ZONE, "
|
||||
+ "EVENT_AT TIMESTAMP(6) WITH TIME ZONE)",
|
||||
"INSERT INTO TIME_EVENT VALUES (1, TIME WITH TIME ZONE '12:00:00.123456+08:00', "
|
||||
+ "TIMESTAMP WITH TIME ZONE '2026-08-21 12:00:00.123456+08:00')",
|
||||
"CREATE TABLE PRECISE_EVENT (ID INT PRIMARY KEY, EVENT_AT TIMESTAMP(6))",
|
||||
"INSERT INTO PRECISE_EVENT VALUES "
|
||||
+ "(1, TIMESTAMP '2026-08-21 12:00:00.123456')");
|
||||
execute(billing,
|
||||
"CREATE TABLE ORDER_ITEM (ID INT PRIMARY KEY, CUSTOMER_ID INT NOT NULL, AMOUNT DECIMAL(12,2), CODE VARCHAR(64))",
|
||||
"INSERT INTO ORDER_ITEM VALUES (10, 1, 30.00, 'Alice'), (11, 1, 20.00, 'Alice'), (12, 2, 80.00, 'Bob')",
|
||||
"CREATE TABLE TIME_EVENT (ID INT PRIMARY KEY, EVENT_TIME TIME(6) WITH TIME ZONE, "
|
||||
+ "EVENT_AT TIMESTAMP(6) WITH TIME ZONE)",
|
||||
"INSERT INTO TIME_EVENT VALUES (2, TIME WITH TIME ZONE '06:00:00.123456+02:00', "
|
||||
+ "TIMESTAMP WITH TIME ZONE '2026-08-21 06:00:00.123456+02:00')",
|
||||
"CREATE TABLE PRECISE_EVENT (ID INT PRIMARY KEY, EVENT_AT TIMESTAMP(6))",
|
||||
"INSERT INTO PRECISE_EVENT VALUES "
|
||||
+ "(2, TIMESTAMP '2026-08-21 08:30:00.654321')");
|
||||
execute(region,
|
||||
"CREATE TABLE CUSTOMER_REGION (CUSTOMER_ID INT PRIMARY KEY, REGION VARCHAR(64))",
|
||||
"INSERT INTO CUSTOMER_REGION VALUES (1, 'North'), (2, 'South'), (3, 'West')");
|
||||
|
||||
RuntimeFingerprint fingerprint = new RuntimeFingerprint(
|
||||
"H2", "2", "H2 JDBC Driver", "2", "1"
|
||||
);
|
||||
engine = FederationSqlEngines.builder()
|
||||
.dataSourceResolver(definition -> FederationDataSourceHandles.shared(
|
||||
dataSourceFor(definition.sourceId()),
|
||||
fingerprint
|
||||
))
|
||||
.tableStatisticsProvider(() -> new FederationStatisticsSnapshot(
|
||||
statisticsVersion.get(),
|
||||
Map.of(
|
||||
new FederationStatisticsSnapshot.TableKey(
|
||||
SALES_SOURCE, "APP", "CUSTOMER"
|
||||
),
|
||||
new FederationTableStatistics(
|
||||
salesRowCount.get(),
|
||||
48,
|
||||
statisticsCollectedAt,
|
||||
"test-catalog"
|
||||
),
|
||||
new FederationStatisticsSnapshot.TableKey(
|
||||
BILLING_SOURCE, "APP", "ORDER_ITEM"
|
||||
),
|
||||
new FederationTableStatistics(
|
||||
3,
|
||||
64,
|
||||
statisticsCollectedAt,
|
||||
"test-catalog"
|
||||
),
|
||||
new FederationStatisticsSnapshot.TableKey(
|
||||
REGION_SOURCE, "APP", "CUSTOMER_REGION"
|
||||
),
|
||||
new FederationTableStatistics(
|
||||
3,
|
||||
32,
|
||||
statisticsCollectedAt,
|
||||
"test-catalog"
|
||||
)
|
||||
)
|
||||
))
|
||||
.federationExecutionPolicy(threeSourcePolicy())
|
||||
.maximumPlanCacheEntries(32)
|
||||
.build();
|
||||
engine.sources().apply(definition(SALES_SOURCE), SourceApplyOptions.prewarmNow());
|
||||
engine.sources().apply(definition(BILLING_SOURCE), SourceApplyOptions.prewarmNow());
|
||||
engine.sources().apply(definition(REGION_SOURCE), SourceApplyOptions.prewarmNow());
|
||||
|
||||
scope = new FederationQueryScopeDefinition(
|
||||
"sales-billing",
|
||||
1,
|
||||
Map.of(
|
||||
"SALES",
|
||||
FederationSourceBindingDefinition.of(SALES_SOURCE, 1),
|
||||
"BILLING",
|
||||
FederationSourceBindingDefinition.of(BILLING_SOURCE, 1)
|
||||
),
|
||||
"SALES",
|
||||
FederationExecutionPolicy.basic()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 Engine。
|
||||
*/
|
||||
@After
|
||||
public void tearDown() {
|
||||
if (engine != null) {
|
||||
engine.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证同一 Query Scope 中实际只引用一个源时保持完整单源下推。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRouteSingleReferencedSourceToDirectExecution() {
|
||||
FederationSqlPlan plan = engine.compile(SqlCompileRequest.of(
|
||||
"SELECT NAME FROM SALES.APP.CUSTOMER ORDER BY ID",
|
||||
scope
|
||||
));
|
||||
|
||||
Assert.assertEquals(FederationQueryMode.SINGLE_SOURCE, plan.queryMode());
|
||||
Assert.assertEquals(1, plan.fragments().size());
|
||||
Assert.assertEquals(java.util.Set.of(SALES_SOURCE), plan.referencedSources());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证短逻辑表名、三段逻辑表名和跨源逻辑表 Join 共用底层映射。
|
||||
*/
|
||||
@Test
|
||||
public void shouldExecuteLogicalTableNamesAcrossSources() {
|
||||
FederationQueryScopeDefinition logicalScope =
|
||||
FederationQueryScopeDefinition.virtual(
|
||||
"logical-sales-billing",
|
||||
2,
|
||||
scope.bindings(),
|
||||
"SALES",
|
||||
List.of(
|
||||
FederationLogicalTableDefinition.of(
|
||||
"customers", "SALES", "APP", "CUSTOMER"
|
||||
),
|
||||
FederationLogicalTableDefinition.of(
|
||||
"order_lines", "BILLING", "APP", "ORDER_ITEM"
|
||||
)
|
||||
),
|
||||
FederationExecutionPolicy.basic()
|
||||
);
|
||||
|
||||
try (FederationResultCursor cursor = engine.query(SqlQueryCommand.of(
|
||||
"SELECT customers.NAME FROM customers ORDER BY customers.ID",
|
||||
logicalScope,
|
||||
List.of()
|
||||
))) {
|
||||
Assert.assertTrue(cursor.next());
|
||||
Assert.assertEquals("Alice", cursor.getObject(1));
|
||||
}
|
||||
|
||||
try (FederationResultCursor cursor = engine.query(SqlQueryCommand.of(
|
||||
"SELECT customers.NAME FROM SALES.APP.customers "
|
||||
+ "ORDER BY SALES.APP.customers.ID",
|
||||
logicalScope,
|
||||
List.of()
|
||||
))) {
|
||||
Assert.assertTrue(cursor.next());
|
||||
Assert.assertEquals("Alice", cursor.getObject(1));
|
||||
}
|
||||
|
||||
try (FederationResultCursor cursor = engine.query(SqlQueryCommand.of(
|
||||
"SELECT c.NAME, o.AMOUNT FROM customers c "
|
||||
+ "JOIN order_lines o ON c.ID = o.CUSTOMER_ID "
|
||||
+ "ORDER BY o.ID",
|
||||
logicalScope,
|
||||
List.of()
|
||||
))) {
|
||||
Assert.assertTrue(cursor.next());
|
||||
Assert.assertEquals(List.of("Alice", new BigDecimal("30.00")), cursor.row());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证全限定 SQL 不依赖未引用默认 Binding 的运行状态。
|
||||
*/
|
||||
@Test
|
||||
public void shouldNotAcquireUnavailableDefaultBindingWhenOnlyAnotherSourceIsReferenced() {
|
||||
FederationQueryScopeDefinition unavailableDefault =
|
||||
FederationQueryScopeDefinition.virtual(
|
||||
"unavailable-default",
|
||||
1,
|
||||
Map.of(
|
||||
"OFFLINE", FederationSourceBindingDefinition.of(
|
||||
new SourceId("offline-source"), 1
|
||||
),
|
||||
"BILLING", FederationSourceBindingDefinition.of(BILLING_SOURCE, 1)
|
||||
),
|
||||
"OFFLINE",
|
||||
FederationExecutionPolicy.basic()
|
||||
);
|
||||
|
||||
FederationSqlPlan plan = engine.compile(SqlCompileRequest.of(
|
||||
"SELECT AMOUNT FROM BILLING.APP.ORDER_ITEM",
|
||||
unavailableDefault
|
||||
));
|
||||
|
||||
Assert.assertEquals(FederationQueryMode.SINGLE_SOURCE, plan.queryMode());
|
||||
Assert.assertEquals(java.util.Set.of(BILLING_SOURCE), plan.referencedSources());
|
||||
|
||||
FederationSqlPlan ctePlan = engine.compile(SqlCompileRequest.of(
|
||||
"WITH x AS (SELECT AMOUNT FROM BILLING.APP.ORDER_ITEM) SELECT * FROM x",
|
||||
unavailableDefault
|
||||
));
|
||||
Assert.assertEquals(FederationQueryMode.SINGLE_SOURCE, ctePlan.queryMode());
|
||||
Assert.assertEquals(java.util.Set.of(BILLING_SOURCE), ctePlan.referencedSources());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证跨源等值 Join、聚合、全局排序和查询指标。
|
||||
*/
|
||||
@Test
|
||||
public void shouldJoinAggregateAndSortAcrossTwoSources() {
|
||||
String sql = "SELECT c.ID, SUM(o.AMOUNT) AS TOTAL "
|
||||
+ "FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID "
|
||||
+ "GROUP BY c.ID ORDER BY TOTAL DESC";
|
||||
FederationSqlPlan plan = engine.compile(SqlCompileRequest.of(sql, scope));
|
||||
Assert.assertEquals(FederationQueryMode.FEDERATED, plan.queryMode());
|
||||
Assert.assertEquals(2, plan.fragments().size());
|
||||
Assert.assertEquals(
|
||||
"the smaller estimated input should become the Hash Join build side",
|
||||
SALES_SOURCE,
|
||||
plan.fragments().get(1).sourceId()
|
||||
);
|
||||
|
||||
List<List<Object>> rows = new ArrayList<>();
|
||||
FederationQueryMetricsSnapshot finalMetrics;
|
||||
try (FederationResultCursor cursor = engine.query(
|
||||
SqlQueryCommand.of(sql, scope, List.of())
|
||||
)) {
|
||||
while (cursor.next()) {
|
||||
rows.add(cursor.row());
|
||||
}
|
||||
finalMetrics = cursor.metrics();
|
||||
}
|
||||
|
||||
Assert.assertEquals(2, rows.size());
|
||||
Assert.assertEquals(2, rows.get(0).get(0));
|
||||
Assert.assertEquals(new BigDecimal("80.00"), rows.get(0).get(1));
|
||||
Assert.assertEquals(1, rows.get(1).get(0));
|
||||
Assert.assertEquals(new BigDecimal("50.00"), rows.get(1).get(1));
|
||||
Assert.assertTrue(finalMetrics.complete());
|
||||
Assert.assertEquals(2, finalMetrics.returnedRows());
|
||||
Assert.assertTrue(finalMetrics.intermediateRows() >= 6);
|
||||
Assert.assertEquals(2, finalMetrics.fragments().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证三个独立 JDBC 数据源可由同一计划完成 Join 并返回稳定结果。
|
||||
*/
|
||||
@Test
|
||||
public void shouldExecuteJoinAcrossThreeSources() {
|
||||
FederationQueryScopeDefinition threeSourceScope =
|
||||
new FederationQueryScopeDefinition(
|
||||
"sales-billing-region",
|
||||
1,
|
||||
Map.of(
|
||||
"SALES", FederationSourceBindingDefinition.of(SALES_SOURCE, 1),
|
||||
"BILLING", FederationSourceBindingDefinition.of(BILLING_SOURCE, 1),
|
||||
"REGION", FederationSourceBindingDefinition.of(REGION_SOURCE, 1)
|
||||
),
|
||||
"SALES",
|
||||
threeSourcePolicy()
|
||||
);
|
||||
String sql = "SELECT c.NAME, o.AMOUNT, r.REGION "
|
||||
+ "FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID "
|
||||
+ "JOIN REGION.APP.CUSTOMER_REGION r ON c.ID = r.CUSTOMER_ID "
|
||||
+ "ORDER BY o.ID";
|
||||
|
||||
FederationSqlPlan plan = engine.compile(SqlCompileRequest.of(sql, threeSourceScope));
|
||||
Assert.assertEquals(FederationQueryMode.FEDERATED, plan.queryMode());
|
||||
Assert.assertEquals(3, plan.referencedSources().size());
|
||||
Assert.assertEquals(3, plan.fragments().size());
|
||||
|
||||
List<List<Object>> rows = new ArrayList<>();
|
||||
try (FederationResultCursor cursor = engine.query(
|
||||
SqlQueryCommand.of(sql, threeSourceScope, List.of())
|
||||
)) {
|
||||
while (cursor.next()) {
|
||||
rows.add(cursor.row());
|
||||
}
|
||||
}
|
||||
Assert.assertEquals(3, rows.size());
|
||||
Assert.assertEquals(
|
||||
List.of("Alice", new BigDecimal("30.00"), "North"),
|
||||
rows.get(0)
|
||||
);
|
||||
Assert.assertEquals(
|
||||
List.of("Bob", new BigDecimal("80.00"), "South"),
|
||||
rows.get(2)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证两个分片分别绑定原始查询中的动态参数。
|
||||
*/
|
||||
@Test
|
||||
public void shouldMapParametersIntoDifferentFragments() {
|
||||
String sql = "SELECT c.NAME, o.AMOUNT "
|
||||
+ "FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID "
|
||||
+ "WHERE c.ID > ? AND o.AMOUNT > ? ORDER BY o.AMOUNT";
|
||||
FederationSqlPlan plan = engine.compile(new SqlCompileRequest(
|
||||
sql,
|
||||
scope,
|
||||
List.of(Types.INTEGER, Types.DECIMAL),
|
||||
"default"
|
||||
));
|
||||
Assert.assertEquals(2, plan.fragments().size());
|
||||
Assert.assertEquals(List.of(0), plan.fragments().get(0).parameterMapping());
|
||||
Assert.assertEquals(List.of(1), plan.fragments().get(1).parameterMapping());
|
||||
Assert.assertTrue(plan.fragments().stream()
|
||||
.allMatch(fragment -> fragment.executableSql().toUpperCase().contains("WHERE")));
|
||||
try (FederationResultCursor cursor = engine.query(SqlQueryCommand.of(
|
||||
sql,
|
||||
scope,
|
||||
List.of(
|
||||
new SqlParameter(Types.INTEGER, 0),
|
||||
new SqlParameter(Types.DECIMAL, new BigDecimal("25.00"))
|
||||
)
|
||||
))) {
|
||||
Assert.assertTrue(cursor.next());
|
||||
Assert.assertEquals(List.of("Alice", new BigDecimal("30.00")), cursor.row());
|
||||
Assert.assertTrue(cursor.next());
|
||||
Assert.assertEquals(List.of("Bob", new BigDecimal("80.00")), cursor.row());
|
||||
Assert.assertFalse(cursor.next());
|
||||
}
|
||||
|
||||
try (FederationResultCursor cursor = engine.query(SqlQueryCommand.of(
|
||||
"SELECT NAME FROM SALES.APP.CUSTOMER ORDER BY ID "
|
||||
+ "OFFSET ? ROWS FETCH NEXT ? ROWS ONLY",
|
||||
scope,
|
||||
List.of(
|
||||
new SqlParameter(Types.INTEGER, 1),
|
||||
new SqlParameter(Types.INTEGER, 1)
|
||||
)
|
||||
))) {
|
||||
Assert.assertTrue(cursor.next());
|
||||
Assert.assertEquals("Bob", cursor.getObject(1));
|
||||
Assert.assertFalse(cursor.next());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 LEFT JOIN、UNION ALL、CTE 和全局分页使用同一联邦执行入口。
|
||||
*/
|
||||
@Test
|
||||
public void shouldExecuteBasicFederatedOperators() {
|
||||
List<List<Object>> leftRows = query(
|
||||
"SELECT c.NAME, o.AMOUNT FROM SALES.APP.CUSTOMER c "
|
||||
+ "LEFT JOIN BILLING.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID "
|
||||
+ "ORDER BY c.ID, o.ID"
|
||||
);
|
||||
Assert.assertEquals(4, leftRows.size());
|
||||
Assert.assertEquals("Carol", leftRows.get(3).get(0));
|
||||
Assert.assertNull(leftRows.get(3).get(1));
|
||||
|
||||
List<List<Object>> unionRows = query(
|
||||
"SELECT ID FROM SALES.APP.CUSTOMER "
|
||||
+ "UNION ALL SELECT CUSTOMER_ID FROM BILLING.APP.ORDER_ITEM ORDER BY ID"
|
||||
);
|
||||
Assert.assertEquals(List.of(1, 1, 1, 2, 2, 3),
|
||||
unionRows.stream().map(row -> row.get(0)).toList());
|
||||
|
||||
List<List<Object>> cteRows = query(
|
||||
"WITH large_orders AS ("
|
||||
+ "SELECT CUSTOMER_ID, AMOUNT FROM BILLING.APP.ORDER_ITEM WHERE AMOUNT >= 50"
|
||||
+ ") SELECT c.NAME, o.AMOUNT FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN large_orders o ON c.ID = o.CUSTOMER_ID "
|
||||
+ "ORDER BY o.AMOUNT DESC FETCH NEXT 1 ROWS ONLY"
|
||||
);
|
||||
Assert.assertEquals(List.of(List.of("Bob", new BigDecimal("80.00"))), cteRows);
|
||||
|
||||
List<List<Object>> residualRows = query(
|
||||
"SELECT c.ID, c.NAME FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID "
|
||||
+ "WHERE c.ID < o.ID ORDER BY c.ID, o.ID"
|
||||
);
|
||||
Assert.assertEquals(List.of("Alice", "Alice", "Bob"),
|
||||
residualRows.stream().map(row -> row.get(1)).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证本地联邦算子以 UTC Offset 类型返回 JDBC 4.2 时区值。
|
||||
*/
|
||||
@Test
|
||||
public void shouldPreserveTimezoneSemanticsAcrossFederatedUnion() {
|
||||
List<List<Object>> rows = query(
|
||||
"SELECT ID, EVENT_TIME, EVENT_AT FROM SALES.APP.TIME_EVENT "
|
||||
+ "UNION ALL SELECT ID, EVENT_TIME, EVENT_AT FROM BILLING.APP.TIME_EVENT "
|
||||
+ "ORDER BY ID"
|
||||
);
|
||||
|
||||
Assert.assertEquals(2, rows.size());
|
||||
Assert.assertEquals(
|
||||
java.time.OffsetTime.parse("04:00:00.123456Z"),
|
||||
rows.get(0).get(1)
|
||||
);
|
||||
Assert.assertEquals(
|
||||
java.time.OffsetDateTime.parse("2026-08-21T04:00:00.123456Z"),
|
||||
rows.get(0).get(2)
|
||||
);
|
||||
Assert.assertEquals(
|
||||
java.time.OffsetTime.parse("04:00:00.123456Z"),
|
||||
rows.get(1).get(1)
|
||||
);
|
||||
Assert.assertEquals(
|
||||
java.time.OffsetDateTime.parse("2026-08-21T04:00:00.123456Z"),
|
||||
rows.get(1).get(2)
|
||||
);
|
||||
|
||||
List<List<Object>> joined = query(
|
||||
"SELECT s.ID, b.ID FROM SALES.APP.TIME_EVENT s "
|
||||
+ "JOIN BILLING.APP.TIME_EVENT b ON s.EVENT_AT = b.EVENT_AT"
|
||||
);
|
||||
Assert.assertEquals(List.of(List.of(1, 2)), joined);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证逻辑 Explain 不访问数据库 Optimizer,物理 Explain 显式返回每个分片原生计划。
|
||||
*/
|
||||
@Test
|
||||
public void shouldExplainLogicalAndPhysicalFederatedPlans() {
|
||||
String sql = "SELECT c.NAME, o.AMOUNT FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID";
|
||||
SqlCompileRequest compileRequest = SqlCompileRequest.of(sql, scope);
|
||||
|
||||
SqlExplainResult logical = engine.explain(new SqlExplainRequest(
|
||||
compileRequest,
|
||||
SqlExplainLevel.LOGICAL
|
||||
));
|
||||
Assert.assertEquals(FederationQueryMode.FEDERATED, logical.queryMode());
|
||||
Assert.assertEquals(2, logical.fragments().size());
|
||||
Assert.assertTrue(logical.fragments().stream()
|
||||
.allMatch(fragment -> fragment.physicalExplain() == null));
|
||||
Assert.assertTrue(logical.fragments().stream().allMatch(fragment ->
|
||||
fragment.costEstimate().estimatedRows() >= 0
|
||||
&& fragment.costEstimate().estimatedRowWidthBytes() > 0
|
||||
&& fragment.costEstimate().estimatedTransferBytes() >= 0
|
||||
&& fragment.costEstimate().statisticsSource().contains("test-catalog")
|
||||
&& "stats-v1".equals(
|
||||
fragment.costEstimate().statisticsSnapshotVersion()
|
||||
)
|
||||
&& !fragment.pushedDownOperators().isEmpty()
|
||||
));
|
||||
|
||||
SqlExplainResult physical = engine.explain(new SqlExplainRequest(compileRequest));
|
||||
Assert.assertEquals(SqlExplainLevel.PHYSICAL, physical.level());
|
||||
Assert.assertEquals(2, physical.fragments().size());
|
||||
Assert.assertTrue(physical.fragments().stream().allMatch(fragment ->
|
||||
fragment.physicalExplain() != null
|
||||
&& fragment.physicalExplain().available()
|
||||
&& !fragment.physicalExplain().nativePlan().isBlank()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证纯快照版本变化不扰动计划,实际引用表统计变化才触发重编译。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRecompileWhenStatisticsSnapshotChanges() {
|
||||
SqlCompileRequest request = SqlCompileRequest.of(
|
||||
"SELECT NAME FROM SALES.APP.CUSTOMER ORDER BY ID",
|
||||
scope
|
||||
);
|
||||
FederationSqlPlan first = engine.compile(request);
|
||||
FederationSqlPlan cacheHit = engine.compile(request);
|
||||
Assert.assertSame(first.relRoot(), cacheHit.relRoot());
|
||||
|
||||
statisticsVersion.set("stats-v2");
|
||||
FederationSqlPlan versionOnly = engine.compile(request);
|
||||
Assert.assertSame(first.relRoot(), versionOnly.relRoot());
|
||||
|
||||
salesRowCount.set(4);
|
||||
FederationSqlPlan refreshed = engine.compile(request);
|
||||
|
||||
Assert.assertNotSame(first.relRoot(), refreshed.relRoot());
|
||||
Assert.assertEquals(
|
||||
"stats-v2",
|
||||
refreshed.fragments().get(0).costEstimate().statisticsSnapshotVersion()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证不支持的跨源算子和中间结果预算超限均返回稳定错误。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectUnsupportedOperatorsAndExceededBudget() {
|
||||
assertCompileError(
|
||||
"SELECT c.NAME FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.ID < o.CUSTOMER_ID",
|
||||
FederationSqlErrorCode.FEDERATION_OPERATOR_UNSUPPORTED
|
||||
);
|
||||
assertCompileError(
|
||||
"SELECT ID FROM SALES.APP.CUSTOMER "
|
||||
+ "UNION SELECT CUSTOMER_ID FROM BILLING.APP.ORDER_ITEM",
|
||||
FederationSqlErrorCode.FEDERATION_OPERATOR_UNSUPPORTED
|
||||
);
|
||||
assertCompileError(
|
||||
"SELECT c.ID FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.NAME = o.CODE",
|
||||
FederationSqlErrorCode.FEDERATION_OPERATOR_UNSUPPORTED
|
||||
);
|
||||
assertCompileError(
|
||||
"SELECT c.ID FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID "
|
||||
+ "AND c.NAME < o.CODE",
|
||||
FederationSqlErrorCode.FEDERATION_OPERATOR_UNSUPPORTED
|
||||
);
|
||||
assertCompileError(
|
||||
"SELECT c.ID FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID "
|
||||
+ "AND c.ID < o.ID",
|
||||
FederationSqlErrorCode.FEDERATION_OPERATOR_UNSUPPORTED
|
||||
);
|
||||
assertCompileError(
|
||||
"SELECT c.ID FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID "
|
||||
+ "AND c.NAME LIKE o.CODE",
|
||||
FederationSqlErrorCode.FEDERATION_OPERATOR_UNSUPPORTED
|
||||
);
|
||||
assertCompileError(
|
||||
"SELECT c.NAME FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID "
|
||||
+ "ORDER BY c.NAME",
|
||||
FederationSqlErrorCode.FEDERATION_OPERATOR_UNSUPPORTED
|
||||
);
|
||||
assertCompileError(
|
||||
"SELECT c.NAME, COUNT(*) FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID "
|
||||
+ "GROUP BY c.NAME",
|
||||
FederationSqlErrorCode.FEDERATION_OPERATOR_UNSUPPORTED
|
||||
);
|
||||
assertQueryError(
|
||||
"SELECT EVENT_AT FROM SALES.APP.PRECISE_EVENT "
|
||||
+ "UNION ALL SELECT EVENT_AT FROM BILLING.APP.PRECISE_EVENT",
|
||||
FederationSqlErrorCode.FEDERATION_OPERATOR_UNSUPPORTED
|
||||
);
|
||||
|
||||
FederationQueryScopeDefinition strictScope = FederationQueryScopeDefinition.virtual(
|
||||
"strict-budget",
|
||||
2,
|
||||
scope.bindings(),
|
||||
scope.defaultBinding(),
|
||||
new FederationExecutionPolicy(2, 8, 2, 1, 1024, 60_000)
|
||||
);
|
||||
try (FederationResultCursor cursor = engine.query(SqlQueryCommand.of(
|
||||
"SELECT c.NAME, o.AMOUNT FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID",
|
||||
strictScope,
|
||||
List.of()
|
||||
))) {
|
||||
cursor.next();
|
||||
Assert.fail("intermediate row budget should reject the query");
|
||||
} catch (FederationSqlException exception) {
|
||||
Assert.assertEquals(
|
||||
FederationSqlErrorCode.FEDERATION_RESOURCE_LIMIT_EXCEEDED,
|
||||
exception.errorCode()
|
||||
);
|
||||
}
|
||||
|
||||
FederationQueryScopeDefinition localExpansionScope =
|
||||
FederationQueryScopeDefinition.virtual(
|
||||
"local-expansion-budget",
|
||||
3,
|
||||
scope.bindings(),
|
||||
scope.defaultBinding(),
|
||||
new FederationExecutionPolicy(2, 8, 2, 7, 64_000, 60_000)
|
||||
);
|
||||
try (FederationResultCursor cursor = engine.query(SqlQueryCommand.of(
|
||||
"SELECT COUNT(*) FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID",
|
||||
localExpansionScope,
|
||||
List.of()
|
||||
))) {
|
||||
cursor.next();
|
||||
Assert.fail("local join expansion should consume the intermediate row budget");
|
||||
} catch (FederationSqlException exception) {
|
||||
Assert.assertEquals(
|
||||
FederationSqlErrorCode.FEDERATION_RESOURCE_LIMIT_EXCEEDED,
|
||||
exception.errorCode()
|
||||
);
|
||||
}
|
||||
|
||||
Assert.assertFalse(query(
|
||||
"SELECT c.NAME FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID"
|
||||
).isEmpty());
|
||||
|
||||
FederationQueryScopeDefinition singleFragmentSlot =
|
||||
FederationQueryScopeDefinition.virtual(
|
||||
"single-fragment-slot",
|
||||
3,
|
||||
scope.bindings(),
|
||||
scope.defaultBinding(),
|
||||
new FederationExecutionPolicy(2, 8, 1, 100, 64_000, 2_000)
|
||||
);
|
||||
try (FederationResultCursor cursor = engine.query(SqlQueryCommand.of(
|
||||
"SELECT c.NAME FROM SALES.APP.CUSTOMER c "
|
||||
+ "JOIN BILLING.APP.ORDER_ITEM o ON c.ID = o.CUSTOMER_ID",
|
||||
singleFragmentSlot,
|
||||
List.of()
|
||||
))) {
|
||||
Assert.assertTrue(cursor.next());
|
||||
}
|
||||
}
|
||||
|
||||
private List<List<Object>> query(String sql) {
|
||||
List<List<Object>> rows = new ArrayList<>();
|
||||
try (FederationResultCursor cursor = engine.query(
|
||||
SqlQueryCommand.of(sql, scope, List.of())
|
||||
)) {
|
||||
while (cursor.next()) {
|
||||
rows.add(cursor.row());
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private void assertCompileError(String sql, FederationSqlErrorCode expected) {
|
||||
try {
|
||||
engine.compile(SqlCompileRequest.of(sql, scope));
|
||||
Assert.fail("SQL should have been rejected: " + sql);
|
||||
} catch (FederationSqlException exception) {
|
||||
Assert.assertEquals(expected, exception.errorCode());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言联邦查询在执行阶段返回指定稳定错误。
|
||||
*
|
||||
* @param sql 待执行 SQL
|
||||
* @param expected 预期错误码
|
||||
*/
|
||||
private void assertQueryError(String sql, FederationSqlErrorCode expected) {
|
||||
try (FederationResultCursor cursor = engine.query(
|
||||
SqlQueryCommand.of(sql, scope, List.of())
|
||||
)) {
|
||||
cursor.next();
|
||||
Assert.fail("SQL execution should have been rejected: " + sql);
|
||||
} catch (FederationSqlException exception) {
|
||||
Assert.assertEquals(expected, exception.errorCode());
|
||||
}
|
||||
}
|
||||
|
||||
private static JdbcDataSource dataSource(String name) {
|
||||
JdbcDataSource dataSource = new JdbcDataSource();
|
||||
dataSource.setURL(
|
||||
"jdbc:h2:mem:federation_" + name + '_' + System.nanoTime()
|
||||
+ ";DB_CLOSE_DELAY=-1"
|
||||
);
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据物理源选择测试数据库。
|
||||
*
|
||||
* @param sourceId 物理数据源标识
|
||||
* @return 对应测试数据源
|
||||
*/
|
||||
private JdbcDataSource dataSourceFor(SourceId sourceId) {
|
||||
if (sourceId.equals(SALES_SOURCE)) {
|
||||
return sales;
|
||||
}
|
||||
if (sourceId.equals(BILLING_SOURCE)) {
|
||||
return billing;
|
||||
}
|
||||
if (sourceId.equals(REGION_SOURCE)) {
|
||||
return region;
|
||||
}
|
||||
throw new IllegalArgumentException("unknown test source: " + sourceId.value());
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回允许三源执行且限制并发分片数的测试策略。
|
||||
*
|
||||
* @return 三源执行策略
|
||||
*/
|
||||
private static FederationExecutionPolicy threeSourcePolicy() {
|
||||
return new FederationExecutionPolicy(
|
||||
3,
|
||||
8,
|
||||
2,
|
||||
100_000,
|
||||
64L * 1024L * 1024L,
|
||||
60_000
|
||||
);
|
||||
}
|
||||
|
||||
private static void execute(JdbcDataSource dataSource, String... statements)
|
||||
throws Exception {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
for (String sql : statements) {
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static FederationSourceDefinition definition(SourceId sourceId) {
|
||||
return new FederationSourceDefinition(
|
||||
sourceId,
|
||||
1,
|
||||
JdbcFederationSqlAdapterProvider.ADAPTER_ID,
|
||||
List.of(new JdbcSchemaDefinition("APP", null, "PUBLIC")),
|
||||
Map.of()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
package com.easyagents.federation.sql.adapter.jdbc;
|
||||
|
||||
import com.easyagents.federation.sql.adapter.AdapterCompatibility;
|
||||
import com.easyagents.federation.sql.adapter.AdapterCompatibilityStatus;
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import com.easyagents.federation.sql.execute.FederationExecutionGuard;
|
||||
import com.easyagents.federation.sql.execute.FederationExecutionObserver;
|
||||
import com.easyagents.federation.sql.execute.FederationFragmentExecutionContext;
|
||||
import com.easyagents.federation.sql.execute.QueryId;
|
||||
import com.easyagents.federation.sql.execute.SqlExecutionOptions;
|
||||
import com.easyagents.federation.sql.execute.StatementLifecycle;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.Reader;
|
||||
import java.io.StringReader;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLTimeoutException;
|
||||
import java.sql.SQLTransientConnectionException;
|
||||
import java.sql.Statement;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.logging.Logger;
|
||||
import javax.sql.DataSource;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* JDBC 分片执行器的连接获取错误边界测试。
|
||||
*/
|
||||
public class JdbcFederationFragmentExecutorTest {
|
||||
|
||||
/**
|
||||
* 验证连接池等待跨过查询截止时间时保留统一查询超时错误。
|
||||
*/
|
||||
@Test
|
||||
public void shouldPreferQueryDeadlineOverConnectionPoolTimeout() {
|
||||
AtomicInteger checks = new AtomicInteger();
|
||||
FederationExecutionGuard guard = new FederationExecutionGuard() {
|
||||
@Override
|
||||
public void ensureAllowed() {
|
||||
if (checks.incrementAndGet() > 1) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.QUERY_TIMEOUT,
|
||||
"query deadline reached"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long remainingNanos() {
|
||||
return 1L;
|
||||
}
|
||||
};
|
||||
|
||||
FederationSqlException failure = expectFailure(context(guard));
|
||||
|
||||
Assert.assertEquals(FederationSqlErrorCode.QUERY_TIMEOUT, failure.errorCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证截止时间仍有效时保留连接获取超时分类。
|
||||
*/
|
||||
@Test
|
||||
public void shouldReportConnectionAcquisitionTimeoutBeforeQueryDeadline() {
|
||||
FederationSqlException failure = expectFailure(
|
||||
context(FederationExecutionGuard.none())
|
||||
);
|
||||
|
||||
Assert.assertEquals(
|
||||
FederationSqlErrorCode.CONNECTION_ACQUISITION_TIMEOUT,
|
||||
failure.errorCode()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证显式取消先到达时,驱动的 SQLTimeoutException 不会覆盖取消终态。
|
||||
*/
|
||||
@Test
|
||||
public void shouldPreserveCancellationWhenDriverReportsExecutionTimeout() {
|
||||
TerminalLifecycle lifecycle = new TerminalLifecycle(false, true);
|
||||
AtomicBoolean statementClosed = new AtomicBoolean();
|
||||
AtomicBoolean connectionClosed = new AtomicBoolean();
|
||||
|
||||
FederationSqlException failure = expectFailure(context(
|
||||
FederationExecutionGuard.none(),
|
||||
executionTimeoutDataSource(statementClosed, connectionClosed),
|
||||
lifecycle
|
||||
));
|
||||
|
||||
Assert.assertEquals(FederationSqlErrorCode.QUERY_CANCELLED, failure.errorCode());
|
||||
Assert.assertTrue(lifecycle.unregistered.get());
|
||||
Assert.assertTrue(statementClosed.get());
|
||||
Assert.assertTrue(connectionClosed.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证结果读取阶段同样保留已经先到达的显式取消终态。
|
||||
*/
|
||||
@Test
|
||||
public void shouldPreserveCancellationWhenDriverReportsResultTimeout() {
|
||||
TerminalLifecycle lifecycle = new TerminalLifecycle(false, true);
|
||||
JdbcFederationResultCursor cursor = failingCursor(
|
||||
lifecycle,
|
||||
new SQLTimeoutException("driver reported timeout after cancel")
|
||||
);
|
||||
|
||||
FederationSqlException failure = expectCursorFailure(cursor);
|
||||
|
||||
Assert.assertEquals(FederationSqlErrorCode.QUERY_CANCELLED, failure.errorCode());
|
||||
Assert.assertTrue(lifecycle.unregistered.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证统一超时先到达时,驱动普通异常仍保持超时终态。
|
||||
*/
|
||||
@Test
|
||||
public void shouldPreserveTimeoutWhenDriverReportsGenericReadFailure() {
|
||||
TerminalLifecycle lifecycle = new TerminalLifecycle(true, true);
|
||||
JdbcFederationResultCursor cursor = failingCursor(
|
||||
lifecycle,
|
||||
new SQLException("statement was closed by timeout task")
|
||||
);
|
||||
|
||||
FederationSqlException failure = expectCursorFailure(cursor);
|
||||
|
||||
Assert.assertEquals(FederationSqlErrorCode.QUERY_TIMEOUT, failure.errorCode());
|
||||
Assert.assertTrue(lifecycle.unregistered.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证二进制流取得后发生取消时,后续流读取立即终止并释放 JDBC 资源。
|
||||
*
|
||||
* @throws Exception 流读取失败
|
||||
*/
|
||||
@Test
|
||||
public void shouldStopBinaryStreamReadAfterCancellation() throws Exception {
|
||||
AtomicBoolean cancelled = new AtomicBoolean();
|
||||
TerminalLifecycle lifecycle = new TerminalLifecycle(false, false);
|
||||
JdbcFederationResultCursor cursor = streamingCursor(
|
||||
lifecycle,
|
||||
cancellationGuard(cancelled)
|
||||
);
|
||||
|
||||
InputStream stream = cursor.getBinaryStream(1);
|
||||
cancelled.set(true);
|
||||
FederationSqlException failure = expectStreamFailure(stream);
|
||||
|
||||
Assert.assertEquals(FederationSqlErrorCode.QUERY_CANCELLED, failure.errorCode());
|
||||
Assert.assertTrue(lifecycle.unregistered.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证字符流取得后发生超时时,后续流读取立即终止并释放 JDBC 资源。
|
||||
*
|
||||
* @throws Exception 流读取失败
|
||||
*/
|
||||
@Test
|
||||
public void shouldStopCharacterStreamReadAfterTimeout() throws Exception {
|
||||
AtomicBoolean timedOut = new AtomicBoolean();
|
||||
TerminalLifecycle lifecycle = new TerminalLifecycle(false, false);
|
||||
JdbcFederationResultCursor cursor = streamingCursor(
|
||||
lifecycle,
|
||||
timeoutGuard(timedOut)
|
||||
);
|
||||
|
||||
Reader reader = cursor.getCharacterStream(2);
|
||||
timedOut.set(true);
|
||||
FederationSqlException failure = expectStreamFailure(reader);
|
||||
|
||||
Assert.assertEquals(FederationSqlErrorCode.QUERY_TIMEOUT, failure.errorCode());
|
||||
Assert.assertTrue(lifecycle.unregistered.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证阻塞的结果读取可由 Statement.cancel 解阻,并确定性释放全部 JDBC 资源。
|
||||
*
|
||||
* @throws Exception 并发测试等待失败
|
||||
*/
|
||||
@Test
|
||||
public void shouldCancelBlockingResultReadAndCloseAllResources() throws Exception {
|
||||
CountDownLatch readStarted = new CountDownLatch(1);
|
||||
CountDownLatch cancelSignal = new CountDownLatch(1);
|
||||
AtomicBoolean resultSetClosed = new AtomicBoolean();
|
||||
AtomicBoolean statementClosed = new AtomicBoolean();
|
||||
AtomicBoolean connectionClosed = new AtomicBoolean();
|
||||
CancellableLifecycle lifecycle = new CancellableLifecycle();
|
||||
DataSource dataSource = blockingReadDataSource(
|
||||
readStarted,
|
||||
cancelSignal,
|
||||
resultSetClosed,
|
||||
statementClosed,
|
||||
connectionClosed
|
||||
);
|
||||
JdbcFederationResultCursor cursor = (JdbcFederationResultCursor)
|
||||
new JdbcFederationFragmentExecutor().execute(context(
|
||||
FederationExecutionGuard.none(),
|
||||
dataSource,
|
||||
lifecycle
|
||||
));
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
Future<FederationSqlException> read = executor.submit(
|
||||
() -> expectCursorFailure(cursor)
|
||||
);
|
||||
Assert.assertTrue(readStarted.await(2, TimeUnit.SECONDS));
|
||||
|
||||
lifecycle.requestCancellation();
|
||||
FederationSqlException failure = read.get(2, TimeUnit.SECONDS);
|
||||
|
||||
Assert.assertEquals(
|
||||
FederationSqlErrorCode.QUERY_CANCELLED,
|
||||
failure.errorCode()
|
||||
);
|
||||
Assert.assertTrue(lifecycle.unregistered.get());
|
||||
Assert.assertTrue(resultSetClosed.get());
|
||||
Assert.assertTrue(statementClosed.get());
|
||||
Assert.assertTrue(connectionClosed.get());
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
executor.awaitTermination(2, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
private static FederationSqlException expectFailure(
|
||||
FederationFragmentExecutionContext context
|
||||
) {
|
||||
try {
|
||||
new JdbcFederationFragmentExecutor().execute(context);
|
||||
Assert.fail("expected connection acquisition to fail");
|
||||
return null;
|
||||
} catch (FederationSqlException exception) {
|
||||
return exception;
|
||||
}
|
||||
}
|
||||
|
||||
private static FederationFragmentExecutionContext context(
|
||||
FederationExecutionGuard guard
|
||||
) {
|
||||
return context(guard, new FailingDataSource(), new TerminalLifecycle(false, false));
|
||||
}
|
||||
|
||||
private static FederationFragmentExecutionContext context(
|
||||
FederationExecutionGuard guard,
|
||||
DataSource dataSource,
|
||||
StatementLifecycle lifecycle
|
||||
) {
|
||||
return new FederationFragmentExecutionContext(
|
||||
QueryId.create(),
|
||||
"SELECT 1",
|
||||
List.of(),
|
||||
SqlExecutionOptions.defaults(),
|
||||
dataSource,
|
||||
new AdapterCompatibility(
|
||||
AdapterCompatibilityStatus.VERIFIED,
|
||||
"test",
|
||||
"1",
|
||||
"test",
|
||||
"1",
|
||||
"test"
|
||||
),
|
||||
Map.of(),
|
||||
lifecycle,
|
||||
null,
|
||||
guard
|
||||
);
|
||||
}
|
||||
|
||||
private static DataSource executionTimeoutDataSource(
|
||||
AtomicBoolean statementClosed,
|
||||
AtomicBoolean connectionClosed
|
||||
) {
|
||||
PreparedStatement statement = (PreparedStatement) Proxy.newProxyInstance(
|
||||
JdbcFederationFragmentExecutorTest.class.getClassLoader(),
|
||||
new Class<?>[] {PreparedStatement.class},
|
||||
(proxy, method, arguments) -> {
|
||||
if ("executeQuery".equals(method.getName())) {
|
||||
throw new SQLTimeoutException("driver reported timeout after cancel");
|
||||
}
|
||||
if ("close".equals(method.getName())) {
|
||||
statementClosed.set(true);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
Connection connection = (Connection) Proxy.newProxyInstance(
|
||||
JdbcFederationFragmentExecutorTest.class.getClassLoader(),
|
||||
new Class<?>[] {Connection.class},
|
||||
(proxy, method, arguments) -> {
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
return statement;
|
||||
}
|
||||
if ("close".equals(method.getName())) {
|
||||
connectionClosed.set(true);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
return dataSource(connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建在 ResultSet.next 中等待 Statement.cancel 的 JDBC 代理。
|
||||
*
|
||||
* @param readStarted 结果读取已开始信号
|
||||
* @param cancelSignal Statement 已取消信号
|
||||
* @param resultSetClosed ResultSet 关闭标记
|
||||
* @param statementClosed Statement 关闭标记
|
||||
* @param connectionClosed Connection 关闭标记
|
||||
* @return 可执行阻塞读取的 DataSource
|
||||
*/
|
||||
private static DataSource blockingReadDataSource(
|
||||
CountDownLatch readStarted,
|
||||
CountDownLatch cancelSignal,
|
||||
AtomicBoolean resultSetClosed,
|
||||
AtomicBoolean statementClosed,
|
||||
AtomicBoolean connectionClosed
|
||||
) {
|
||||
Object metadata = Proxy.newProxyInstance(
|
||||
JdbcFederationFragmentExecutorTest.class.getClassLoader(),
|
||||
new Class<?>[] {java.sql.ResultSetMetaData.class},
|
||||
(proxy, method, arguments) -> defaultValue(method.getReturnType())
|
||||
);
|
||||
ResultSet resultSet = (ResultSet) Proxy.newProxyInstance(
|
||||
JdbcFederationFragmentExecutorTest.class.getClassLoader(),
|
||||
new Class<?>[] {ResultSet.class},
|
||||
(proxy, method, arguments) -> {
|
||||
if ("getMetaData".equals(method.getName())) {
|
||||
return metadata;
|
||||
}
|
||||
if ("next".equals(method.getName())) {
|
||||
readStarted.countDown();
|
||||
try {
|
||||
if (!cancelSignal.await(2, TimeUnit.SECONDS)) {
|
||||
throw new SQLException("test cancellation did not arrive");
|
||||
}
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new SQLException("blocking read was interrupted", exception);
|
||||
}
|
||||
throw new SQLException("driver read cancelled");
|
||||
}
|
||||
if ("close".equals(method.getName())) {
|
||||
resultSetClosed.set(true);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
PreparedStatement statement = (PreparedStatement) Proxy.newProxyInstance(
|
||||
JdbcFederationFragmentExecutorTest.class.getClassLoader(),
|
||||
new Class<?>[] {PreparedStatement.class},
|
||||
(proxy, method, arguments) -> {
|
||||
if ("executeQuery".equals(method.getName())) {
|
||||
return resultSet;
|
||||
}
|
||||
if ("cancel".equals(method.getName())) {
|
||||
cancelSignal.countDown();
|
||||
}
|
||||
if ("close".equals(method.getName())) {
|
||||
statementClosed.set(true);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
Connection connection = (Connection) Proxy.newProxyInstance(
|
||||
JdbcFederationFragmentExecutorTest.class.getClassLoader(),
|
||||
new Class<?>[] {Connection.class},
|
||||
(proxy, method, arguments) -> {
|
||||
if ("isReadOnly".equals(method.getName())) {
|
||||
return true;
|
||||
}
|
||||
if ("prepareStatement".equals(method.getName())) {
|
||||
return statement;
|
||||
}
|
||||
if ("close".equals(method.getName())) {
|
||||
connectionClosed.set(true);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
return dataSource(connection);
|
||||
}
|
||||
|
||||
private static JdbcFederationResultCursor failingCursor(
|
||||
StatementLifecycle lifecycle,
|
||||
SQLException readFailure
|
||||
) {
|
||||
ResultSet resultSet = (ResultSet) Proxy.newProxyInstance(
|
||||
JdbcFederationFragmentExecutorTest.class.getClassLoader(),
|
||||
new Class<?>[] {ResultSet.class},
|
||||
(proxy, method, arguments) -> {
|
||||
if ("next".equals(method.getName())) {
|
||||
throw readFailure;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
PreparedStatement statement = (PreparedStatement) Proxy.newProxyInstance(
|
||||
JdbcFederationFragmentExecutorTest.class.getClassLoader(),
|
||||
new Class<?>[] {PreparedStatement.class},
|
||||
(proxy, method, arguments) -> defaultValue(method.getReturnType())
|
||||
);
|
||||
Connection connection = (Connection) Proxy.newProxyInstance(
|
||||
JdbcFederationFragmentExecutorTest.class.getClassLoader(),
|
||||
new Class<?>[] {Connection.class},
|
||||
(proxy, method, arguments) -> defaultValue(method.getReturnType())
|
||||
);
|
||||
return new JdbcFederationResultCursor(
|
||||
QueryId.create(),
|
||||
List.of(),
|
||||
resultSet,
|
||||
statement,
|
||||
connection,
|
||||
lifecycle
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可返回二进制流和字符流的测试游标。
|
||||
*
|
||||
* @param lifecycle Statement 生命周期
|
||||
* @param guard 查询终态检查器
|
||||
* @return 测试游标
|
||||
*/
|
||||
private static JdbcFederationResultCursor streamingCursor(
|
||||
StatementLifecycle lifecycle,
|
||||
FederationExecutionGuard guard
|
||||
) {
|
||||
ResultSet resultSet = (ResultSet) Proxy.newProxyInstance(
|
||||
JdbcFederationFragmentExecutorTest.class.getClassLoader(),
|
||||
new Class<?>[] {ResultSet.class},
|
||||
(proxy, method, arguments) -> {
|
||||
if ("getBinaryStream".equals(method.getName())) {
|
||||
return new ByteArrayInputStream(new byte[] {1, 2, 3});
|
||||
}
|
||||
if ("getCharacterStream".equals(method.getName())) {
|
||||
return new StringReader("streamed value");
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
PreparedStatement statement = (PreparedStatement) Proxy.newProxyInstance(
|
||||
JdbcFederationFragmentExecutorTest.class.getClassLoader(),
|
||||
new Class<?>[] {PreparedStatement.class},
|
||||
(proxy, method, arguments) -> defaultValue(method.getReturnType())
|
||||
);
|
||||
Connection connection = (Connection) Proxy.newProxyInstance(
|
||||
JdbcFederationFragmentExecutorTest.class.getClassLoader(),
|
||||
new Class<?>[] {Connection.class},
|
||||
(proxy, method, arguments) -> defaultValue(method.getReturnType())
|
||||
);
|
||||
return new JdbcFederationResultCursor(
|
||||
QueryId.create(),
|
||||
List.of(),
|
||||
resultSet,
|
||||
statement,
|
||||
connection,
|
||||
lifecycle,
|
||||
guard,
|
||||
FederationExecutionObserver.none()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建由布尔终态驱动的取消检查器。
|
||||
*
|
||||
* @param cancelled 是否已取消
|
||||
* @return 取消检查器
|
||||
*/
|
||||
private static FederationExecutionGuard cancellationGuard(AtomicBoolean cancelled) {
|
||||
return terminalGuard(
|
||||
cancelled,
|
||||
FederationSqlErrorCode.QUERY_CANCELLED,
|
||||
"query was cancelled"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建由布尔终态驱动的超时检查器。
|
||||
*
|
||||
* @param timedOut 是否已超时
|
||||
* @return 超时检查器
|
||||
*/
|
||||
private static FederationExecutionGuard timeoutGuard(AtomicBoolean timedOut) {
|
||||
return terminalGuard(
|
||||
timedOut,
|
||||
FederationSqlErrorCode.QUERY_TIMEOUT,
|
||||
"query deadline reached"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建固定错误语义的查询终态检查器。
|
||||
*
|
||||
* @param terminal 是否进入终态
|
||||
* @param errorCode 终态错误码
|
||||
* @param message 错误消息
|
||||
* @return 查询终态检查器
|
||||
*/
|
||||
private static FederationExecutionGuard terminalGuard(
|
||||
AtomicBoolean terminal,
|
||||
FederationSqlErrorCode errorCode,
|
||||
String message
|
||||
) {
|
||||
return new FederationExecutionGuard() {
|
||||
@Override
|
||||
public void ensureAllowed() {
|
||||
if (terminal.get()) {
|
||||
throw new FederationSqlException(errorCode, message);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long remainingNanos() {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取二进制流并捕获预期的统一异常。
|
||||
*
|
||||
* @param stream 测试流
|
||||
* @return 捕获的统一异常
|
||||
* @throws Exception 非预期读取错误
|
||||
*/
|
||||
private static FederationSqlException expectStreamFailure(InputStream stream)
|
||||
throws Exception {
|
||||
try {
|
||||
stream.read();
|
||||
Assert.fail("expected binary stream read to fail");
|
||||
return null;
|
||||
} catch (FederationSqlException exception) {
|
||||
return exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取字符流并捕获预期的统一异常。
|
||||
*
|
||||
* @param reader 测试 Reader
|
||||
* @return 捕获的统一异常
|
||||
* @throws Exception 非预期读取错误
|
||||
*/
|
||||
private static FederationSqlException expectStreamFailure(Reader reader)
|
||||
throws Exception {
|
||||
try {
|
||||
reader.read();
|
||||
Assert.fail("expected character stream read to fail");
|
||||
return null;
|
||||
} catch (FederationSqlException exception) {
|
||||
return exception;
|
||||
}
|
||||
}
|
||||
|
||||
private static FederationSqlException expectCursorFailure(
|
||||
JdbcFederationResultCursor cursor
|
||||
) {
|
||||
try {
|
||||
cursor.next();
|
||||
Assert.fail("expected result read to fail");
|
||||
return null;
|
||||
} catch (FederationSqlException exception) {
|
||||
return exception;
|
||||
}
|
||||
}
|
||||
|
||||
private static DataSource dataSource(Connection connection) {
|
||||
return (DataSource) Proxy.newProxyInstance(
|
||||
JdbcFederationFragmentExecutorTest.class.getClassLoader(),
|
||||
new Class<?>[] {DataSource.class},
|
||||
(proxy, method, arguments) -> {
|
||||
if ("getConnection".equals(method.getName())) {
|
||||
return connection;
|
||||
}
|
||||
if ("getParentLogger".equals(method.getName())) {
|
||||
return Logger.getGlobal();
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (!type.isPrimitive()) {
|
||||
return null;
|
||||
}
|
||||
if (type == boolean.class) {
|
||||
return false;
|
||||
}
|
||||
if (type == byte.class) {
|
||||
return (byte) 0;
|
||||
}
|
||||
if (type == short.class) {
|
||||
return (short) 0;
|
||||
}
|
||||
if (type == int.class) {
|
||||
return 0;
|
||||
}
|
||||
if (type == long.class) {
|
||||
return 0L;
|
||||
}
|
||||
if (type == float.class) {
|
||||
return 0F;
|
||||
}
|
||||
if (type == double.class) {
|
||||
return 0D;
|
||||
}
|
||||
if (type == char.class) {
|
||||
return '\0';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 记录测试所需的查询终态和注销动作。 */
|
||||
private static final class TerminalLifecycle implements StatementLifecycle {
|
||||
|
||||
private final boolean timedOut;
|
||||
private final boolean cancelled;
|
||||
private final AtomicBoolean unregistered = new AtomicBoolean();
|
||||
|
||||
/**
|
||||
* 创建固定终态的生命周期。
|
||||
*
|
||||
* @param timedOut 是否已超时
|
||||
* @param cancelled 是否已取消
|
||||
*/
|
||||
private TerminalLifecycle(boolean timedOut, boolean cancelled) {
|
||||
this.timedOut = timedOut;
|
||||
this.cancelled = cancelled;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public void register(Statement statement) {
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public void unregister(Statement statement) {
|
||||
unregistered.set(true);
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public boolean cancellationRequested() {
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public boolean timeoutRequested() {
|
||||
return timedOut;
|
||||
}
|
||||
}
|
||||
|
||||
/** 可从测试线程触发 Statement.cancel 的生命周期。 */
|
||||
private static final class CancellableLifecycle implements StatementLifecycle {
|
||||
|
||||
private final AtomicReference<Statement> statement = new AtomicReference<>();
|
||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
||||
private final AtomicBoolean unregistered = new AtomicBoolean();
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public void register(Statement candidate) {
|
||||
statement.set(candidate);
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public void unregister(Statement candidate) {
|
||||
statement.compareAndSet(candidate, null);
|
||||
unregistered.set(true);
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public boolean cancellationRequested() {
|
||||
return cancelled.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记查询取消并调用已登记 Statement 的取消入口。
|
||||
*
|
||||
* @throws SQLException JDBC 取消失败
|
||||
*/
|
||||
private void requestCancellation() throws SQLException {
|
||||
cancelled.set(true);
|
||||
Statement active = statement.get();
|
||||
if (active != null) {
|
||||
active.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 始终返回瞬时连接池超时的测试 DataSource。 */
|
||||
private static final class FailingDataSource implements DataSource {
|
||||
|
||||
@Override
|
||||
public Connection getConnection() throws SQLException {
|
||||
throw new SQLTransientConnectionException("pool timeout");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Connection getConnection(String username, String password) throws SQLException {
|
||||
throw new SQLTransientConnectionException("pool timeout");
|
||||
}
|
||||
|
||||
@Override
|
||||
public PrintWriter getLogWriter() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setLogWriter(PrintWriter out) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setLoginTimeout(int seconds) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getLoginTimeout() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Logger getParentLogger() {
|
||||
return Logger.getGlobal();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T unwrap(Class<T> iface) throws SQLException {
|
||||
throw new SQLException("unsupported");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWrapperFor(Class<?> iface) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.easyagents.federation.sql.adapter.jdbc;
|
||||
|
||||
import com.easyagents.federation.sql.adapter.AdapterCompatibility;
|
||||
import com.easyagents.federation.sql.adapter.AdapterCompatibilityStatus;
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import com.easyagents.federation.sql.execute.FederationExecutionGuard;
|
||||
import com.easyagents.federation.sql.execute.FederationFragmentExplainContext;
|
||||
import java.io.PrintWriter;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.logging.Logger;
|
||||
import javax.sql.DataSource;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* JDBC 物理 Explain 的连接获取错误边界测试。
|
||||
*/
|
||||
public class JdbcFederationFragmentExplainerTest {
|
||||
|
||||
/**
|
||||
* 验证连接池以运行时异常拒绝连接时返回精确连接获取失败错误。
|
||||
*/
|
||||
@Test
|
||||
public void shouldMapRuntimeConnectionFailure() {
|
||||
FederationSqlException failure = expectFailure(context(
|
||||
FederationExecutionGuard.none(),
|
||||
new IllegalStateException("pool is closed")
|
||||
));
|
||||
|
||||
Assert.assertEquals(
|
||||
FederationSqlErrorCode.CONNECTION_ACQUISITION_FAILED,
|
||||
failure.errorCode()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证连接池失败返回时,已经到达的统一截止时间优先于连接错误。
|
||||
*/
|
||||
@Test
|
||||
public void shouldPreferDeadlineOverRuntimeConnectionFailure() {
|
||||
AtomicInteger checks = new AtomicInteger();
|
||||
FederationExecutionGuard guard = new FederationExecutionGuard() {
|
||||
@Override
|
||||
public void ensureAllowed() {
|
||||
if (checks.incrementAndGet() > 1) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.QUERY_TIMEOUT,
|
||||
"query deadline reached"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long remainingNanos() {
|
||||
return 1L;
|
||||
}
|
||||
};
|
||||
|
||||
FederationSqlException failure = expectFailure(context(
|
||||
guard,
|
||||
new IllegalStateException("pool is closed")
|
||||
));
|
||||
|
||||
Assert.assertEquals(FederationSqlErrorCode.QUERY_TIMEOUT, failure.errorCode());
|
||||
}
|
||||
|
||||
private static FederationSqlException expectFailure(
|
||||
FederationFragmentExplainContext context
|
||||
) {
|
||||
try {
|
||||
new JdbcFederationFragmentExplainer().explain(context);
|
||||
Assert.fail("physical Explain should fail");
|
||||
return null;
|
||||
} catch (FederationSqlException exception) {
|
||||
return exception;
|
||||
}
|
||||
}
|
||||
|
||||
private static FederationFragmentExplainContext context(
|
||||
FederationExecutionGuard guard,
|
||||
RuntimeException failure
|
||||
) {
|
||||
return new FederationFragmentExplainContext(
|
||||
"SELECT 1",
|
||||
List.of(),
|
||||
failingDataSource(failure),
|
||||
new AdapterCompatibility(
|
||||
AdapterCompatibilityStatus.VERIFIED,
|
||||
"MySQL",
|
||||
"8",
|
||||
"test-driver",
|
||||
"1",
|
||||
"test"
|
||||
),
|
||||
Map.of(),
|
||||
5,
|
||||
guard
|
||||
);
|
||||
}
|
||||
|
||||
private static DataSource failingDataSource(RuntimeException failure) {
|
||||
return new DataSource() {
|
||||
@Override
|
||||
public Connection getConnection() {
|
||||
throw failure;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Connection getConnection(String username, String password) {
|
||||
throw failure;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T unwrap(Class<T> iface) throws SQLException {
|
||||
throw new SQLException("not a wrapper");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWrapperFor(Class<?> iface) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PrintWriter getLogWriter() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setLogWriter(PrintWriter out) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setLoginTimeout(int seconds) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getLoginTimeout() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Logger getParentLogger() {
|
||||
return Logger.getGlobal();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,347 @@
|
||||
package com.easyagents.federation.sql.adapter.jdbc;
|
||||
|
||||
import com.easyagents.federation.sql.adapter.FederationStatisticsCollectionContext;
|
||||
import com.easyagents.federation.sql.api.FederationSqlEngine;
|
||||
import com.easyagents.federation.sql.api.FederationSqlEngines;
|
||||
import com.easyagents.federation.sql.compile.SqlCompileRequest;
|
||||
import com.easyagents.federation.sql.compile.SqlExplainLevel;
|
||||
import com.easyagents.federation.sql.compile.SqlExplainRequest;
|
||||
import com.easyagents.federation.sql.compile.SqlExplainResult;
|
||||
import com.easyagents.federation.sql.federation.FederationExecutionPolicy;
|
||||
import com.easyagents.federation.sql.federation.FederationQueryScopeDefinition;
|
||||
import com.easyagents.federation.sql.federation.FederationSourceBindingDefinition;
|
||||
import com.easyagents.federation.sql.federation.FederationStatisticsSnapshot;
|
||||
import com.easyagents.federation.sql.federation.FederationTableStatistics;
|
||||
import com.easyagents.federation.sql.source.FederationDataSourceHandles;
|
||||
import com.easyagents.federation.sql.source.FederationSourceDefinition;
|
||||
import com.easyagents.federation.sql.source.RuntimeFingerprint;
|
||||
import com.easyagents.federation.sql.source.SourceApplyOptions;
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import com.mysql.cj.jdbc.MysqlDataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.sql.DataSource;
|
||||
import org.postgresql.ds.PGSimpleDataSource;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Assume;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* 本机 MySQL 与 PostgreSQL 的 Adapter 内建统计采集验证。
|
||||
*/
|
||||
public class JdbcFederationStatisticsIntegrationTest {
|
||||
|
||||
private static final Duration STATISTICS_TTL = Duration.ofMinutes(30);
|
||||
private static final SourceId MYSQL_SOURCE = new SourceId("mysql-statistics");
|
||||
private static final SourceId POSTGRESQL_SOURCE = new SourceId(
|
||||
"postgresql-statistics"
|
||||
);
|
||||
|
||||
/**
|
||||
* 验证 MySQL 目录统计可由 JDBC Adapter 自动采集。
|
||||
*
|
||||
* @throws Exception JDBC 连接或目录读取失败
|
||||
*/
|
||||
@Test
|
||||
public void shouldCollectMysqlStatistics() throws Exception {
|
||||
Assume.assumeTrue(Boolean.getBoolean("federation.integration.enabled"));
|
||||
String database = System.getProperty(
|
||||
"federation.mysql.database",
|
||||
"data-sheet"
|
||||
);
|
||||
String url = "jdbc:mysql://"
|
||||
+ System.getProperty("federation.mysql.host", "127.0.0.1")
|
||||
+ ':' + Integer.getInteger("federation.mysql.port", 33306)
|
||||
+ '/' + database
|
||||
+ "?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai";
|
||||
FederationSourceDefinition definition = definition(
|
||||
"mysql-statistics",
|
||||
"MAIN",
|
||||
database,
|
||||
null
|
||||
);
|
||||
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
url,
|
||||
System.getProperty("federation.mysql.username", "root"),
|
||||
System.getProperty("federation.mysql.password", "root")
|
||||
)) {
|
||||
Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> statistics =
|
||||
collect(definition, connection);
|
||||
|
||||
FederationTableStatistics outlet = statistics.get(
|
||||
new FederationStatisticsSnapshot.TableKey(
|
||||
definition.sourceId(),
|
||||
"MAIN",
|
||||
"outlet"
|
||||
)
|
||||
);
|
||||
Assert.assertNotNull(outlet);
|
||||
assertUsable(outlet, "database-catalog:mysql");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 PostgreSQL 目录统计可由 JDBC Adapter 自动采集。
|
||||
*
|
||||
* @throws Exception JDBC 连接或目录读取失败
|
||||
*/
|
||||
@Test
|
||||
public void shouldCollectPostgresqlStatistics() throws Exception {
|
||||
Assume.assumeTrue(Boolean.getBoolean("federation.integration.enabled"));
|
||||
String database = System.getProperty(
|
||||
"federation.pg.database",
|
||||
"harmony_adapter"
|
||||
);
|
||||
String url = "jdbc:postgresql://"
|
||||
+ System.getProperty("federation.pg.host", "127.0.0.1")
|
||||
+ ':' + Integer.getInteger("federation.pg.port", 54329)
|
||||
+ '/' + database;
|
||||
FederationSourceDefinition definition = definition(
|
||||
"postgresql-statistics",
|
||||
"MAIN",
|
||||
database,
|
||||
"public"
|
||||
);
|
||||
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
url,
|
||||
System.getProperty("federation.pg.username", "harmony"),
|
||||
System.getProperty("federation.pg.password", "harmony")
|
||||
)) {
|
||||
Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> statistics =
|
||||
collect(definition, connection);
|
||||
|
||||
Assert.assertFalse(statistics.isEmpty());
|
||||
Assert.assertTrue(statistics.keySet().stream().allMatch(key ->
|
||||
definition.sourceId().equals(key.sourceId())
|
||||
&& "main".equals(key.schema())
|
||||
));
|
||||
statistics.values().forEach(value ->
|
||||
assertUsable(value, "database-catalog:postgresql")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Engine 默认启用 Adapter 统计,并将结果交给 Explain 成本估算。
|
||||
*/
|
||||
@Test
|
||||
public void shouldExposeAutomaticallyCollectedStatisticsThroughExplain() {
|
||||
Assume.assumeTrue(Boolean.getBoolean("federation.integration.enabled"));
|
||||
String mysqlDatabase = System.getProperty(
|
||||
"federation.mysql.database",
|
||||
"data-sheet"
|
||||
);
|
||||
String postgresqlDatabase = System.getProperty(
|
||||
"federation.pg.database",
|
||||
"harmony_adapter"
|
||||
);
|
||||
FederationSourceDefinition mysqlDefinition = definition(
|
||||
MYSQL_SOURCE.value(),
|
||||
"main",
|
||||
mysqlDatabase,
|
||||
null
|
||||
);
|
||||
FederationSourceDefinition postgresqlDefinition = definition(
|
||||
POSTGRESQL_SOURCE.value(),
|
||||
"main",
|
||||
postgresqlDatabase,
|
||||
"public"
|
||||
);
|
||||
Map<SourceId, DataSource> dataSources = Map.of(
|
||||
MYSQL_SOURCE,
|
||||
mysqlDataSource(mysqlDatabase),
|
||||
POSTGRESQL_SOURCE,
|
||||
postgresqlDataSource(postgresqlDatabase)
|
||||
);
|
||||
|
||||
try (FederationSqlEngine engine = FederationSqlEngines.builder()
|
||||
.dataSourceResolver(definition -> FederationDataSourceHandles.shared(
|
||||
dataSources.get(definition.sourceId()),
|
||||
new RuntimeFingerprint("test", "1", "jdbc", "1", "1")
|
||||
))
|
||||
.build()) {
|
||||
engine.sources().apply(mysqlDefinition, SourceApplyOptions.prewarmNow());
|
||||
engine.sources().apply(
|
||||
postgresqlDefinition,
|
||||
SourceApplyOptions.prewarmNow()
|
||||
);
|
||||
FederationQueryScopeDefinition scope = FederationQueryScopeDefinition.virtual(
|
||||
"automatic-statistics",
|
||||
1,
|
||||
Map.of(
|
||||
"mysql",
|
||||
FederationSourceBindingDefinition.of(MYSQL_SOURCE, 1),
|
||||
"pg",
|
||||
FederationSourceBindingDefinition.of(POSTGRESQL_SOURCE, 1)
|
||||
),
|
||||
"mysql",
|
||||
FederationExecutionPolicy.basic()
|
||||
);
|
||||
|
||||
SqlExplainResult mysqlExplain = explain(
|
||||
engine,
|
||||
scope,
|
||||
"SELECT * FROM mysql.main.outlet"
|
||||
);
|
||||
SqlExplainResult postgresqlExplain = explain(
|
||||
engine,
|
||||
scope,
|
||||
"SELECT * FROM pg.main.artifact"
|
||||
);
|
||||
|
||||
assertExplainStatistics(mysqlExplain, "database-catalog:mysql");
|
||||
assertExplainStatistics(
|
||||
postgresqlExplain,
|
||||
"database-catalog:postgresql"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Adapter 统计采集器读取当前连接。
|
||||
*
|
||||
* @param definition 物理源定义
|
||||
* @param connection JDBC 连接
|
||||
* @return 不可变表统计
|
||||
* @throws Exception 目录读取失败
|
||||
*/
|
||||
private Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> collect(
|
||||
FederationSourceDefinition definition,
|
||||
Connection connection
|
||||
) throws Exception {
|
||||
Instant collectedAt = Instant.now();
|
||||
return new JdbcFederationStatisticsCollector().collect(
|
||||
new FederationStatisticsCollectionContext(
|
||||
definition,
|
||||
connection,
|
||||
collectedAt,
|
||||
collectedAt.plus(STATISTICS_TTL),
|
||||
5
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单 Schema JDBC 数据源定义。
|
||||
*
|
||||
* @param sourceId 物理源标识
|
||||
* @param logicalSchema 逻辑 Schema
|
||||
* @param catalog 物理 Catalog
|
||||
* @param physicalSchema 物理 Schema
|
||||
* @return 数据源定义
|
||||
*/
|
||||
private FederationSourceDefinition definition(
|
||||
String sourceId,
|
||||
String logicalSchema,
|
||||
String catalog,
|
||||
String physicalSchema
|
||||
) {
|
||||
return new FederationSourceDefinition(
|
||||
new SourceId(sourceId),
|
||||
1,
|
||||
JdbcFederationSqlAdapterProvider.ADAPTER_ID,
|
||||
List.of(new JdbcSchemaDefinition(
|
||||
logicalSchema,
|
||||
catalog,
|
||||
physicalSchema
|
||||
)),
|
||||
Map.of()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建本机 MySQL 测试 DataSource。
|
||||
*
|
||||
* @param database 数据库名称
|
||||
* @return MySQL DataSource
|
||||
*/
|
||||
private DataSource mysqlDataSource(String database) {
|
||||
MysqlDataSource dataSource = new MysqlDataSource();
|
||||
dataSource.setUrl(
|
||||
"jdbc:mysql://"
|
||||
+ System.getProperty("federation.mysql.host", "127.0.0.1")
|
||||
+ ':' + Integer.getInteger("federation.mysql.port", 33306)
|
||||
+ '/' + database
|
||||
+ "?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai"
|
||||
);
|
||||
dataSource.setUser(System.getProperty("federation.mysql.username", "root"));
|
||||
dataSource.setPassword(System.getProperty("federation.mysql.password", "root"));
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建本机 PostgreSQL 测试 DataSource。
|
||||
*
|
||||
* @param database 数据库名称
|
||||
* @return PostgreSQL DataSource
|
||||
*/
|
||||
private DataSource postgresqlDataSource(String database) {
|
||||
PGSimpleDataSource dataSource = new PGSimpleDataSource();
|
||||
dataSource.setServerNames(new String[]{
|
||||
System.getProperty("federation.pg.host", "127.0.0.1")
|
||||
});
|
||||
dataSource.setPortNumbers(new int[]{
|
||||
Integer.getInteger("federation.pg.port", 54329)
|
||||
});
|
||||
dataSource.setDatabaseName(database);
|
||||
dataSource.setUser(System.getProperty("federation.pg.username", "harmony"));
|
||||
dataSource.setPassword(System.getProperty("federation.pg.password", "harmony"));
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行逻辑 Explain。
|
||||
*
|
||||
* @param engine 联邦 SQL Engine
|
||||
* @param scope 查询范围
|
||||
* @param sql SQL
|
||||
* @return Explain 结果
|
||||
*/
|
||||
private SqlExplainResult explain(
|
||||
FederationSqlEngine engine,
|
||||
FederationQueryScopeDefinition scope,
|
||||
String sql
|
||||
) {
|
||||
return engine.explain(new SqlExplainRequest(
|
||||
SqlCompileRequest.of(sql, scope),
|
||||
SqlExplainLevel.LOGICAL
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言 Explain 已使用自动采集的数据库统计。
|
||||
*
|
||||
* @param explain Explain 结果
|
||||
* @param source 预期统计来源
|
||||
*/
|
||||
private void assertExplainStatistics(SqlExplainResult explain, String source) {
|
||||
Assert.assertEquals(1, explain.fragments().size());
|
||||
Assert.assertFalse(explain.fragments().get(0).costEstimate().statisticsMissing());
|
||||
Assert.assertTrue(
|
||||
explain.fragments().get(0).costEstimate().statisticsSource().contains(source)
|
||||
);
|
||||
Assert.assertTrue(
|
||||
explain.fragments().get(0).costEstimate().estimatedRowWidthBytes() > 0L
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言采集结果包含优化器可使用的基础统计。
|
||||
*
|
||||
* @param statistics 表统计
|
||||
* @param source 预期统计来源
|
||||
*/
|
||||
private void assertUsable(FederationTableStatistics statistics, String source) {
|
||||
Assert.assertTrue(statistics.estimatedRows() >= 0D);
|
||||
Assert.assertTrue(statistics.averageRowWidthBytes() > 0L);
|
||||
Assert.assertEquals(source, statistics.source());
|
||||
Assert.assertNotNull(statistics.collectedAt());
|
||||
Assert.assertTrue(statistics.expiresAt().isAfter(statistics.collectedAt()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.easyagents.federation.sql.adapter.jdbc;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.sql.DataSource;
|
||||
import org.apache.calcite.adapter.jdbc.JdbcSchema;
|
||||
import org.apache.calcite.adapter.jdbc.JdbcTable;
|
||||
import org.apache.calcite.jdbc.JavaTypeFactoryImpl;
|
||||
import org.apache.calcite.rel.type.RelDataType;
|
||||
import org.apache.calcite.rel.type.RelDataTypeFactory;
|
||||
import org.apache.calcite.schema.Schema;
|
||||
import org.apache.calcite.schema.Table;
|
||||
import org.apache.calcite.schema.Wrapper;
|
||||
import org.apache.calcite.schema.impl.AbstractSchema;
|
||||
import org.apache.calcite.schema.impl.AbstractTable;
|
||||
import org.apache.calcite.sql.dialect.MysqlSqlDialect;
|
||||
import org.apache.calcite.sql.type.SqlTypeName;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* MySQL 表名与列名大小写语义包装器测试。
|
||||
*/
|
||||
public class MysqlCaseInsensitiveColumnSchemaTest {
|
||||
|
||||
/**
|
||||
* 验证 JDBC 元数据 LIKE 模式不会把下划线表名解析到近似表。
|
||||
*/
|
||||
@Test
|
||||
public void shouldEscapeJdbcMetadataPatternAndRequireExactPhysicalTable() {
|
||||
DataSource dataSource = metadataDataSource();
|
||||
JdbcSchema jdbcSchema = new JdbcSchema(
|
||||
dataSource,
|
||||
MysqlSqlDialect.DEFAULT,
|
||||
null,
|
||||
"app",
|
||||
null
|
||||
);
|
||||
JdbcTable rawTable = ((Wrapper) jdbcSchema.tables().get("order_item"))
|
||||
.unwrap(JdbcTable.class);
|
||||
Assert.assertEquals("order0item", rawTable.jdbcTableName);
|
||||
|
||||
Schema schema = new MysqlCaseInsensitiveColumnSchema(jdbcSchema);
|
||||
JdbcTable exactTable = ((Wrapper) schema.getTable("order_item"))
|
||||
.unwrap(JdbcTable.class);
|
||||
Assert.assertEquals("order_item", exactTable.jdbcTableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证大小写不同的表保持独立,同时列名可忽略大小写查找。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepExactTableNamesAndMatchColumnsIgnoringCase() {
|
||||
Schema schema = new MysqlCaseInsensitiveColumnSchema(new AbstractSchema() {
|
||||
@Override
|
||||
protected Map<String, Table> getTableMap() {
|
||||
return Map.of(
|
||||
"orders", table("id"),
|
||||
"Orders", table("different_column")
|
||||
);
|
||||
}
|
||||
});
|
||||
RelDataTypeFactory typeFactory = new JavaTypeFactoryImpl();
|
||||
|
||||
Table lowerCaseTable = schema.getTable("orders");
|
||||
Table upperCaseTable = schema.getTable("Orders");
|
||||
Assert.assertNotNull(lowerCaseTable);
|
||||
Assert.assertNotNull(upperCaseTable);
|
||||
Assert.assertNull(schema.getTable("ORDERS"));
|
||||
|
||||
RelDataType lowerCaseRow = lowerCaseTable.getRowType(typeFactory);
|
||||
RelDataType upperCaseRow = upperCaseTable.getRowType(typeFactory);
|
||||
Assert.assertNotNull(lowerCaseRow.getField("ID", true, false));
|
||||
Assert.assertEquals("id", lowerCaseRow.getField("ID", true, false).getName());
|
||||
Assert.assertNotNull(upperCaseRow.getField("DIFFERENT_COLUMN", true, false));
|
||||
Assert.assertNull(upperCaseRow.getField("ID", true, false));
|
||||
}
|
||||
|
||||
private static Table table(String columnName) {
|
||||
return new AbstractTable() {
|
||||
@Override
|
||||
public RelDataType getRowType(RelDataTypeFactory typeFactory) {
|
||||
return typeFactory.builder()
|
||||
.add(columnName, SqlTypeName.INTEGER)
|
||||
.build();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static DataSource metadataDataSource() {
|
||||
DatabaseMetaData metadata = (DatabaseMetaData) Proxy.newProxyInstance(
|
||||
MysqlCaseInsensitiveColumnSchemaTest.class.getClassLoader(),
|
||||
new Class<?>[] {DatabaseMetaData.class},
|
||||
(proxy, method, arguments) -> switch (method.getName()) {
|
||||
case "getSearchStringEscape" -> "\\";
|
||||
case "getJDBCMajorVersion" -> 4;
|
||||
case "getJDBCMinorVersion" -> 2;
|
||||
case "getDatabaseProductName" -> "MySQL";
|
||||
case "getTables" -> tableResultSet((String) arguments[2]);
|
||||
default -> defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
Connection connection = (Connection) Proxy.newProxyInstance(
|
||||
MysqlCaseInsensitiveColumnSchemaTest.class.getClassLoader(),
|
||||
new Class<?>[] {Connection.class},
|
||||
(proxy, method, arguments) -> switch (method.getName()) {
|
||||
case "getMetaData" -> metadata;
|
||||
case "getCatalog" -> "app";
|
||||
case "getSchema" -> null;
|
||||
case "close" -> null;
|
||||
case "isClosed" -> false;
|
||||
default -> defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
return (DataSource) Proxy.newProxyInstance(
|
||||
MysqlCaseInsensitiveColumnSchemaTest.class.getClassLoader(),
|
||||
new Class<?>[] {DataSource.class},
|
||||
(proxy, method, arguments) -> switch (method.getName()) {
|
||||
case "getConnection" -> connection;
|
||||
default -> defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private static ResultSet tableResultSet(String pattern) {
|
||||
List<String> tableNames = switch (pattern) {
|
||||
case "order_item" -> List.of("order0item", "order_item");
|
||||
case "order\\_item" -> List.of("order_item");
|
||||
case "%" -> List.of("order0item", "order_item");
|
||||
default -> List.of();
|
||||
};
|
||||
int[] cursor = {-1};
|
||||
return (ResultSet) Proxy.newProxyInstance(
|
||||
MysqlCaseInsensitiveColumnSchemaTest.class.getClassLoader(),
|
||||
new Class<?>[] {ResultSet.class},
|
||||
(proxy, method, arguments) -> switch (method.getName()) {
|
||||
case "next" -> ++cursor[0] < tableNames.size();
|
||||
case "getString" -> switch ((Integer) arguments[0]) {
|
||||
case 1 -> "app";
|
||||
case 2 -> null;
|
||||
case 3 -> tableNames.get(cursor[0]);
|
||||
case 4 -> "TABLE";
|
||||
default -> null;
|
||||
};
|
||||
case "close" -> null;
|
||||
default -> defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (!type.isPrimitive()) {
|
||||
return null;
|
||||
}
|
||||
if (type == boolean.class) {
|
||||
return false;
|
||||
}
|
||||
if (type == int.class) {
|
||||
return 0;
|
||||
}
|
||||
if (type == long.class) {
|
||||
return 0L;
|
||||
}
|
||||
if (type == short.class) {
|
||||
return (short) 0;
|
||||
}
|
||||
if (type == byte.class) {
|
||||
return (byte) 0;
|
||||
}
|
||||
if (type == float.class) {
|
||||
return 0F;
|
||||
}
|
||||
if (type == double.class) {
|
||||
return 0D;
|
||||
}
|
||||
if (type == char.class) {
|
||||
return '\0';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-federation-sql</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>easy-agents-federation-sql-core</artifactId>
|
||||
<name>easy-agents-federation-sql-core</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.apache.calcite</groupId>
|
||||
<artifactId>calcite-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.easyagents.federation.sql.adapter;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Adapter 对当前数据库与驱动的兼容性说明。
|
||||
*
|
||||
* @param status 兼容性状态
|
||||
* @param databaseProduct 数据库产品
|
||||
* @param databaseVersion 数据库版本
|
||||
* @param driverName 驱动名称
|
||||
* @param driverVersion 驱动版本
|
||||
* @param diagnostic 不含敏感信息的说明
|
||||
*/
|
||||
public record AdapterCompatibility(
|
||||
AdapterCompatibilityStatus status,
|
||||
String databaseProduct,
|
||||
String databaseVersion,
|
||||
String driverName,
|
||||
String driverVersion,
|
||||
String diagnostic
|
||||
) implements Serializable {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.easyagents.federation.sql.adapter;
|
||||
|
||||
/**
|
||||
* 数据库 Adapter 兼容性证据状态。
|
||||
*/
|
||||
public enum AdapterCompatibilityStatus {
|
||||
/** 已通过目标数据库真实集成验证。 */
|
||||
VERIFIED,
|
||||
/** 代码与契约已支持,缺少目标环境验证。 */
|
||||
CODE_SUPPORTED_UNVERIFIED,
|
||||
/** 当前 Adapter 明确不支持。 */
|
||||
UNSUPPORTED
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.easyagents.federation.sql.adapter;
|
||||
|
||||
import com.easyagents.federation.sql.source.FederationSourceDefinition;
|
||||
import java.sql.DatabaseMetaData;
|
||||
|
||||
/**
|
||||
* Adapter 选择数据库方言的上下文。
|
||||
*
|
||||
* @param metadata JDBC 元数据
|
||||
* @param sourceDefinition 数据源定义
|
||||
*/
|
||||
public record AdapterDialectContext(
|
||||
DatabaseMetaData metadata,
|
||||
FederationSourceDefinition sourceDefinition
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.easyagents.federation.sql.adapter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Adapter 探测与编译的非敏感提示。
|
||||
*
|
||||
* @param options Definition 中的 Adapter 选项
|
||||
*/
|
||||
public record AdapterHints(Map<String, String> options) {
|
||||
|
||||
/**
|
||||
* 防御性复制提示选项。
|
||||
*/
|
||||
public AdapterHints {
|
||||
options = Map.copyOf(options == null ? Map.of() : options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断指定布尔选项是否开启。
|
||||
*
|
||||
* @param key 选项名
|
||||
* @return 是否开启
|
||||
*/
|
||||
public boolean enabled(String key) {
|
||||
return Boolean.parseBoolean(options.getOrDefault(key, "false"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.easyagents.federation.sql.adapter;
|
||||
|
||||
import com.easyagents.federation.sql.source.FederationDataSourceHandle;
|
||||
import com.easyagents.federation.sql.source.FederationSchemaDefinition;
|
||||
import com.easyagents.federation.sql.source.FederationSourceDefinition;
|
||||
import org.apache.calcite.schema.SchemaPlus;
|
||||
import org.apache.calcite.sql.SqlDialect;
|
||||
|
||||
/**
|
||||
* Adapter 创建 Calcite Schema 时所需的节点本地上下文。
|
||||
*
|
||||
* @param parentSchema Calcite 父 Schema
|
||||
* @param sourceDefinition 数据源定义
|
||||
* @param schemaDefinition 当前 Schema 定义
|
||||
* @param handle DataSource 句柄
|
||||
* @param dialect 已探测方言
|
||||
*/
|
||||
public record AdapterSchemaContext(
|
||||
SchemaPlus parentSchema,
|
||||
FederationSourceDefinition sourceDefinition,
|
||||
FederationSchemaDefinition schemaDefinition,
|
||||
FederationDataSourceHandle handle,
|
||||
SqlDialect dialect
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.easyagents.federation.sql.adapter;
|
||||
|
||||
import com.easyagents.federation.sql.execute.FederationFragmentExecutor;
|
||||
import com.easyagents.federation.sql.execute.FederationFragmentExplainer;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.apache.calcite.plan.RelOptRule;
|
||||
import org.apache.calcite.rel.type.RelDataTypeSystem;
|
||||
import org.apache.calcite.schema.Schema;
|
||||
import org.apache.calcite.sql.SqlBasicTypeNameSpec;
|
||||
import org.apache.calcite.sql.SqlDataTypeSpec;
|
||||
import org.apache.calcite.sql.SqlDialect;
|
||||
import org.apache.calcite.sql.SqlOperatorTable;
|
||||
import org.apache.calcite.sql.fun.SqlStdOperatorTable;
|
||||
import org.apache.calcite.sql.parser.SqlParserPos;
|
||||
import org.apache.calcite.sql.parser.SqlParser;
|
||||
import org.apache.calcite.sql.type.SqlTypeName;
|
||||
|
||||
/**
|
||||
* 直接扩展 Calcite Schema、Dialect、类型和规则的数据库 Adapter SPI。
|
||||
*/
|
||||
public interface FederationSqlAdapterProvider {
|
||||
|
||||
/**
|
||||
* 返回全局唯一 Adapter 标识。
|
||||
*
|
||||
* @return Adapter 标识
|
||||
*/
|
||||
String adapterId();
|
||||
|
||||
/**
|
||||
* 判断当前数据库与驱动是否受支持。
|
||||
*
|
||||
* @param metadata JDBC 元数据
|
||||
* @param hints 非敏感提示
|
||||
* @return 是否受支持
|
||||
* @throws SQLException 元数据读取失败
|
||||
*/
|
||||
boolean supports(DatabaseMetaData metadata, AdapterHints hints) throws SQLException;
|
||||
|
||||
/**
|
||||
* 返回当前数据库的兼容性证据状态。
|
||||
*
|
||||
* @param metadata JDBC 元数据
|
||||
* @param hints 非敏感提示
|
||||
* @return 兼容性说明
|
||||
* @throws SQLException 元数据读取失败
|
||||
*/
|
||||
AdapterCompatibility compatibility(DatabaseMetaData metadata, AdapterHints hints) throws SQLException;
|
||||
|
||||
/**
|
||||
* 创建当前 Definition 对应的 Calcite Schema。
|
||||
*
|
||||
* @param context Schema 上下文
|
||||
* @return Calcite Schema
|
||||
*/
|
||||
Schema createSchema(AdapterSchemaContext context);
|
||||
|
||||
/**
|
||||
* 选择目标数据库 SqlDialect。
|
||||
*
|
||||
* @param context 方言上下文
|
||||
* @return Calcite SqlDialect
|
||||
* @throws SQLException 元数据读取失败
|
||||
*/
|
||||
SqlDialect createDialect(AdapterDialectContext context) throws SQLException;
|
||||
|
||||
/**
|
||||
* 返回数据库类型系统。
|
||||
*
|
||||
* @return Calcite 类型系统
|
||||
*/
|
||||
default RelDataTypeSystem typeSystem() {
|
||||
return RelDataTypeSystem.DEFAULT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回数据库运算符表。
|
||||
*
|
||||
* @return Calcite 运算符表
|
||||
*/
|
||||
default SqlOperatorTable operatorTable() {
|
||||
return SqlStdOperatorTable.instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 Adapter 附加的 Calcite Planner 规则。
|
||||
*
|
||||
* @return Planner 规则
|
||||
*/
|
||||
default List<RelOptRule> plannerRules() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建保留 ANSI 双引号输入、继承目标方言大小写语义的解析配置。
|
||||
*
|
||||
* <p>数据库若对表名与列名采用不同的大小写规则,可以在 Adapter 中覆盖。</p>
|
||||
*
|
||||
* @param dialect 目标数据库方言
|
||||
* @return Calcite 解析配置
|
||||
*/
|
||||
default SqlParser.Config parserConfig(SqlDialect dialect) {
|
||||
return SqlParser.config()
|
||||
.withQuotedCasing(dialect.getQuotedCasing())
|
||||
.withUnquotedCasing(dialect.getUnquotedCasing())
|
||||
.withCaseSensitive(dialect.isCaseSensitive());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JDBC 参数类型映射为 Calcite 类型声明,供动态参数参与校验和类型推导。
|
||||
*
|
||||
* <p>默认实现补齐 JDBC 4.2 时区类型,并将 {@link Types#OTHER} 解释为 UUID。
|
||||
* 厂商 Adapter 可以覆盖此方法,直接返回带精度、长度或专有类型名的
|
||||
* Calcite 类型声明。</p>
|
||||
*
|
||||
* @param jdbcType {@link java.sql.Types} 类型值
|
||||
* @param parserPosition 动态参数的解析位置
|
||||
* @return Calcite 类型声明;无法映射时返回 null
|
||||
*/
|
||||
default SqlDataTypeSpec parameterTypeSpec(int jdbcType, SqlParserPos parserPosition) {
|
||||
SqlTypeName typeName = switch (jdbcType) {
|
||||
case Types.TIME_WITH_TIMEZONE -> SqlTypeName.TIME_TZ;
|
||||
case Types.TIMESTAMP_WITH_TIMEZONE -> SqlTypeName.TIMESTAMP_TZ;
|
||||
case Types.OTHER -> SqlTypeName.UUID;
|
||||
default -> SqlTypeName.getNameForJdbcType(jdbcType);
|
||||
};
|
||||
if (typeName == null || typeName.isSpecial() || !typeName.allowsNoPrecNoScale()) {
|
||||
return null;
|
||||
}
|
||||
return new SqlDataTypeSpec(
|
||||
new SqlBasicTypeNameSpec(typeName, parserPosition),
|
||||
parserPosition
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回目标数据库 Fragment 执行器。
|
||||
*
|
||||
* @return Fragment 执行器
|
||||
*/
|
||||
FederationFragmentExecutor fragmentExecutor();
|
||||
|
||||
/**
|
||||
* 返回可选的数据库物理 Explain 实现。
|
||||
*
|
||||
* @return 物理 Explain SPI
|
||||
*/
|
||||
default Optional<FederationFragmentExplainer> fragmentExplainer() {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回可选的数据库目录统计采集器。
|
||||
*
|
||||
* <p>统计采集由引擎管理缓存、并发合并、失效和失败降级,Adapter 只负责
|
||||
* 当前数据库的目录语义。</p>
|
||||
*
|
||||
* @return 统计采集 SPI;未适配时为空
|
||||
*/
|
||||
default Optional<FederationStatisticsCollector> statisticsCollector() {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.easyagents.federation.sql.adapter;
|
||||
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.ServiceLoader;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 支持显式注册与 ServiceLoader 的 Adapter 注册表。
|
||||
*/
|
||||
public final class FederationSqlAdapterRegistry {
|
||||
|
||||
private final Map<String, FederationSqlAdapterProvider> providers;
|
||||
|
||||
/**
|
||||
* 创建注册表;显式 Provider 优先于 ServiceLoader Provider。
|
||||
*
|
||||
* @param explicitProviders 显式 Provider
|
||||
* @param classLoader ServiceLoader 使用的类加载器
|
||||
*/
|
||||
public FederationSqlAdapterRegistry(
|
||||
Collection<FederationSqlAdapterProvider> explicitProviders,
|
||||
ClassLoader classLoader
|
||||
) {
|
||||
Map<String, FederationSqlAdapterProvider> loaded = new LinkedHashMap<>();
|
||||
ServiceLoader.load(FederationSqlAdapterProvider.class, classLoader)
|
||||
.forEach(provider -> putUnique(loaded, provider));
|
||||
if (explicitProviders != null) {
|
||||
Set<String> explicitIds = new HashSet<>();
|
||||
for (FederationSqlAdapterProvider provider : explicitProviders) {
|
||||
Objects.requireNonNull(provider, "adapter provider must not be null");
|
||||
if (!explicitIds.add(provider.adapterId())) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.SOURCE_DEFINITION_CONFLICT,
|
||||
"duplicate explicitly registered adapter id: " + provider.adapterId()
|
||||
);
|
||||
}
|
||||
loaded.put(provider.adapterId(), provider);
|
||||
}
|
||||
}
|
||||
this.providers = Map.copyOf(loaded);
|
||||
}
|
||||
|
||||
private static void putUnique(
|
||||
Map<String, FederationSqlAdapterProvider> providers,
|
||||
FederationSqlAdapterProvider provider
|
||||
) {
|
||||
FederationSqlAdapterProvider previous = providers.putIfAbsent(provider.adapterId(), provider);
|
||||
if (previous != null && !previous.getClass().equals(provider.getClass())) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.SOURCE_DEFINITION_CONFLICT,
|
||||
"duplicate adapter id from ServiceLoader: " + provider.adapterId()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找 Adapter Provider。
|
||||
*
|
||||
* @param adapterId Adapter 标识
|
||||
* @return 可选 Provider
|
||||
*/
|
||||
public Optional<FederationSqlAdapterProvider> find(String adapterId) {
|
||||
return Optional.ofNullable(providers.get(adapterId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 Adapter Provider,缺失时抛出稳定错误。
|
||||
*
|
||||
* @param adapterId Adapter 标识
|
||||
* @return Provider
|
||||
*/
|
||||
public FederationSqlAdapterProvider require(String adapterId) {
|
||||
return find(adapterId).orElseThrow(() -> new FederationSqlException(
|
||||
FederationSqlErrorCode.ADAPTER_NOT_FOUND,
|
||||
"adapter is not registered: " + adapterId
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回不可变 Provider 视图。
|
||||
*
|
||||
* @return Provider 映射
|
||||
*/
|
||||
public Map<String, FederationSqlAdapterProvider> providers() {
|
||||
return providers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.easyagents.federation.sql.adapter;
|
||||
|
||||
import com.easyagents.federation.sql.source.FederationSourceDefinition;
|
||||
import java.sql.Connection;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Adapter 采集数据库目录统计时使用的只读上下文。
|
||||
*
|
||||
* @param sourceDefinition 当前物理数据源定义
|
||||
* @param connection 已从运行时连接池借出的 JDBC 连接
|
||||
* @param collectedAt 本轮统计采集时间
|
||||
* @param expiresAt 本轮统计默认失效时间
|
||||
* @param queryTimeoutSeconds 单条目录查询超时秒数
|
||||
*/
|
||||
public record FederationStatisticsCollectionContext(
|
||||
FederationSourceDefinition sourceDefinition,
|
||||
Connection connection,
|
||||
Instant collectedAt,
|
||||
Instant expiresAt,
|
||||
int queryTimeoutSeconds
|
||||
) {
|
||||
|
||||
/**
|
||||
* 校验统计采集上下文。
|
||||
*/
|
||||
public FederationStatisticsCollectionContext {
|
||||
sourceDefinition = Objects.requireNonNull(sourceDefinition, "sourceDefinition");
|
||||
connection = Objects.requireNonNull(connection, "connection");
|
||||
collectedAt = Objects.requireNonNull(collectedAt, "collectedAt");
|
||||
expiresAt = Objects.requireNonNull(expiresAt, "expiresAt");
|
||||
if (!expiresAt.isAfter(collectedAt)) {
|
||||
throw new IllegalArgumentException("expiresAt must be after collectedAt");
|
||||
}
|
||||
if (queryTimeoutSeconds <= 0) {
|
||||
throw new IllegalArgumentException("queryTimeoutSeconds must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.easyagents.federation.sql.adapter;
|
||||
|
||||
import com.easyagents.federation.sql.federation.FederationStatisticsSnapshot;
|
||||
import com.easyagents.federation.sql.federation.FederationTableStatistics;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 数据库 Adapter 提供的轻量目录统计采集 SPI。
|
||||
*
|
||||
* <p>实现应使用数据库系统目录或 JDBC 元数据批量采集,禁止执行逐表
|
||||
* {@code COUNT(*)}。采集异常由引擎统一降级,不应在实现中伪造成功结果。</p>
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FederationStatisticsCollector {
|
||||
|
||||
/**
|
||||
* 采集一个物理数据源当前 revision 的表统计。
|
||||
*
|
||||
* @param context 统计采集上下文
|
||||
* @return 按逻辑 Schema 和物理表索引的不可变统计;不支持时返回空映射
|
||||
* @throws SQLException 数据库目录或 JDBC 元数据读取失败
|
||||
*/
|
||||
Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> collect(
|
||||
FederationStatisticsCollectionContext context
|
||||
) throws SQLException;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.easyagents.federation.sql.api;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 节点本地 JDBC 终止与游标清理通道的累计观测指标。
|
||||
*
|
||||
* @param overflowFallbacks 主清理队列拒绝后转入隔离通道的次数
|
||||
* @param deferredRetries 隔离通道拒绝后进入有界延期重试队列的次数
|
||||
* @param unresolvedCleanups 延期队列溢出或 Engine 有界关闭后仍未完成的清理数
|
||||
* @param deferredQueueDepth 当前等待重试的清理数
|
||||
*/
|
||||
public record FederationCleanupMetrics(
|
||||
long overflowFallbacks,
|
||||
long deferredRetries,
|
||||
long unresolvedCleanups,
|
||||
int deferredQueueDepth
|
||||
) implements Serializable {
|
||||
|
||||
private static final FederationCleanupMetrics EMPTY = new FederationCleanupMetrics(
|
||||
0L,
|
||||
0L,
|
||||
0L,
|
||||
0
|
||||
);
|
||||
|
||||
/**
|
||||
* 返回无清理压力的空指标。
|
||||
*
|
||||
* @return 空指标
|
||||
*/
|
||||
public static FederationCleanupMetrics empty() {
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.easyagents.federation.sql.api;
|
||||
|
||||
import com.easyagents.federation.sql.compile.FederationSqlPlan;
|
||||
import com.easyagents.federation.sql.compile.SqlCompileRequest;
|
||||
import com.easyagents.federation.sql.compile.SqlExplainRequest;
|
||||
import com.easyagents.federation.sql.compile.SqlExplainResult;
|
||||
import com.easyagents.federation.sql.execute.FederationResultCursor;
|
||||
import com.easyagents.federation.sql.execute.QueryId;
|
||||
import com.easyagents.federation.sql.source.FederationSourceManager;
|
||||
|
||||
/**
|
||||
* SQL 编译、补全、查询、Explain、取消和数据源管理的统一公共入口。
|
||||
*/
|
||||
public interface FederationSqlEngine extends AutoCloseable {
|
||||
|
||||
/**
|
||||
* 返回数据源管理入口。
|
||||
*
|
||||
* @return 数据源管理器
|
||||
*/
|
||||
FederationSourceManager sources();
|
||||
|
||||
/**
|
||||
* 编译节点本地计划。
|
||||
*
|
||||
* @param request 编译请求
|
||||
* @return 节点本地计划
|
||||
*/
|
||||
FederationSqlPlan compile(SqlCompileRequest request);
|
||||
|
||||
/**
|
||||
* 执行节点本地计划。
|
||||
*
|
||||
* @param plan 编译计划
|
||||
* @param context 执行上下文
|
||||
* @return 流式游标
|
||||
*/
|
||||
FederationResultCursor execute(FederationSqlPlan plan, SqlExecutionContext context);
|
||||
|
||||
/**
|
||||
* 在当前节点完成编译或缓存命中并立即执行。
|
||||
*
|
||||
* @param command 可跨节点查询命令
|
||||
* @return 流式游标
|
||||
*/
|
||||
FederationResultCursor query(SqlQueryCommand command);
|
||||
|
||||
/**
|
||||
* 返回不含运行对象的 Explain 结果。
|
||||
*
|
||||
* @param request Explain 请求
|
||||
* @return Explain 结果
|
||||
*/
|
||||
SqlExplainResult explain(SqlExplainRequest request);
|
||||
|
||||
/**
|
||||
* 根据当前查询范围返回 Calcite SQL 上下文补全候选。
|
||||
*
|
||||
* @param request 补全请求
|
||||
* @return 替换区间与候选列表
|
||||
*/
|
||||
SqlCompletionResult complete(SqlCompletionRequest request);
|
||||
|
||||
/**
|
||||
* 尝试取消当前节点正在执行的查询。
|
||||
*
|
||||
* @param queryId 查询标识
|
||||
* @return 是否找到并发起取消
|
||||
*/
|
||||
boolean cancel(QueryId queryId);
|
||||
|
||||
/**
|
||||
* 返回节点本地 JDBC 终止与游标清理通道的累计指标。
|
||||
*
|
||||
* <p>自定义 Engine 未提供资源治理指标时返回空快照。</p>
|
||||
*
|
||||
* @return 清理通道指标
|
||||
*/
|
||||
default FederationCleanupMetrics cleanupMetrics() {
|
||||
return FederationCleanupMetrics.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 Engine、订阅、Runtime 和独占句柄。
|
||||
*/
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package com.easyagents.federation.sql.api;
|
||||
|
||||
import com.easyagents.federation.sql.adapter.FederationSqlAdapterProvider;
|
||||
import com.easyagents.federation.sql.adapter.FederationSqlAdapterRegistry;
|
||||
import com.easyagents.federation.sql.compile.FederationSqlPolicy;
|
||||
import com.easyagents.federation.sql.execute.FederationQueryAdmissionController;
|
||||
import com.easyagents.federation.sql.execute.LocalFederationQueryAdmissionController;
|
||||
import com.easyagents.federation.sql.federation.FederationExecutionPolicy;
|
||||
import com.easyagents.federation.sql.federation.FederationTableStatisticsProvider;
|
||||
import com.easyagents.federation.sql.runtime.DefaultFederationSqlEngine;
|
||||
import com.easyagents.federation.sql.runtime.DefaultFederationSourceManager;
|
||||
import com.easyagents.federation.sql.source.FederationDataSourceResolver;
|
||||
import com.easyagents.federation.sql.source.FederationSourceStateProvider;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 使用显式依赖构建独立 FederationSqlEngine 的入口。
|
||||
*/
|
||||
public final class FederationSqlEngines {
|
||||
|
||||
private FederationSqlEngines() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Engine Builder。
|
||||
*
|
||||
* @return Builder
|
||||
*/
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/**
|
||||
* FederationSqlEngine 的轻量配置 Builder。
|
||||
*/
|
||||
public static final class Builder {
|
||||
|
||||
private final List<FederationSqlAdapterProvider> adapters = new ArrayList<>();
|
||||
private final List<FederationSqlPolicy> policies = new ArrayList<>();
|
||||
private FederationDataSourceResolver resolver;
|
||||
private FederationSourceStateProvider stateProvider = FederationSourceStateProvider.none();
|
||||
private FederationQueryAdmissionController admissionController =
|
||||
new LocalFederationQueryAdmissionController(64);
|
||||
private int maximumPlanCacheEntries = 1024;
|
||||
private long maximumPlanCacheWeightBytes = 64L * 1024L * 1024L;
|
||||
private Duration planCacheTimeToLive = Duration.ofMinutes(30);
|
||||
private int maximumConcurrentCompilations = Math.max(
|
||||
1,
|
||||
Math.min(8, Runtime.getRuntime().availableProcessors())
|
||||
);
|
||||
private boolean crossSourceEnabled = true;
|
||||
private FederationExecutionPolicy executionPolicy = FederationExecutionPolicy.basic();
|
||||
private long maximumNodeIntermediateBytes = 512L * 1024L * 1024L;
|
||||
private FederationTableStatisticsProvider statisticsProvider;
|
||||
private ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
|
||||
|
||||
private Builder() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置调用方 DataSource Resolver。
|
||||
*
|
||||
* @param resolver Resolver
|
||||
* @return 当前 Builder
|
||||
*/
|
||||
public Builder dataSourceResolver(FederationDataSourceResolver resolver) {
|
||||
this.resolver = Objects.requireNonNull(resolver, "resolver must not be null");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显式注册 Adapter;同 id 时覆盖 ServiceLoader 实现。
|
||||
*
|
||||
* @param adapter Adapter Provider
|
||||
* @return 当前 Builder
|
||||
*/
|
||||
public Builder adapter(FederationSqlAdapterProvider adapter) {
|
||||
this.adapters.add(Objects.requireNonNull(adapter, "adapter must not be null"));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加 SQL 策略。
|
||||
*
|
||||
* @param policy 策略
|
||||
* @return 当前 Builder
|
||||
*/
|
||||
public Builder policy(FederationSqlPolicy policy) {
|
||||
this.policies.add(Objects.requireNonNull(policy, "policy must not be null"));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置共享状态 Provider。
|
||||
*
|
||||
* @param stateProvider 状态 Provider
|
||||
* @return 当前 Builder
|
||||
*/
|
||||
public Builder stateProvider(FederationSourceStateProvider stateProvider) {
|
||||
this.stateProvider = Objects.requireNonNull(stateProvider, "stateProvider must not be null");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置查询准入控制器。
|
||||
*
|
||||
* @param admissionController 准入控制器
|
||||
* @return 当前 Builder
|
||||
*/
|
||||
public Builder admissionController(FederationQueryAdmissionController admissionController) {
|
||||
this.admissionController = Objects.requireNonNull(
|
||||
admissionController,
|
||||
"admissionController must not be null"
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置计划缓存最大条目数。
|
||||
*
|
||||
* @param maximumPlanCacheEntries 最大条目数
|
||||
* @return 当前 Builder
|
||||
*/
|
||||
public Builder maximumPlanCacheEntries(int maximumPlanCacheEntries) {
|
||||
if (maximumPlanCacheEntries <= 0) {
|
||||
throw new IllegalArgumentException("maximumPlanCacheEntries must be positive");
|
||||
}
|
||||
this.maximumPlanCacheEntries = maximumPlanCacheEntries;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置计划缓存最大估算权重。
|
||||
*
|
||||
* @param maximumPlanCacheWeightBytes 最大估算字节数
|
||||
* @return 当前 Builder
|
||||
*/
|
||||
public Builder maximumPlanCacheWeightBytes(long maximumPlanCacheWeightBytes) {
|
||||
if (maximumPlanCacheWeightBytes <= 0) {
|
||||
throw new IllegalArgumentException("maximumPlanCacheWeightBytes must be positive");
|
||||
}
|
||||
this.maximumPlanCacheWeightBytes = maximumPlanCacheWeightBytes;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置计划缓存条目存活时间。
|
||||
*
|
||||
* @param planCacheTimeToLive 存活时间
|
||||
* @return 当前 Builder
|
||||
*/
|
||||
public Builder planCacheTimeToLive(Duration planCacheTimeToLive) {
|
||||
if (planCacheTimeToLive == null
|
||||
|| planCacheTimeToLive.isZero()
|
||||
|| planCacheTimeToLive.isNegative()) {
|
||||
throw new IllegalArgumentException("planCacheTimeToLive must be positive");
|
||||
}
|
||||
this.planCacheTimeToLive = planCacheTimeToLive;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Calcite 冷编译最大并发数。
|
||||
*
|
||||
* @param maximumConcurrentCompilations 最大并发冷编译数
|
||||
* @return 当前 Builder
|
||||
*/
|
||||
public Builder maximumConcurrentCompilations(int maximumConcurrentCompilations) {
|
||||
if (maximumConcurrentCompilations <= 0) {
|
||||
throw new IllegalArgumentException("maximumConcurrentCompilations must be positive");
|
||||
}
|
||||
this.maximumConcurrentCompilations = maximumConcurrentCompilations;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置联邦执行开关。
|
||||
*
|
||||
* @param enabled 是否开启
|
||||
* @return 当前 Builder
|
||||
*/
|
||||
public Builder crossSourceEnabled(boolean enabled) {
|
||||
this.crossSourceEnabled = enabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Engine 级联邦资源硬上限。
|
||||
*
|
||||
* @param executionPolicy 资源策略
|
||||
* @return 当前 Builder
|
||||
*/
|
||||
public Builder federationExecutionPolicy(FederationExecutionPolicy executionPolicy) {
|
||||
this.executionPolicy = Objects.requireNonNull(
|
||||
executionPolicy,
|
||||
"executionPolicy must not be null"
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置节点同时预留的联邦中间结果内存总上限。
|
||||
*
|
||||
* @param maximumNodeIntermediateBytes 节点内存上限
|
||||
* @return 当前 Builder
|
||||
*/
|
||||
public Builder maximumNodeIntermediateBytes(long maximumNodeIntermediateBytes) {
|
||||
if (maximumNodeIntermediateBytes <= 0L) {
|
||||
throw new IllegalArgumentException(
|
||||
"maximumNodeIntermediateBytes must be positive"
|
||||
);
|
||||
}
|
||||
this.maximumNodeIntermediateBytes = maximumNodeIntermediateBytes;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置联邦表统计 Provider,覆盖引擎内建的 Adapter 自动采集能力。
|
||||
*
|
||||
* @param statisticsProvider 调用方完全托管的只读统计快照 Provider
|
||||
* @return 当前 Builder
|
||||
*/
|
||||
public Builder tableStatisticsProvider(
|
||||
FederationTableStatisticsProvider statisticsProvider
|
||||
) {
|
||||
this.statisticsProvider = Objects.requireNonNull(
|
||||
statisticsProvider,
|
||||
"statisticsProvider must not be null"
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 ServiceLoader 类加载器。
|
||||
*
|
||||
* @param classLoader 类加载器
|
||||
* @return 当前 Builder
|
||||
*/
|
||||
public Builder classLoader(ClassLoader classLoader) {
|
||||
this.classLoader = Objects.requireNonNull(classLoader, "classLoader must not be null");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建独立 Engine。
|
||||
*
|
||||
* @return Engine
|
||||
*/
|
||||
public FederationSqlEngine build() {
|
||||
if (resolver == null) {
|
||||
throw new IllegalStateException("dataSourceResolver must be configured");
|
||||
}
|
||||
FederationSqlAdapterRegistry registry = new FederationSqlAdapterRegistry(adapters, classLoader);
|
||||
DefaultFederationSourceManager sourceManager = new DefaultFederationSourceManager(
|
||||
resolver,
|
||||
registry,
|
||||
stateProvider
|
||||
);
|
||||
return new DefaultFederationSqlEngine(
|
||||
sourceManager,
|
||||
admissionController,
|
||||
policies,
|
||||
maximumPlanCacheEntries,
|
||||
maximumConcurrentCompilations,
|
||||
crossSourceEnabled,
|
||||
executionPolicy,
|
||||
maximumPlanCacheWeightBytes,
|
||||
planCacheTimeToLive,
|
||||
statisticsProvider,
|
||||
maximumNodeIntermediateBytes
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.easyagents.federation.sql.api;
|
||||
|
||||
/**
|
||||
* SQL 联邦查询稳定错误码。
|
||||
*/
|
||||
public enum FederationSqlErrorCode {
|
||||
/** 公共参数不合法。 */
|
||||
INVALID_ARGUMENT,
|
||||
/** Engine 或 SourceManager 已关闭。 */
|
||||
ENGINE_CLOSED,
|
||||
/** SQL 解析失败。 */
|
||||
SQL_PARSE_FAILED,
|
||||
/** SQL 校验失败。 */
|
||||
SQL_VALIDATION_FAILED,
|
||||
/** SQL 超出只读查询基线。 */
|
||||
SQL_NOT_READ_ONLY,
|
||||
/** SQL 编译或关系转换失败。 */
|
||||
SQL_COMPILE_FAILED,
|
||||
/** SQL 冷编译等待或编译过程超过统一时限。 */
|
||||
SQL_COMPILE_TIMEOUT,
|
||||
/** SQL 编辑器补全失败。 */
|
||||
SQL_COMPLETION_FAILED,
|
||||
/** 单源计划仍含不可执行的本地残余算子。 */
|
||||
SQL_NOT_FULLY_PUSHDOWN,
|
||||
/** 跨数据源能力未开启。 */
|
||||
CROSS_SOURCE_DISABLED,
|
||||
/** 跨数据源执行在当前阶段未实现。 */
|
||||
CROSS_SOURCE_EXECUTION_UNSUPPORTED,
|
||||
/** 查询范围或 Binding 声明不合法。 */
|
||||
INVALID_QUERY_SCOPE,
|
||||
/** 联邦本地执行暂不支持当前关系算子。 */
|
||||
FEDERATION_OPERATOR_UNSUPPORTED,
|
||||
/** 联邦中间结果行数、字节数或执行时间超过限制。 */
|
||||
FEDERATION_RESOURCE_LIMIT_EXCEEDED,
|
||||
/** 节点本地计划绑定的 Runtime 身份已经失效。 */
|
||||
PLAN_STALE,
|
||||
/** 数据源未登记且无法从共享状态恢复。 */
|
||||
SOURCE_NOT_FOUND,
|
||||
/** 数据源已被墓碑删除。 */
|
||||
SOURCE_REMOVED,
|
||||
/** 节点本地数据源版本不满足请求。 */
|
||||
SOURCE_REVISION_NOT_READY,
|
||||
/** 同 revision 出现不同 Definition 校验和。 */
|
||||
SOURCE_DEFINITION_CONFLICT,
|
||||
/** 数据源 Runtime 初始化失败。 */
|
||||
SOURCE_INITIALIZATION_FAILED,
|
||||
/** Adapter 未注册。 */
|
||||
ADAPTER_NOT_FOUND,
|
||||
/** Adapter 不支持当前数据库。 */
|
||||
ADAPTER_UNSUPPORTED,
|
||||
/** SQL 动态参数数量不匹配。 */
|
||||
PARAMETER_COUNT_MISMATCH,
|
||||
/** 查询准入等待超时或被中断。 */
|
||||
QUERY_ADMISSION_TIMEOUT,
|
||||
/** 节点本地联邦中间结果内存准入超时。 */
|
||||
NODE_MEMORY_ADMISSION_TIMEOUT,
|
||||
/** JDBC 连接池获取连接达到超时。 */
|
||||
CONNECTION_ACQUISITION_TIMEOUT,
|
||||
/** JDBC 连接获取因网络、认证或连接池关闭等原因失败。 */
|
||||
CONNECTION_ACQUISITION_FAILED,
|
||||
/** 查询被主动取消。 */
|
||||
QUERY_CANCELLED,
|
||||
/** JDBC 查询或结果读取达到驱动超时。 */
|
||||
QUERY_TIMEOUT,
|
||||
/** JDBC 查询执行失败。 */
|
||||
EXECUTION_FAILED,
|
||||
/** 物理数据库 Explain 执行失败。 */
|
||||
EXPLAIN_FAILED,
|
||||
/** JDBC 或 Runtime 资源关闭失败。 */
|
||||
RESOURCE_CLOSE_FAILED
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.easyagents.federation.sql.api;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* SQL 联邦查询异常,携带稳定错误码供调用方分类处理。
|
||||
*/
|
||||
public class FederationSqlException extends RuntimeException {
|
||||
|
||||
/** 稳定错误码。 */
|
||||
private final FederationSqlErrorCode errorCode;
|
||||
|
||||
/**
|
||||
* 创建异常。
|
||||
*
|
||||
* @param errorCode 稳定错误码
|
||||
* @param message 可安全返回的错误说明
|
||||
*/
|
||||
public FederationSqlException(FederationSqlErrorCode errorCode, String message) {
|
||||
super(message);
|
||||
this.errorCode = Objects.requireNonNull(errorCode, "errorCode must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带原始原因的异常。
|
||||
*
|
||||
* @param errorCode 稳定错误码
|
||||
* @param message 可安全返回的错误说明
|
||||
* @param cause 原始异常
|
||||
*/
|
||||
public FederationSqlException(FederationSqlErrorCode errorCode, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.errorCode = Objects.requireNonNull(errorCode, "errorCode must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回稳定错误码。
|
||||
*
|
||||
* @return 错误码
|
||||
*/
|
||||
public FederationSqlErrorCode errorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.easyagents.federation.sql.api;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 一个可插入 SQL 编辑器的补全候选。
|
||||
*
|
||||
* @param label 面向用户展示的短名称
|
||||
* @param insertText Calcite 生成的替换文本
|
||||
* @param kind 候选类型
|
||||
* @param qualifiedName 候选的完整限定名称
|
||||
*/
|
||||
public record SqlCompletionItem(
|
||||
String label,
|
||||
String insertText,
|
||||
SqlCompletionKind kind,
|
||||
List<String> qualifiedName
|
||||
) {
|
||||
|
||||
/**
|
||||
* 校验并防御性复制候选信息。
|
||||
*/
|
||||
public SqlCompletionItem {
|
||||
if (label == null || label.isBlank() || insertText == null || kind == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"completion label, insertText and kind must be provided"
|
||||
);
|
||||
}
|
||||
qualifiedName = List.copyOf(qualifiedName == null ? List.of() : qualifiedName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.easyagents.federation.sql.api;
|
||||
|
||||
/**
|
||||
* SQL 补全候选类型。
|
||||
*/
|
||||
public enum SqlCompletionKind {
|
||||
/** SQL 关键字。 */
|
||||
KEYWORD,
|
||||
/** SQL 函数。 */
|
||||
FUNCTION,
|
||||
/** 逻辑表。 */
|
||||
TABLE,
|
||||
/** 逻辑视图。 */
|
||||
VIEW,
|
||||
/** Schema。 */
|
||||
SCHEMA,
|
||||
/** Catalog。 */
|
||||
CATALOG,
|
||||
/** 字段。 */
|
||||
COLUMN,
|
||||
/** 无法进一步分类的候选。 */
|
||||
OTHER
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.easyagents.federation.sql.api;
|
||||
|
||||
import com.easyagents.federation.sql.federation.FederationQueryScopeDefinition;
|
||||
|
||||
/**
|
||||
* SQL 编辑器补全请求。
|
||||
*
|
||||
* @param queryScope 当前编辑器可见的查询范围
|
||||
* @param sql 允许不完整的 SQL 文本
|
||||
* @param cursorOffset 光标 UTF-16 字符偏移
|
||||
*/
|
||||
public record SqlCompletionRequest(
|
||||
FederationQueryScopeDefinition queryScope,
|
||||
String sql,
|
||||
int cursorOffset
|
||||
) {
|
||||
|
||||
/**
|
||||
* 校验补全请求。
|
||||
*/
|
||||
public SqlCompletionRequest {
|
||||
if (queryScope == null || sql == null) {
|
||||
throw new IllegalArgumentException("queryScope and sql must be provided");
|
||||
}
|
||||
if (cursorOffset < 0 || cursorOffset > sql.length()) {
|
||||
throw new IllegalArgumentException("cursorOffset is outside the SQL text");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.easyagents.federation.sql.api;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SQL 补全结果。
|
||||
*
|
||||
* @param replaceStart 建议替换区间起点,使用 UTF-16 字符偏移
|
||||
* @param replaceEnd 建议替换区间终点,使用 UTF-16 字符偏移
|
||||
* @param items 补全候选
|
||||
*/
|
||||
public record SqlCompletionResult(
|
||||
int replaceStart,
|
||||
int replaceEnd,
|
||||
List<SqlCompletionItem> items
|
||||
) {
|
||||
|
||||
/**
|
||||
* 校验并防御性复制补全结果。
|
||||
*/
|
||||
public SqlCompletionResult {
|
||||
if (replaceStart < 0 || replaceEnd < replaceStart) {
|
||||
throw new IllegalArgumentException("completion replacement range is invalid");
|
||||
}
|
||||
items = List.copyOf(items == null ? List.of() : items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.easyagents.federation.sql.api;
|
||||
|
||||
import com.easyagents.federation.sql.execute.QueryId;
|
||||
import com.easyagents.federation.sql.execute.SqlExecutionOptions;
|
||||
import com.easyagents.federation.sql.execute.SqlParameter;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 执行节点本地编译计划的上下文。
|
||||
*
|
||||
* @param queryId 查询标识
|
||||
* @param parameters 参数值
|
||||
* @param options JDBC 执行限制
|
||||
* @param admissionTimeout 查询准入等待上限
|
||||
*/
|
||||
public record SqlExecutionContext(
|
||||
QueryId queryId,
|
||||
List<SqlParameter> parameters,
|
||||
SqlExecutionOptions options,
|
||||
Duration admissionTimeout
|
||||
) {
|
||||
|
||||
/**
|
||||
* 校验并防御性复制执行上下文。
|
||||
*/
|
||||
public SqlExecutionContext {
|
||||
queryId = queryId == null ? QueryId.create() : queryId;
|
||||
parameters = List.copyOf(parameters == null ? List.of() : parameters);
|
||||
options = options == null ? SqlExecutionOptions.defaults() : options;
|
||||
admissionTimeout = admissionTimeout == null ? Duration.ofSeconds(5) : admissionTimeout;
|
||||
if (admissionTimeout.isNegative()) {
|
||||
throw new IllegalArgumentException("admissionTimeout must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认执行上下文。
|
||||
*
|
||||
* @param parameters 参数值
|
||||
* @return 执行上下文
|
||||
*/
|
||||
public static SqlExecutionContext of(List<SqlParameter> parameters) {
|
||||
return new SqlExecutionContext(null, parameters, null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.easyagents.federation.sql.api;
|
||||
|
||||
import com.easyagents.federation.sql.execute.QueryId;
|
||||
import com.easyagents.federation.sql.execute.SqlExecutionOptions;
|
||||
import com.easyagents.federation.sql.execute.SqlParameter;
|
||||
import com.easyagents.federation.sql.federation.FederationQueryScopeDefinition;
|
||||
import com.easyagents.federation.sql.federation.FederationSourceBindingDefinition;
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 可持久化或跨节点传递的一体化查询命令。
|
||||
*
|
||||
* @param queryId 查询标识,可为空并在构造命令时生成
|
||||
* @param sql 单条只读 SQL
|
||||
* @param queryScope 查询可见的数据源范围
|
||||
* @param parameters 参数值
|
||||
* @param options JDBC 执行限制
|
||||
* @param admissionTimeoutMillis 查询准入等待毫秒数
|
||||
* @param policyVersion 策略版本
|
||||
*/
|
||||
public record SqlQueryCommand(
|
||||
QueryId queryId,
|
||||
String sql,
|
||||
FederationQueryScopeDefinition queryScope,
|
||||
List<SqlParameter> parameters,
|
||||
SqlExecutionOptions options,
|
||||
long admissionTimeoutMillis,
|
||||
String policyVersion
|
||||
) implements Serializable {
|
||||
|
||||
/**
|
||||
* 校验并防御性复制查询命令。
|
||||
*/
|
||||
public SqlQueryCommand {
|
||||
if (sql == null || sql.isBlank() || queryScope == null) {
|
||||
throw new IllegalArgumentException("sql and queryScope must be provided");
|
||||
}
|
||||
if (admissionTimeoutMillis < 0) {
|
||||
throw new IllegalArgumentException("timeout must not be negative");
|
||||
}
|
||||
queryId = queryId == null ? QueryId.create() : queryId;
|
||||
parameters = List.copyOf(parameters == null ? List.of() : parameters);
|
||||
options = options == null ? SqlExecutionOptions.defaults() : options;
|
||||
policyVersion = policyVersion == null || policyVersion.isBlank() ? "default" : policyVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用单物理数据源创建兼容查询命令。
|
||||
*
|
||||
* @param queryId 查询标识
|
||||
* @param sql 单条只读 SQL
|
||||
* @param sourceId 默认数据源
|
||||
* @param minimumRevision 最低数据源版本
|
||||
* @param parameters 参数值
|
||||
* @param options JDBC 执行限制
|
||||
* @param admissionTimeoutMillis 准入等待毫秒数
|
||||
* @param policyVersion 策略版本
|
||||
*/
|
||||
public SqlQueryCommand(
|
||||
QueryId queryId,
|
||||
String sql,
|
||||
SourceId sourceId,
|
||||
long minimumRevision,
|
||||
List<SqlParameter> parameters,
|
||||
SqlExecutionOptions options,
|
||||
long admissionTimeoutMillis,
|
||||
String policyVersion
|
||||
) {
|
||||
this(
|
||||
queryId,
|
||||
sql,
|
||||
FederationQueryScopeDefinition.single(sourceId, minimumRevision),
|
||||
parameters,
|
||||
options,
|
||||
admissionTimeoutMillis,
|
||||
policyVersion
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回默认 Binding 的物理数据源,供单源调用方兼容读取。
|
||||
*
|
||||
* @return 默认物理数据源
|
||||
*/
|
||||
public SourceId sourceId() {
|
||||
return defaultBinding().sourceId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回默认 Binding 的最低物理 Definition 版本。
|
||||
*
|
||||
* @return 最低版本
|
||||
*/
|
||||
public long minimumRevision() {
|
||||
return defaultBinding().minimumRevision();
|
||||
}
|
||||
|
||||
private FederationSourceBindingDefinition defaultBinding() {
|
||||
return queryScope.defaultBindingDefinition();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建使用默认执行限制的查询命令。
|
||||
*
|
||||
* @param sql 单条只读 SQL
|
||||
* @param sourceId 数据源标识
|
||||
* @param minimumRevision 最低数据源版本
|
||||
* @param parameters 参数
|
||||
* @return 查询命令
|
||||
*/
|
||||
public static SqlQueryCommand of(
|
||||
String sql,
|
||||
SourceId sourceId,
|
||||
long minimumRevision,
|
||||
List<SqlParameter> parameters
|
||||
) {
|
||||
return new SqlQueryCommand(
|
||||
null,
|
||||
sql,
|
||||
sourceId,
|
||||
minimumRevision,
|
||||
parameters,
|
||||
SqlExecutionOptions.defaults(),
|
||||
Duration.ofSeconds(5).toMillis(),
|
||||
"default"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建使用默认执行限制的查询范围命令。
|
||||
*
|
||||
* @param sql 单条只读 SQL
|
||||
* @param queryScope 查询范围
|
||||
* @param parameters 参数
|
||||
* @return 查询命令
|
||||
*/
|
||||
public static SqlQueryCommand of(
|
||||
String sql,
|
||||
FederationQueryScopeDefinition queryScope,
|
||||
List<SqlParameter> parameters
|
||||
) {
|
||||
return new SqlQueryCommand(
|
||||
null,
|
||||
sql,
|
||||
queryScope,
|
||||
parameters,
|
||||
SqlExecutionOptions.defaults(),
|
||||
Duration.ofSeconds(5).toMillis(),
|
||||
"default"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.easyagents.federation.sql.compile;
|
||||
|
||||
import com.easyagents.federation.sql.execute.FederationColumn;
|
||||
import com.easyagents.federation.sql.execute.FederationPhysicalExplain;
|
||||
import com.easyagents.federation.sql.federation.FederationCostEstimate;
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Explain 中一个目标数据库分片的纯数据视图。
|
||||
*
|
||||
* @param fragmentId 分片标识
|
||||
* @param bindingName 查询范围 Binding 名称
|
||||
* @param sourceId 物理数据源
|
||||
* @param adapterId Adapter 标识
|
||||
* @param executableSql 目标方言参数化 SQL
|
||||
* @param parameterMapping 分片参数到原查询参数的映射
|
||||
* @param columns 分片输出列
|
||||
* @param costEstimate 分片搬运成本估算
|
||||
* @param pushedDownOperators 已下推算子
|
||||
* @param physicalExplain 显式物理 Explain;逻辑级别时为空
|
||||
*/
|
||||
public record FederationFragmentExplain(
|
||||
String fragmentId,
|
||||
String bindingName,
|
||||
SourceId sourceId,
|
||||
String adapterId,
|
||||
String executableSql,
|
||||
List<Integer> parameterMapping,
|
||||
List<FederationColumn> columns,
|
||||
FederationCostEstimate costEstimate,
|
||||
List<String> pushedDownOperators,
|
||||
FederationPhysicalExplain physicalExplain
|
||||
) implements Serializable {
|
||||
|
||||
/**
|
||||
* 防御性复制集合字段。
|
||||
*/
|
||||
public FederationFragmentExplain {
|
||||
parameterMapping = List.copyOf(parameterMapping == null ? List.of() : parameterMapping);
|
||||
columns = List.copyOf(columns == null ? List.of() : columns);
|
||||
pushedDownOperators = List.copyOf(
|
||||
pushedDownOperators == null ? List.of() : pushedDownOperators
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建旧字段集合的兼容 Explain 分片。
|
||||
*
|
||||
* @param fragmentId 分片标识
|
||||
* @param bindingName Binding 名称
|
||||
* @param sourceId 物理源
|
||||
* @param adapterId Adapter 标识
|
||||
* @param executableSql 目标 SQL
|
||||
* @param parameterMapping 参数映射
|
||||
* @param columns 输出列
|
||||
* @param physicalExplain 物理 Explain
|
||||
*/
|
||||
public FederationFragmentExplain(
|
||||
String fragmentId,
|
||||
String bindingName,
|
||||
SourceId sourceId,
|
||||
String adapterId,
|
||||
String executableSql,
|
||||
List<Integer> parameterMapping,
|
||||
List<FederationColumn> columns,
|
||||
FederationPhysicalExplain physicalExplain
|
||||
) {
|
||||
this(
|
||||
fragmentId,
|
||||
bindingName,
|
||||
sourceId,
|
||||
adapterId,
|
||||
executableSql,
|
||||
parameterMapping,
|
||||
columns,
|
||||
new FederationCostEstimate(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"calcite-default",
|
||||
"none",
|
||||
java.time.Instant.EPOCH,
|
||||
true,
|
||||
com.easyagents.federation.sql.federation.FederationStatisticsStatus.MISSING,
|
||||
false
|
||||
),
|
||||
List.of(),
|
||||
physicalExplain
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.easyagents.federation.sql.compile;
|
||||
|
||||
import com.easyagents.federation.sql.adapter.AdapterCompatibility;
|
||||
import com.easyagents.federation.sql.execute.FederationColumn;
|
||||
import com.easyagents.federation.sql.federation.FederationFragmentPlan;
|
||||
import com.easyagents.federation.sql.federation.FederationJoinOptimization;
|
||||
import com.easyagents.federation.sql.federation.FederationQueryMode;
|
||||
import com.easyagents.federation.sql.federation.FederationQueryScopeDefinition;
|
||||
import com.easyagents.federation.sql.federation.FederationSourceRuntimeIdentity;
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.apache.calcite.rel.RelRoot;
|
||||
import org.apache.calcite.sql.SqlNode;
|
||||
|
||||
/**
|
||||
* Engine 签发的节点本地 SQL 编译计划。
|
||||
*
|
||||
* <p>该接口仅用于读取编译事实。调用方不能自行创建可执行计划,且计划只能交回
|
||||
* 签发它的 Engine 实例执行。</p>
|
||||
*/
|
||||
public interface FederationSqlPlan {
|
||||
|
||||
/**
|
||||
* 返回主数据源。
|
||||
*
|
||||
* @return 主数据源
|
||||
*/
|
||||
SourceId sourceId();
|
||||
|
||||
/**
|
||||
* 返回编译时使用的不可变查询范围。
|
||||
*
|
||||
* @return 查询范围
|
||||
*/
|
||||
FederationQueryScopeDefinition queryScope();
|
||||
|
||||
/**
|
||||
* 返回根据实际引用源确定的查询模式。
|
||||
*
|
||||
* @return 查询模式
|
||||
*/
|
||||
FederationQueryMode queryMode();
|
||||
|
||||
/**
|
||||
* 返回数据源版本。
|
||||
*
|
||||
* @return 数据源版本
|
||||
*/
|
||||
long sourceRevision();
|
||||
|
||||
/**
|
||||
* 返回 Calcite 规范化 SQL。
|
||||
*
|
||||
* @return Calcite 规范化 SQL
|
||||
*/
|
||||
String normalizedSql();
|
||||
|
||||
/**
|
||||
* 返回单源目标数据库参数化 SQL。
|
||||
*
|
||||
* <p>联邦计划应读取 {@link #fragments()};本兼容视图不代表任一数据库可执行 SQL。</p>
|
||||
*
|
||||
* @return 单源目标 SQL,或联邦调用方原始 SQL 兼容视图
|
||||
*/
|
||||
String executableSql();
|
||||
|
||||
/**
|
||||
* 返回 Calcite 已校验 SQL 节点。
|
||||
*
|
||||
* @return Calcite 已校验 SQL 节点
|
||||
*/
|
||||
SqlNode sqlNode();
|
||||
|
||||
/**
|
||||
* 返回 Calcite 关系计划。
|
||||
*
|
||||
* @return Calcite 关系计划
|
||||
*/
|
||||
RelRoot relRoot();
|
||||
|
||||
/**
|
||||
* 返回动态参数数量。
|
||||
*
|
||||
* @return 动态参数数量
|
||||
*/
|
||||
int parameterCount();
|
||||
|
||||
/**
|
||||
* 返回编译时声明的原始 JDBC 参数类型。
|
||||
*
|
||||
* @return JDBC 参数类型;未显式声明时为空
|
||||
*/
|
||||
List<Integer> parameterJdbcTypes();
|
||||
|
||||
/**
|
||||
* 返回目标 SQL 占位符到原始参数的零基索引映射。
|
||||
*
|
||||
* @return 参数映射
|
||||
*/
|
||||
List<Integer> parameterMapping();
|
||||
|
||||
/**
|
||||
* 返回物理数据源分片;单源计划也包含一个分片。
|
||||
*
|
||||
* @return 分片列表
|
||||
*/
|
||||
List<FederationFragmentPlan> fragments();
|
||||
|
||||
/**
|
||||
* 返回跨源 Join 的优化选择。
|
||||
*
|
||||
* @return 不可变 Join 优化列表
|
||||
*/
|
||||
default List<FederationJoinOptimization> joinOptimizations() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回实际引用 Binding 对应的节点本地运行身份。
|
||||
*
|
||||
* @return 运行身份列表
|
||||
*/
|
||||
List<FederationSourceRuntimeIdentity> sourceRuntimeIdentities();
|
||||
|
||||
/**
|
||||
* 返回查询范围的稳定校验和。
|
||||
*
|
||||
* @return Scope 校验和
|
||||
*/
|
||||
String scopeChecksum();
|
||||
|
||||
/**
|
||||
* 返回结果列。
|
||||
*
|
||||
* @return 结果列
|
||||
*/
|
||||
List<FederationColumn> columns();
|
||||
|
||||
/**
|
||||
* 返回引用的数据源集合。
|
||||
*
|
||||
* @return 引用的数据源集合
|
||||
*/
|
||||
Set<SourceId> referencedSources();
|
||||
|
||||
/**
|
||||
* 返回 Adapter 兼容性。
|
||||
*
|
||||
* @return Adapter 兼容性
|
||||
*/
|
||||
AdapterCompatibility compatibility();
|
||||
|
||||
/**
|
||||
* 返回是否允许直接执行。
|
||||
*
|
||||
* @return 是否允许直接执行
|
||||
*/
|
||||
boolean executable();
|
||||
|
||||
/**
|
||||
* 返回编译时的数据源 Definition 校验和。
|
||||
*
|
||||
* @return Definition 校验和
|
||||
*/
|
||||
String sourceChecksum();
|
||||
|
||||
/**
|
||||
* 返回编译时的 Adapter 标识。
|
||||
*
|
||||
* @return Adapter 标识
|
||||
*/
|
||||
String adapterId();
|
||||
|
||||
/**
|
||||
* 返回编译时的数据库与驱动指纹。
|
||||
*
|
||||
* @return 运行指纹摘要
|
||||
*/
|
||||
String runtimeFingerprint();
|
||||
|
||||
/**
|
||||
* 返回该计划所依赖统计快照的最早失效时间。
|
||||
*
|
||||
* @return 最早失效时间;未使用有期限统计时为 {@link Instant#MAX}
|
||||
*/
|
||||
default Instant statisticsValidUntil() {
|
||||
return Instant.MAX;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.easyagents.federation.sql.compile;
|
||||
|
||||
/**
|
||||
* 调用方在 SQL 已校验并转换为 RelRoot 后执行的策略 SPI。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FederationSqlPolicy {
|
||||
|
||||
/**
|
||||
* 返回策略实现的稳定版本,用于隔离计划缓存。
|
||||
*
|
||||
* <p>策略规则发生变化时应同步更新版本。默认版本适用于 Engine 生命周期内
|
||||
* 逻辑不变的无状态策略。</p>
|
||||
*
|
||||
* @return 稳定策略版本
|
||||
*/
|
||||
default String version() {
|
||||
return "1";
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验已编译 SQL;拒绝时应抛出 FederationSqlException。
|
||||
*
|
||||
* @param context 策略上下文
|
||||
*/
|
||||
void validate(SqlPolicyContext context);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.easyagents.federation.sql.compile;
|
||||
|
||||
import com.easyagents.federation.sql.federation.FederationQueryScopeDefinition;
|
||||
import com.easyagents.federation.sql.federation.FederationSourceBindingDefinition;
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 节点本地 SQL 编译请求。
|
||||
*
|
||||
* @param sql 单条只读 SQL
|
||||
* @param queryScope 查询可见的数据源范围
|
||||
* @param parameterJdbcTypes 参数 JDBC 类型列表
|
||||
* @param policyVersion 调用方策略版本,用于隔离计划缓存
|
||||
*/
|
||||
public record SqlCompileRequest(
|
||||
String sql,
|
||||
FederationQueryScopeDefinition queryScope,
|
||||
List<Integer> parameterJdbcTypes,
|
||||
String policyVersion
|
||||
) {
|
||||
|
||||
/**
|
||||
* 校验并防御性复制编译请求。
|
||||
*/
|
||||
public SqlCompileRequest {
|
||||
if (sql == null || sql.isBlank()) {
|
||||
throw new IllegalArgumentException("sql must not be blank");
|
||||
}
|
||||
if (queryScope == null) {
|
||||
throw new IllegalArgumentException("queryScope must not be null");
|
||||
}
|
||||
parameterJdbcTypes = List.copyOf(parameterJdbcTypes == null ? List.of() : parameterJdbcTypes);
|
||||
policyVersion = policyVersion == null || policyVersion.isBlank() ? "default" : policyVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用单物理数据源创建兼容编译请求。
|
||||
*
|
||||
* @param sql 单条只读 SQL
|
||||
* @param sourceId 默认数据源
|
||||
* @param minimumRevision 最低数据源版本
|
||||
* @param parameterJdbcTypes 参数 JDBC 类型
|
||||
* @param policyVersion 调用方策略版本
|
||||
*/
|
||||
public SqlCompileRequest(
|
||||
String sql,
|
||||
SourceId sourceId,
|
||||
long minimumRevision,
|
||||
List<Integer> parameterJdbcTypes,
|
||||
String policyVersion
|
||||
) {
|
||||
this(
|
||||
sql,
|
||||
FederationQueryScopeDefinition.single(sourceId, minimumRevision),
|
||||
parameterJdbcTypes,
|
||||
policyVersion
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回默认 Binding 的物理数据源,供单源调用方兼容读取。
|
||||
*
|
||||
* @return 默认物理数据源
|
||||
*/
|
||||
public SourceId sourceId() {
|
||||
return defaultBinding().sourceId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回默认 Binding 的最低物理 Definition 版本。
|
||||
*
|
||||
* @return 最低版本
|
||||
*/
|
||||
public long minimumRevision() {
|
||||
return defaultBinding().minimumRevision();
|
||||
}
|
||||
|
||||
private FederationSourceBindingDefinition defaultBinding() {
|
||||
return queryScope.defaultBindingDefinition();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建无参数的默认编译请求。
|
||||
*
|
||||
* @param sql 单条只读 SQL
|
||||
* @param sourceId 默认数据源
|
||||
* @param minimumRevision 最低数据源版本
|
||||
* @return 编译请求
|
||||
*/
|
||||
public static SqlCompileRequest of(String sql, SourceId sourceId, long minimumRevision) {
|
||||
return new SqlCompileRequest(sql, sourceId, minimumRevision, List.of(), "default");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建无参数的查询范围编译请求。
|
||||
*
|
||||
* @param sql 单条只读 SQL
|
||||
* @param queryScope 查询范围
|
||||
* @return 编译请求
|
||||
*/
|
||||
public static SqlCompileRequest of(
|
||||
String sql,
|
||||
FederationQueryScopeDefinition queryScope
|
||||
) {
|
||||
return new SqlCompileRequest(sql, queryScope, List.of(), "default");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.easyagents.federation.sql.compile;
|
||||
|
||||
/**
|
||||
* Explain 深度。
|
||||
*/
|
||||
public enum SqlExplainLevel {
|
||||
|
||||
/** 只生成 Calcite 逻辑计划和物理分片 SQL,不访问数据库 Optimizer。 */
|
||||
LOGICAL,
|
||||
|
||||
/** 在逻辑计划基础上显式请求各物理数据库的非 ANALYZE Explain。 */
|
||||
PHYSICAL
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.easyagents.federation.sql.compile;
|
||||
|
||||
import com.easyagents.federation.sql.execute.SqlParameter;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SQL Explain 请求。
|
||||
*
|
||||
* @param compileRequest 编译请求
|
||||
* @param level Explain 深度
|
||||
* @param parameters 保留的兼容字段;为避免数据库计划回显敏感值,只允许为空
|
||||
*/
|
||||
public record SqlExplainRequest(
|
||||
SqlCompileRequest compileRequest,
|
||||
SqlExplainLevel level,
|
||||
List<SqlParameter> parameters
|
||||
) {
|
||||
|
||||
/**
|
||||
* 校验 Explain 请求。
|
||||
*/
|
||||
public SqlExplainRequest {
|
||||
if (compileRequest == null) {
|
||||
throw new IllegalArgumentException("compileRequest must not be null");
|
||||
}
|
||||
level = level == null ? SqlExplainLevel.PHYSICAL : level;
|
||||
parameters = List.copyOf(parameters == null ? List.of() : parameters);
|
||||
if (!parameters.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"physical Explain does not accept parameter values; "
|
||||
+ "declare JDBC types in compileRequest"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认物理 Explain 请求,不提供实际参数值。
|
||||
*
|
||||
* @param compileRequest 编译请求
|
||||
*/
|
||||
public SqlExplainRequest(SqlCompileRequest compileRequest) {
|
||||
this(compileRequest, SqlExplainLevel.PHYSICAL, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建指定深度的 Explain 请求。
|
||||
*
|
||||
* @param compileRequest 编译请求
|
||||
* @param level Explain 深度
|
||||
*/
|
||||
public SqlExplainRequest(SqlCompileRequest compileRequest, SqlExplainLevel level) {
|
||||
this(compileRequest, level, List.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.easyagents.federation.sql.compile;
|
||||
|
||||
import com.easyagents.federation.sql.adapter.AdapterCompatibility;
|
||||
import com.easyagents.federation.sql.federation.FederationQueryMode;
|
||||
import com.easyagents.federation.sql.federation.FederationJoinOptimization;
|
||||
import com.easyagents.federation.sql.federation.FederationStatisticsStatus;
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 不含节点本地 Calcite/JDBC 对象的 Explain 结果。
|
||||
*
|
||||
* @param level Explain 深度
|
||||
* @param queryMode 实际查询模式
|
||||
* @param statisticsStatus 成本统计完整性与时效状态
|
||||
* @param estimateAvailable 聚合成本数值是否为有效估算
|
||||
* @param estimatedTransferBytes 预计从物理源搬运的总字节数
|
||||
* @param estimatedLocalMemoryBytes 本地 Join 构建侧估算内存字节数
|
||||
* @param joinOptimizations 跨源 Join 优化选择
|
||||
* @param normalizedSql Calcite 规范化 SQL
|
||||
* @param executableSql 单源目标方言 SQL;联邦计划仅为兼容视图,应读取 fragments
|
||||
* @param relationalPlan 关系计划文本
|
||||
* @param executionPlan 实际单源下推或联邦本地执行计划文本
|
||||
* @param fragments 物理分片与可选数据库计划
|
||||
* @param referencedSources 引用的数据源
|
||||
* @param compatibility Adapter 兼容性
|
||||
* @param executable 是否允许执行
|
||||
* @param planCacheHit 是否命中节点本地计划缓存
|
||||
* @param diagnostic 诊断说明
|
||||
*/
|
||||
public record SqlExplainResult(
|
||||
SqlExplainLevel level,
|
||||
FederationQueryMode queryMode,
|
||||
FederationStatisticsStatus statisticsStatus,
|
||||
boolean estimateAvailable,
|
||||
double estimatedTransferBytes,
|
||||
long estimatedLocalMemoryBytes,
|
||||
List<FederationJoinOptimization> joinOptimizations,
|
||||
String normalizedSql,
|
||||
String executableSql,
|
||||
String relationalPlan,
|
||||
String executionPlan,
|
||||
List<FederationFragmentExplain> fragments,
|
||||
Set<SourceId> referencedSources,
|
||||
AdapterCompatibility compatibility,
|
||||
boolean executable,
|
||||
boolean planCacheHit,
|
||||
String diagnostic
|
||||
) implements Serializable {
|
||||
|
||||
/**
|
||||
* 防御性复制引用集合。
|
||||
*/
|
||||
public SqlExplainResult {
|
||||
level = level == null ? SqlExplainLevel.LOGICAL : level;
|
||||
queryMode = queryMode == null ? FederationQueryMode.SINGLE_SOURCE : queryMode;
|
||||
statisticsStatus = statisticsStatus == null
|
||||
? FederationStatisticsStatus.MISSING
|
||||
: statisticsStatus;
|
||||
if (!Double.isFinite(estimatedTransferBytes) || estimatedTransferBytes < 0
|
||||
|| estimatedLocalMemoryBytes < 0) {
|
||||
throw new IllegalArgumentException("Explain cost values must be non-negative");
|
||||
}
|
||||
joinOptimizations = List.copyOf(
|
||||
joinOptimizations == null ? List.of() : joinOptimizations
|
||||
);
|
||||
fragments = List.copyOf(fragments == null ? List.of() : fragments);
|
||||
referencedSources = Set.copyOf(referencedSources);
|
||||
diagnostic = diagnostic == null ? "" : diagnostic;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建未包含聚合成本字段的兼容 Explain 结果。
|
||||
*
|
||||
* @param level Explain 深度
|
||||
* @param queryMode 查询模式
|
||||
* @param normalizedSql 规范化 SQL
|
||||
* @param executableSql 可执行 SQL
|
||||
* @param relationalPlan 关系计划
|
||||
* @param executionPlan 执行计划
|
||||
* @param fragments 分片计划
|
||||
* @param referencedSources 引用源
|
||||
* @param compatibility Adapter 兼容性
|
||||
* @param executable 是否可执行
|
||||
* @param planCacheHit 是否命中缓存
|
||||
* @param diagnostic 诊断信息
|
||||
*/
|
||||
public SqlExplainResult(
|
||||
SqlExplainLevel level,
|
||||
FederationQueryMode queryMode,
|
||||
String normalizedSql,
|
||||
String executableSql,
|
||||
String relationalPlan,
|
||||
String executionPlan,
|
||||
List<FederationFragmentExplain> fragments,
|
||||
Set<SourceId> referencedSources,
|
||||
AdapterCompatibility compatibility,
|
||||
boolean executable,
|
||||
boolean planCacheHit,
|
||||
String diagnostic
|
||||
) {
|
||||
this(
|
||||
level,
|
||||
queryMode,
|
||||
FederationStatisticsStatus.MISSING,
|
||||
false,
|
||||
0D,
|
||||
0L,
|
||||
List.of(),
|
||||
normalizedSql,
|
||||
executableSql,
|
||||
relationalPlan,
|
||||
executionPlan,
|
||||
fragments,
|
||||
referencedSources,
|
||||
compatibility,
|
||||
executable,
|
||||
planCacheHit,
|
||||
diagnostic
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建旧单源字段视图的兼容 Explain 结果。
|
||||
*
|
||||
* @param normalizedSql Calcite 规范化 SQL
|
||||
* @param executableSql 目标方言 SQL
|
||||
* @param relationalPlan 关系计划文本
|
||||
* @param referencedSources 引用的数据源
|
||||
* @param compatibility Adapter 兼容性
|
||||
* @param executable 是否允许执行
|
||||
* @param diagnostic 诊断说明
|
||||
*/
|
||||
public SqlExplainResult(
|
||||
String normalizedSql,
|
||||
String executableSql,
|
||||
String relationalPlan,
|
||||
Set<SourceId> referencedSources,
|
||||
AdapterCompatibility compatibility,
|
||||
boolean executable,
|
||||
String diagnostic
|
||||
) {
|
||||
this(
|
||||
SqlExplainLevel.LOGICAL,
|
||||
FederationQueryMode.SINGLE_SOURCE,
|
||||
FederationStatisticsStatus.MISSING,
|
||||
false,
|
||||
0D,
|
||||
0L,
|
||||
List.of(),
|
||||
normalizedSql,
|
||||
executableSql,
|
||||
relationalPlan,
|
||||
relationalPlan,
|
||||
List.of(),
|
||||
referencedSources,
|
||||
compatibility,
|
||||
executable,
|
||||
false,
|
||||
diagnostic
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.easyagents.federation.sql.compile;
|
||||
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.util.Set;
|
||||
import org.apache.calcite.rel.RelRoot;
|
||||
import org.apache.calcite.sql.SqlNode;
|
||||
|
||||
/**
|
||||
* SQL 策略直接读取 Calcite 事实对象的上下文。
|
||||
*
|
||||
* @param request 原始编译请求
|
||||
* @param validatedSql 已校验 SqlNode
|
||||
* @param relRoot 关系计划
|
||||
* @param referencedSources 引用的数据源
|
||||
*/
|
||||
public record SqlPolicyContext(
|
||||
SqlCompileRequest request,
|
||||
SqlNode validatedSql,
|
||||
RelRoot relRoot,
|
||||
Set<SourceId> referencedSources
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 查询结果列元数据。
|
||||
*
|
||||
* @param index 从 1 开始的列序号
|
||||
* @param label 列标签
|
||||
* @param jdbcType JDBC 类型
|
||||
* @param typeName 数据库类型名
|
||||
* @param nullable 是否允许空值
|
||||
*/
|
||||
public record FederationColumn(
|
||||
int index,
|
||||
String label,
|
||||
int jdbcType,
|
||||
String typeName,
|
||||
boolean nullable
|
||||
) implements Serializable {
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 贯穿连接获取、Statement 执行和结果读取的查询终态检查器。
|
||||
*/
|
||||
public interface FederationExecutionGuard {
|
||||
|
||||
/**
|
||||
* 检查查询是否仍允许继续执行。
|
||||
*
|
||||
* @throws RuntimeException 查询取消或超时时抛出稳定异常
|
||||
*/
|
||||
void ensureAllowed();
|
||||
|
||||
/**
|
||||
* 返回查询剩余时限。
|
||||
*
|
||||
* @return 剩余纳秒数;无限制时返回 {@link Long#MAX_VALUE}
|
||||
*/
|
||||
long remainingNanos();
|
||||
|
||||
/**
|
||||
* 将调用方 JDBC 秒级超时收敛到统一剩余时限。
|
||||
*
|
||||
* @param requestedSeconds 调用方超时,0 表示未指定
|
||||
* @return 至少 1 秒的 JDBC 超时;无限制且未指定时返回 0
|
||||
*/
|
||||
default int boundedQueryTimeoutSeconds(int requestedSeconds) {
|
||||
if (remainingNanos() == Long.MAX_VALUE) {
|
||||
return requestedSeconds;
|
||||
}
|
||||
long remainingSeconds = Math.max(
|
||||
1L,
|
||||
TimeUnit.NANOSECONDS.toSeconds(Math.max(1L, remainingNanos()))
|
||||
);
|
||||
int bounded = (int) Math.min(Integer.MAX_VALUE, remainingSeconds);
|
||||
return requestedSeconds == 0 ? bounded : Math.min(requestedSeconds, bounded);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回无限制检查器,供旧 Adapter 调用兼容使用。
|
||||
*
|
||||
* @return 无限制检查器
|
||||
*/
|
||||
static FederationExecutionGuard none() {
|
||||
return NoopHolder.INSTANCE;
|
||||
}
|
||||
|
||||
/** 无状态实例持有者。 */
|
||||
final class NoopHolder {
|
||||
private static final FederationExecutionGuard INSTANCE = new FederationExecutionGuard() {
|
||||
@Override
|
||||
public void ensureAllowed() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public long remainingNanos() {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
};
|
||||
|
||||
private NoopHolder() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
/**
|
||||
* Adapter 向 Core 回传 Fragment 执行阶段耗时的轻量观察器。
|
||||
*/
|
||||
public interface FederationExecutionObserver {
|
||||
|
||||
/**
|
||||
* 记录获取物理连接的耗时。
|
||||
*
|
||||
* @param elapsedNanos 获取连接耗时
|
||||
*/
|
||||
default void connectionAcquired(long elapsedNanos) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录数据库完成 Statement 执行并返回 ResultSet 的耗时。
|
||||
*
|
||||
* @param elapsedNanos 数据库执行耗时
|
||||
*/
|
||||
default void databaseExecutionCompleted(long elapsedNanos) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 ResultSet 返回首行的耗时。
|
||||
*
|
||||
* @param elapsedNanos 从 ResultSet 创建到首行可用的耗时
|
||||
*/
|
||||
default void firstRowAvailable(long elapsedNanos) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回不采集指标的观察器。
|
||||
*
|
||||
* @return 空观察器
|
||||
*/
|
||||
static FederationExecutionObserver none() {
|
||||
return new FederationExecutionObserver() {
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import com.easyagents.federation.sql.adapter.AdapterCompatibility;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.sql.DataSource;
|
||||
|
||||
/**
|
||||
* 单数据源 SQL Fragment 的执行上下文。
|
||||
*
|
||||
* @param queryId 查询标识
|
||||
* @param sql 已按目标方言生成的参数化 SQL
|
||||
* @param parameters JDBC 参数
|
||||
* @param options 强制执行限制
|
||||
* @param dataSource 调用方提供的 DataSource
|
||||
* @param compatibility 当前数据库与驱动兼容性信息
|
||||
* @param adapterOptions 不含凭据的 Adapter 执行选项
|
||||
* @param statementLifecycle Statement 取消登记回调
|
||||
* @param observer Fragment 执行阶段观察器
|
||||
* @param executionGuard 查询取消与统一截止时间检查器
|
||||
*/
|
||||
public record FederationFragmentExecutionContext(
|
||||
QueryId queryId,
|
||||
String sql,
|
||||
List<SqlParameter> parameters,
|
||||
SqlExecutionOptions options,
|
||||
DataSource dataSource,
|
||||
AdapterCompatibility compatibility,
|
||||
Map<String, String> adapterOptions,
|
||||
StatementLifecycle statementLifecycle,
|
||||
FederationExecutionObserver observer,
|
||||
FederationExecutionGuard executionGuard
|
||||
) {
|
||||
|
||||
/**
|
||||
* 防御性复制参数并校验必需字段。
|
||||
*/
|
||||
public FederationFragmentExecutionContext {
|
||||
if (queryId == null || sql == null || sql.isBlank() || options == null
|
||||
|| dataSource == null || compatibility == null || statementLifecycle == null) {
|
||||
throw new IllegalArgumentException("fragment execution context contains null or blank values");
|
||||
}
|
||||
parameters = List.copyOf(parameters == null ? List.of() : parameters);
|
||||
adapterOptions = Map.copyOf(adapterOptions == null ? Map.of() : adapterOptions);
|
||||
observer = observer == null ? FederationExecutionObserver.none() : observer;
|
||||
executionGuard = executionGuard == null ? FederationExecutionGuard.none() : executionGuard;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建不采集 Adapter 阶段指标的兼容执行上下文。
|
||||
*
|
||||
* @param queryId 查询标识
|
||||
* @param sql 参数化 SQL
|
||||
* @param parameters JDBC 参数
|
||||
* @param options 执行限制
|
||||
* @param dataSource 数据源
|
||||
* @param compatibility 数据库兼容信息
|
||||
* @param adapterOptions Adapter 选项
|
||||
* @param statementLifecycle Statement 生命周期
|
||||
*/
|
||||
public FederationFragmentExecutionContext(
|
||||
QueryId queryId,
|
||||
String sql,
|
||||
List<SqlParameter> parameters,
|
||||
SqlExecutionOptions options,
|
||||
DataSource dataSource,
|
||||
AdapterCompatibility compatibility,
|
||||
Map<String, String> adapterOptions,
|
||||
StatementLifecycle statementLifecycle
|
||||
) {
|
||||
this(
|
||||
queryId,
|
||||
sql,
|
||||
parameters,
|
||||
options,
|
||||
dataSource,
|
||||
compatibility,
|
||||
adapterOptions,
|
||||
statementLifecycle,
|
||||
FederationExecutionObserver.none(),
|
||||
FederationExecutionGuard.none()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带阶段观察器的旧调用兼容上下文。
|
||||
*
|
||||
* @param queryId 查询标识
|
||||
* @param sql 参数化 SQL
|
||||
* @param parameters JDBC 参数
|
||||
* @param options 执行限制
|
||||
* @param dataSource 数据源
|
||||
* @param compatibility 数据库兼容信息
|
||||
* @param adapterOptions Adapter 选项
|
||||
* @param statementLifecycle Statement 生命周期
|
||||
* @param observer Fragment 观察器
|
||||
*/
|
||||
public FederationFragmentExecutionContext(
|
||||
QueryId queryId,
|
||||
String sql,
|
||||
List<SqlParameter> parameters,
|
||||
SqlExecutionOptions options,
|
||||
DataSource dataSource,
|
||||
AdapterCompatibility compatibility,
|
||||
Map<String, String> adapterOptions,
|
||||
StatementLifecycle statementLifecycle,
|
||||
FederationExecutionObserver observer
|
||||
) {
|
||||
this(
|
||||
queryId,
|
||||
sql,
|
||||
parameters,
|
||||
options,
|
||||
dataSource,
|
||||
compatibility,
|
||||
adapterOptions,
|
||||
statementLifecycle,
|
||||
observer,
|
||||
FederationExecutionGuard.none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
/**
|
||||
* Adapter 提供的单数据源 SQL Fragment 执行器。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FederationFragmentExecutor {
|
||||
|
||||
/**
|
||||
* 执行参数化 SQL 并返回流式游标。
|
||||
*
|
||||
* @param context 执行上下文
|
||||
* @return 流式游标
|
||||
*/
|
||||
FederationResultCursor execute(FederationFragmentExecutionContext context);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import com.easyagents.federation.sql.adapter.AdapterCompatibility;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.sql.DataSource;
|
||||
|
||||
/**
|
||||
* Adapter 执行单个物理分片 Explain 的上下文。
|
||||
*
|
||||
* @param sql 目标数据库方言参数化 SQL
|
||||
* @param parameters 已按分片参数映射排序的参数
|
||||
* @param dataSource 调用方提供的 DataSource
|
||||
* @param compatibility 数据库与驱动兼容性
|
||||
* @param adapterOptions 不含凭据的 Adapter 选项
|
||||
* @param queryTimeoutSeconds Explain 超时秒数
|
||||
* @param executionGuard 统一查询终态与截止时间检查器
|
||||
*/
|
||||
public record FederationFragmentExplainContext(
|
||||
String sql,
|
||||
List<SqlParameter> parameters,
|
||||
DataSource dataSource,
|
||||
AdapterCompatibility compatibility,
|
||||
Map<String, String> adapterOptions,
|
||||
int queryTimeoutSeconds,
|
||||
FederationExecutionGuard executionGuard
|
||||
) {
|
||||
|
||||
/**
|
||||
* 校验并创建不可变 Explain 上下文。
|
||||
*/
|
||||
public FederationFragmentExplainContext {
|
||||
if (sql == null || sql.isBlank() || dataSource == null || compatibility == null) {
|
||||
throw new IllegalArgumentException("fragment Explain context is incomplete");
|
||||
}
|
||||
if (queryTimeoutSeconds < 0) {
|
||||
throw new IllegalArgumentException("queryTimeoutSeconds must not be negative");
|
||||
}
|
||||
parameters = List.copyOf(parameters == null ? List.of() : parameters);
|
||||
adapterOptions = Map.copyOf(adapterOptions == null ? Map.of() : adapterOptions);
|
||||
executionGuard = executionGuard == null
|
||||
? FederationExecutionGuard.none()
|
||||
: executionGuard;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保留旧 Adapter 与调用方的兼容构造器。
|
||||
*
|
||||
* @param sql 目标数据库 SQL
|
||||
* @param parameters 参数
|
||||
* @param dataSource 数据源
|
||||
* @param compatibility 兼容性信息
|
||||
* @param adapterOptions Adapter 选项
|
||||
* @param queryTimeoutSeconds Explain 超时秒数
|
||||
*/
|
||||
public FederationFragmentExplainContext(
|
||||
String sql,
|
||||
List<SqlParameter> parameters,
|
||||
DataSource dataSource,
|
||||
AdapterCompatibility compatibility,
|
||||
Map<String, String> adapterOptions,
|
||||
int queryTimeoutSeconds
|
||||
) {
|
||||
this(
|
||||
sql,
|
||||
parameters,
|
||||
dataSource,
|
||||
compatibility,
|
||||
adapterOptions,
|
||||
queryTimeoutSeconds,
|
||||
FederationExecutionGuard.none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
/**
|
||||
* Adapter 可选的物理数据库 Explain SPI。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FederationFragmentExplainer {
|
||||
|
||||
/**
|
||||
* 执行不会运行真实数据查询的物理 Explain。
|
||||
*
|
||||
* @param context 分片 Explain 上下文
|
||||
* @return 物理计划
|
||||
*/
|
||||
FederationPhysicalExplain explain(FederationFragmentExplainContext context);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 单个物理分片的查询消耗快照。
|
||||
*
|
||||
* @param fragmentId 分片标识
|
||||
* @param sourceId 物理数据源标识
|
||||
* @param rowsRead 已读取行数
|
||||
* @param bytesRead 已读取估算字节数;Adapter 未安全提供时为 -1
|
||||
* @param elapsedNanos 当前或最终耗时
|
||||
* @param connectionAcquireNanos 获取连接耗时
|
||||
* @param databaseExecutionNanos Statement 返回 ResultSet 的耗时
|
||||
* @param firstRowNanos ResultSet 创建到首行可用的耗时;不可用时为 -1
|
||||
* @param complete 是否已完成或关闭
|
||||
*/
|
||||
public record FederationFragmentMetrics(
|
||||
String fragmentId,
|
||||
SourceId sourceId,
|
||||
long rowsRead,
|
||||
long bytesRead,
|
||||
long elapsedNanos,
|
||||
long connectionAcquireNanos,
|
||||
long databaseExecutionNanos,
|
||||
long firstRowNanos,
|
||||
boolean complete
|
||||
) implements Serializable {
|
||||
|
||||
/**
|
||||
* 创建只包含旧基础字段的兼容分片指标。
|
||||
*
|
||||
* @param fragmentId 分片标识
|
||||
* @param sourceId 数据源
|
||||
* @param rowsRead 读取行数
|
||||
* @param bytesRead 读取字节数
|
||||
* @param elapsedNanos 耗时
|
||||
* @param complete 是否完成
|
||||
*/
|
||||
public FederationFragmentMetrics(
|
||||
String fragmentId,
|
||||
SourceId sourceId,
|
||||
long rowsRead,
|
||||
long bytesRead,
|
||||
long elapsedNanos,
|
||||
boolean complete
|
||||
) {
|
||||
this(fragmentId, sourceId, rowsRead, bytesRead, elapsedNanos, 0, 0, -1, complete);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Calcite 本地联邦算子的累计执行指标。
|
||||
*
|
||||
* @param operatorName 算子名称
|
||||
* @param outputRows 交给下游的输出行数
|
||||
* @param outputBytes 输出估算字节数
|
||||
* @param executionNanos 算子产生输出的累计耗时
|
||||
*/
|
||||
public record FederationLocalOperatorMetrics(
|
||||
String operatorName,
|
||||
long outputRows,
|
||||
long outputBytes,
|
||||
long executionNanos
|
||||
) implements Serializable {
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 物理数据库 Optimizer 的非 ANALYZE Explain 结果。
|
||||
*
|
||||
* @param available 是否获得物理计划
|
||||
* @param nativePlan 数据库原生计划文本
|
||||
* @param nodeType 首个主要计划节点类型
|
||||
* @param scanType 扫描或访问方式
|
||||
* @param candidateIndexes 数据库返回的候选索引
|
||||
* @param chosenIndex 数据库选择的索引
|
||||
* @param estimatedRows 数据库估算行数
|
||||
* @param extraCondition 额外过滤或索引条件
|
||||
* @param diagnostic 不含凭据和参数值的诊断
|
||||
*/
|
||||
public record FederationPhysicalExplain(
|
||||
boolean available,
|
||||
String nativePlan,
|
||||
String nodeType,
|
||||
String scanType,
|
||||
List<String> candidateIndexes,
|
||||
String chosenIndex,
|
||||
Long estimatedRows,
|
||||
String extraCondition,
|
||||
String diagnostic
|
||||
) implements Serializable {
|
||||
|
||||
/**
|
||||
* 防御性复制候选索引。
|
||||
*/
|
||||
public FederationPhysicalExplain {
|
||||
candidateIndexes = List.copyOf(candidateIndexes == null ? List.of() : candidateIndexes);
|
||||
nativePlan = nativePlan == null ? "" : nativePlan;
|
||||
diagnostic = diagnostic == null ? "" : diagnostic;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建数据库不支持或未能提供物理计划的结果。
|
||||
*
|
||||
* @param diagnostic 诊断说明
|
||||
* @return 不可用结果
|
||||
*/
|
||||
public static FederationPhysicalExplain unavailable(String diagnostic) {
|
||||
return new FederationPhysicalExplain(
|
||||
false,
|
||||
"",
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
diagnostic
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.time.Duration;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
/**
|
||||
* 可替换的查询并发准入控制器。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FederationQueryAdmissionController extends AutoCloseable {
|
||||
|
||||
/**
|
||||
* 获取查询许可。
|
||||
*
|
||||
* @param sourceId 数据源标识
|
||||
* @param queryId 查询标识
|
||||
* @param timeout 最大等待时间
|
||||
* @return 查询许可
|
||||
*/
|
||||
FederationQueryPermit acquire(SourceId sourceId, QueryId queryId, Duration timeout);
|
||||
|
||||
/**
|
||||
* 获取支持查询级取消的许可。
|
||||
*
|
||||
* <p>自定义实现可以覆盖此方法及时中断分布式或远程准入等待。</p>
|
||||
*
|
||||
* @param sourceId 数据源标识
|
||||
* @param queryId 查询标识
|
||||
* @param timeout 最大等待时间
|
||||
* @param cancellationRequested 取消状态
|
||||
* @return 查询许可
|
||||
*/
|
||||
default FederationQueryPermit acquire(
|
||||
SourceId sourceId,
|
||||
QueryId queryId,
|
||||
Duration timeout,
|
||||
BooleanSupplier cancellationRequested
|
||||
) {
|
||||
return acquire(sourceId, queryId, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为一次单源或联邦查询获取一份查询级许可。
|
||||
*
|
||||
* <p>兼容实现只接受单源请求。联邦查询必须由实现方明确覆盖本方法,避免其余
|
||||
* 物理源静默绕过源级配额。</p>
|
||||
*
|
||||
* @param request 查询级准入请求
|
||||
* @return 查询许可
|
||||
*/
|
||||
default FederationQueryPermit acquire(QueryAdmissionRequest request) {
|
||||
if (request.sourceIds().size() != 1) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.INVALID_QUERY_SCOPE,
|
||||
"admission controller does not declare multi-source query support"
|
||||
);
|
||||
}
|
||||
return acquire(
|
||||
request.primarySourceId(),
|
||||
request.queryId(),
|
||||
request.timeout(),
|
||||
request.cancellationRequested()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭控制器;默认无额外资源。
|
||||
*/
|
||||
@Override
|
||||
default void close() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回无并发限制的控制器。
|
||||
*
|
||||
* @return 无限制控制器
|
||||
*/
|
||||
static FederationQueryAdmissionController unlimited() {
|
||||
return new FederationQueryAdmissionController() {
|
||||
@Override
|
||||
public FederationQueryPermit acquire(
|
||||
SourceId sourceId,
|
||||
QueryId queryId,
|
||||
Duration timeout
|
||||
) {
|
||||
return () -> { };
|
||||
}
|
||||
|
||||
@Override
|
||||
public FederationQueryPermit acquire(QueryAdmissionRequest request) {
|
||||
return () -> { };
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import com.easyagents.federation.sql.federation.FederationQueryMode;
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 查询游标当前或关闭后的不可变消耗指标快照。
|
||||
*
|
||||
* @param queryId 查询标识;不可用快照可为空
|
||||
* @param queryMode 查询模式
|
||||
* @param planCacheHit 是否命中计划缓存
|
||||
* @param planningNanos 编译阶段耗时
|
||||
* @param admissionWaitNanos 准入等待耗时
|
||||
* @param connectionAcquireNanos 全部分片获取连接累计耗时
|
||||
* @param databaseExecutionNanos 全部分片执行 Statement 累计耗时
|
||||
* @param localExecutionNanos 本地算子累计耗时
|
||||
* @param executionNanos 从执行开始到当前或结束的耗时
|
||||
* @param firstRowNanos 从执行开始到首行的耗时;尚未返回首行时为 -1
|
||||
* @param returnedRows 调用方已消费的最终结果行数
|
||||
* @param returnedBytes 最终结果估算字节数;Adapter 未安全提供时为 -1
|
||||
* @param intermediateRows 全部分片读取的中间结果行数
|
||||
* @param intermediateBytes 全部分片读取的中间结果估算字节数;Adapter 未安全提供时为 -1
|
||||
* @param complete 查询是否正常消费完成
|
||||
* @param cancelled 查询是否因取消结束
|
||||
* @param timedOut 查询是否因统一执行时限结束
|
||||
* @param truncated 是否因最终行数上限停止继续消费
|
||||
* @param terminalErrorCode 失败终态错误码;成功或尚未失败时为空
|
||||
* @param fragments 分片指标
|
||||
* @param localOperators 本地算子指标
|
||||
*/
|
||||
public record FederationQueryMetricsSnapshot(
|
||||
QueryId queryId,
|
||||
FederationQueryMode queryMode,
|
||||
boolean planCacheHit,
|
||||
long planningNanos,
|
||||
long admissionWaitNanos,
|
||||
long connectionAcquireNanos,
|
||||
long databaseExecutionNanos,
|
||||
long localExecutionNanos,
|
||||
long executionNanos,
|
||||
long firstRowNanos,
|
||||
long returnedRows,
|
||||
long returnedBytes,
|
||||
long intermediateRows,
|
||||
long intermediateBytes,
|
||||
boolean complete,
|
||||
boolean cancelled,
|
||||
boolean timedOut,
|
||||
boolean truncated,
|
||||
String terminalErrorCode,
|
||||
List<FederationFragmentMetrics> fragments,
|
||||
List<FederationLocalOperatorMetrics> localOperators
|
||||
) implements Serializable {
|
||||
|
||||
/**
|
||||
* 防御性复制分片指标。
|
||||
*/
|
||||
public FederationQueryMetricsSnapshot {
|
||||
fragments = List.copyOf(fragments == null ? List.of() : fragments);
|
||||
localOperators = List.copyOf(localOperators == null ? List.of() : localOperators);
|
||||
terminalErrorCode = terminalErrorCode == null ? "" : terminalErrorCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建旧基础字段视图的兼容指标快照。
|
||||
*
|
||||
* @param queryId 查询标识
|
||||
* @param queryMode 查询模式
|
||||
* @param planCacheHit 是否命中计划缓存
|
||||
* @param planningNanos 编译耗时
|
||||
* @param executionNanos 执行耗时
|
||||
* @param firstRowNanos 首行耗时
|
||||
* @param returnedRows 返回行数
|
||||
* @param returnedBytes 返回字节数
|
||||
* @param intermediateRows 中间行数
|
||||
* @param intermediateBytes 中间字节数
|
||||
* @param complete 是否完成
|
||||
* @param cancelled 是否取消
|
||||
* @param fragments 分片指标
|
||||
*/
|
||||
public FederationQueryMetricsSnapshot(
|
||||
QueryId queryId,
|
||||
FederationQueryMode queryMode,
|
||||
boolean planCacheHit,
|
||||
long planningNanos,
|
||||
long executionNanos,
|
||||
long firstRowNanos,
|
||||
long returnedRows,
|
||||
long returnedBytes,
|
||||
long intermediateRows,
|
||||
long intermediateBytes,
|
||||
boolean complete,
|
||||
boolean cancelled,
|
||||
List<FederationFragmentMetrics> fragments
|
||||
) {
|
||||
this(
|
||||
queryId,
|
||||
queryMode,
|
||||
planCacheHit,
|
||||
planningNanos,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
executionNanos,
|
||||
firstRowNanos,
|
||||
returnedRows,
|
||||
returnedBytes,
|
||||
intermediateRows,
|
||||
intermediateBytes,
|
||||
complete,
|
||||
cancelled,
|
||||
false,
|
||||
false,
|
||||
"",
|
||||
fragments,
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回第三方 Adapter 尚未接入指标时的空快照。
|
||||
*
|
||||
* @return 空快照
|
||||
*/
|
||||
public static FederationQueryMetricsSnapshot unavailable() {
|
||||
return new FederationQueryMetricsSnapshot(
|
||||
null,
|
||||
FederationQueryMode.SINGLE_SOURCE,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
-1,
|
||||
0,
|
||||
-1,
|
||||
0,
|
||||
-1,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
"",
|
||||
List.of(),
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
/**
|
||||
* 查询准入许可,关闭时释放并发配额。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FederationQueryPermit extends AutoCloseable {
|
||||
|
||||
/**
|
||||
* 释放准入许可。
|
||||
*/
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.Reader;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 按行消费且必须关闭的流式结果游标。
|
||||
*/
|
||||
public interface FederationResultCursor extends AutoCloseable {
|
||||
|
||||
/**
|
||||
* 返回查询标识。
|
||||
*
|
||||
* @return 查询标识
|
||||
*/
|
||||
QueryId queryId();
|
||||
|
||||
/**
|
||||
* 返回结果列元数据。
|
||||
*
|
||||
* @return 不可变列列表
|
||||
*/
|
||||
List<FederationColumn> columns();
|
||||
|
||||
/**
|
||||
* 返回查询当前或关闭后的消耗指标快照。
|
||||
*
|
||||
* @return 不可变指标快照
|
||||
*/
|
||||
default FederationQueryMetricsSnapshot metrics() {
|
||||
return FederationQueryMetricsSnapshot.unavailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动至下一行。
|
||||
*
|
||||
* @return 是否存在下一行
|
||||
*/
|
||||
boolean next();
|
||||
|
||||
/**
|
||||
* 按 JDBC 列序号读取当前行。
|
||||
*
|
||||
* @param columnIndex 从 1 开始的列序号
|
||||
* @return 列值
|
||||
*/
|
||||
Object getObject(int columnIndex);
|
||||
|
||||
/**
|
||||
* 以流方式读取二进制列,避免调用方为大字段一次性分配完整字节数组。
|
||||
*
|
||||
* @param columnIndex 从 1 开始的列序号
|
||||
* @return 二进制流;SQL NULL 返回 null
|
||||
* @throws UnsupportedOperationException Adapter 不支持流式列读取
|
||||
*/
|
||||
default InputStream getBinaryStream(int columnIndex) {
|
||||
throw new UnsupportedOperationException("binary stream access is not supported by this adapter");
|
||||
}
|
||||
|
||||
/**
|
||||
* 以流方式读取字符列,避免调用方为大字段一次性分配完整字符串。
|
||||
*
|
||||
* @param columnIndex 从 1 开始的列序号
|
||||
* @return 字符流;SQL NULL 返回 null
|
||||
* @throws UnsupportedOperationException Adapter 不支持流式列读取
|
||||
*/
|
||||
default Reader getCharacterStream(int columnIndex) {
|
||||
throw new UnsupportedOperationException("character stream access is not supported by this adapter");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前行复制为不可变列表。
|
||||
*
|
||||
* @return 当前行列值
|
||||
*/
|
||||
List<Object> row();
|
||||
|
||||
/**
|
||||
* 关闭结果集、Statement、Connection、准入许可和 Runtime lease。
|
||||
*/
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
/**
|
||||
* 使用公平信号量限制单节点查询并发的默认控制器。
|
||||
*/
|
||||
public final class LocalFederationQueryAdmissionController implements FederationQueryAdmissionController {
|
||||
|
||||
private final int maxConcurrentQueries;
|
||||
private final Semaphore permits;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
/**
|
||||
* 创建本地准入控制器。
|
||||
*
|
||||
* @param maxConcurrentQueries 最大并发查询数
|
||||
*/
|
||||
public LocalFederationQueryAdmissionController(int maxConcurrentQueries) {
|
||||
if (maxConcurrentQueries <= 0) {
|
||||
throw new IllegalArgumentException("maxConcurrentQueries must be positive");
|
||||
}
|
||||
this.maxConcurrentQueries = maxConcurrentQueries;
|
||||
this.permits = new Semaphore(maxConcurrentQueries, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定时间内获取本地许可。
|
||||
*
|
||||
* @param sourceId 数据源标识
|
||||
* @param queryId 查询标识
|
||||
* @param timeout 最大等待时间
|
||||
* @return 可幂等关闭的许可
|
||||
*/
|
||||
@Override
|
||||
public FederationQueryPermit acquire(SourceId sourceId, QueryId queryId, Duration timeout) {
|
||||
return acquire(sourceId, queryId, timeout, () -> false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在等待本地许可期间轮询查询取消状态。
|
||||
*
|
||||
* @param sourceId 数据源标识
|
||||
* @param queryId 查询标识
|
||||
* @param timeout 最大等待时间
|
||||
* @param cancellationRequested 取消状态
|
||||
* @return 可幂等关闭的许可
|
||||
*/
|
||||
@Override
|
||||
public FederationQueryPermit acquire(
|
||||
SourceId sourceId,
|
||||
QueryId queryId,
|
||||
Duration timeout,
|
||||
BooleanSupplier cancellationRequested
|
||||
) {
|
||||
ensureOpen();
|
||||
if (cancellationRequested.getAsBoolean()) {
|
||||
throw cancelled(queryId);
|
||||
}
|
||||
boolean acquired = false;
|
||||
try {
|
||||
long timeoutNanos = timeout.toNanos();
|
||||
if (timeoutNanos == 0) {
|
||||
acquired = permits.tryAcquire();
|
||||
} else {
|
||||
long started = System.nanoTime();
|
||||
long remaining = timeoutNanos;
|
||||
long pollNanos = TimeUnit.MILLISECONDS.toNanos(50);
|
||||
while (!acquired && remaining > 0) {
|
||||
acquired = permits.tryAcquire(Math.min(remaining, pollNanos), TimeUnit.NANOSECONDS);
|
||||
if (!acquired && cancellationRequested.getAsBoolean()) {
|
||||
throw cancelled(queryId);
|
||||
}
|
||||
remaining = timeoutNanos - (System.nanoTime() - started);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.QUERY_ADMISSION_TIMEOUT,
|
||||
"query admission was interrupted for source " + sourceId,
|
||||
exception
|
||||
);
|
||||
}
|
||||
if (cancellationRequested.getAsBoolean()) {
|
||||
if (acquired) {
|
||||
permits.release();
|
||||
}
|
||||
throw cancelled(queryId);
|
||||
}
|
||||
if (!acquired) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.QUERY_ADMISSION_TIMEOUT,
|
||||
"query admission timed out for source " + sourceId
|
||||
);
|
||||
}
|
||||
if (closed.get()) {
|
||||
permits.release();
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.ENGINE_CLOSED,
|
||||
"query admission controller is closed"
|
||||
);
|
||||
}
|
||||
AtomicBoolean released = new AtomicBoolean();
|
||||
return () -> {
|
||||
if (released.compareAndSet(false, true)) {
|
||||
permits.release();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 为单源或联邦查询获取一份节点级本地许可。
|
||||
*
|
||||
* @param request 查询级准入请求
|
||||
* @return 可幂等关闭的节点许可
|
||||
*/
|
||||
@Override
|
||||
public FederationQueryPermit acquire(QueryAdmissionRequest request) {
|
||||
return acquire(
|
||||
request.primarySourceId(),
|
||||
request.queryId(),
|
||||
request.timeout(),
|
||||
request.cancellationRequested()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭控制器并唤醒等待准入的线程。
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
permits.release(maxConcurrentQueries);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureOpen() {
|
||||
if (closed.get()) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.ENGINE_CLOSED,
|
||||
"query admission controller is closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static FederationSqlException cancelled(QueryId queryId) {
|
||||
return new FederationSqlException(
|
||||
FederationSqlErrorCode.QUERY_CANCELLED,
|
||||
"query admission was cancelled: " + queryId.value()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.time.Duration;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
/**
|
||||
* 单次单源或联邦查询的准入请求。
|
||||
*
|
||||
* @param sourceIds 查询实际引用的去重物理数据源,按稳定顺序排列
|
||||
* @param queryId 查询标识
|
||||
* @param timeout 最大等待时间
|
||||
* @param cancellationRequested 查询取消状态
|
||||
*/
|
||||
public record QueryAdmissionRequest(
|
||||
List<SourceId> sourceIds,
|
||||
QueryId queryId,
|
||||
Duration timeout,
|
||||
BooleanSupplier cancellationRequested
|
||||
) {
|
||||
|
||||
/**
|
||||
* 校验并创建不可变准入请求。
|
||||
*/
|
||||
public QueryAdmissionRequest {
|
||||
if (sourceIds == null || sourceIds.isEmpty() || queryId == null) {
|
||||
throw new IllegalArgumentException("sourceIds and queryId must be provided");
|
||||
}
|
||||
sourceIds = sourceIds.stream()
|
||||
.distinct()
|
||||
.sorted(Comparator.comparing(SourceId::value))
|
||||
.toList();
|
||||
timeout = timeout == null ? Duration.ZERO : timeout;
|
||||
cancellationRequested = cancellationRequested == null
|
||||
? () -> false
|
||||
: cancellationRequested;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回兼容单源准入实现使用的首个数据源。
|
||||
*
|
||||
* @return 稳定排序后的首个数据源
|
||||
*/
|
||||
public SourceId primarySourceId() {
|
||||
return sourceIds.get(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 节点本地查询标识。
|
||||
*
|
||||
* @param value 查询标识文本
|
||||
*/
|
||||
public record QueryId(String value) implements Serializable {
|
||||
|
||||
/**
|
||||
* 校验查询标识。
|
||||
*/
|
||||
public QueryId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("query id must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建随机查询标识。
|
||||
*
|
||||
* @return 查询标识
|
||||
*/
|
||||
public static QueryId create() {
|
||||
return new QueryId(UUID.randomUUID().toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 不可由 SQL 文本覆盖的 JDBC 执行限制。
|
||||
*
|
||||
* @param fetchSize 驱动抓取批次,0 表示驱动默认
|
||||
* @param maxRows 最大返回行数,0 表示不额外限制
|
||||
* @param queryTimeoutSeconds 查询超时秒数,0 表示驱动默认
|
||||
* @param readOnly 是否强制只读连接;正式 JDBC 路径必须为 true
|
||||
*/
|
||||
public record SqlExecutionOptions(
|
||||
int fetchSize,
|
||||
int maxRows,
|
||||
int queryTimeoutSeconds,
|
||||
boolean readOnly
|
||||
) implements Serializable {
|
||||
|
||||
/**
|
||||
* 校验执行限制。
|
||||
*/
|
||||
public SqlExecutionOptions {
|
||||
if (fetchSize < 0 || maxRows < 0 || queryTimeoutSeconds < 0) {
|
||||
throw new IllegalArgumentException("execution limits must not be negative");
|
||||
}
|
||||
if (!readOnly) {
|
||||
throw new IllegalArgumentException("federation SQL execution must remain read-only");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回适合普通只读流式查询的默认配置。
|
||||
*
|
||||
* @return 默认配置
|
||||
*/
|
||||
public static SqlExecutionOptions defaults() {
|
||||
return new SqlExecutionOptions(500, 0, 30, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.sql.Types;
|
||||
|
||||
/**
|
||||
* 可序列化边界内的 JDBC 标量参数值与显式类型。
|
||||
*
|
||||
* <p>默认 Adapter 将 {@link Types#OTHER} 解释为 UUID;厂商专有 OTHER 类型应由
|
||||
* 对应 Adapter 覆盖参数类型映射。</p>
|
||||
*
|
||||
* @param jdbcType {@link Types} 类型值
|
||||
* @param value 参数值
|
||||
*/
|
||||
public record SqlParameter(int jdbcType, Object value) implements Serializable {
|
||||
|
||||
/**
|
||||
* 校验跨节点参数值属于稳定、无嵌套对象图的 JDBC 标量类型。
|
||||
*/
|
||||
public SqlParameter {
|
||||
if (jdbcType == Types.NULL) {
|
||||
throw new IllegalArgumentException("an explicit JDBC type is required for NULL parameters");
|
||||
}
|
||||
if (value != null && !isSupportedScalar(value)) {
|
||||
throw new IllegalArgumentException(
|
||||
"SQL parameter value must be a supported serializable JDBC scalar"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据常用 Java 值推断 JDBC 类型。
|
||||
*
|
||||
* @param value 参数值
|
||||
* @return 参数
|
||||
*/
|
||||
public static SqlParameter of(Object value) {
|
||||
return new SqlParameter(inferType(value), value);
|
||||
}
|
||||
|
||||
private static int inferType(Object value) {
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException("use new SqlParameter(jdbcType, null) for NULL values");
|
||||
}
|
||||
if (value instanceof Integer || value instanceof Short || value instanceof Byte) {
|
||||
return Types.INTEGER;
|
||||
}
|
||||
if (value instanceof Long) {
|
||||
return Types.BIGINT;
|
||||
}
|
||||
if (value instanceof Float) {
|
||||
return Types.REAL;
|
||||
}
|
||||
if (value instanceof Double) {
|
||||
return Types.DOUBLE;
|
||||
}
|
||||
if (value instanceof java.math.BigDecimal || value instanceof java.math.BigInteger) {
|
||||
return Types.DECIMAL;
|
||||
}
|
||||
if (value instanceof Boolean) {
|
||||
return Types.BOOLEAN;
|
||||
}
|
||||
if (value instanceof java.sql.Date || value instanceof java.time.LocalDate) {
|
||||
return Types.DATE;
|
||||
}
|
||||
if (value instanceof java.sql.Time || value instanceof java.time.LocalTime) {
|
||||
return Types.TIME;
|
||||
}
|
||||
if (value instanceof java.time.OffsetTime) {
|
||||
return Types.TIME_WITH_TIMEZONE;
|
||||
}
|
||||
if (value instanceof java.time.OffsetDateTime) {
|
||||
return Types.TIMESTAMP_WITH_TIMEZONE;
|
||||
}
|
||||
if (value instanceof java.sql.Timestamp || value instanceof java.time.LocalDateTime
|
||||
|| value instanceof java.time.Instant) {
|
||||
return Types.TIMESTAMP;
|
||||
}
|
||||
if (value instanceof byte[]) {
|
||||
return Types.VARBINARY;
|
||||
}
|
||||
if (value instanceof java.util.UUID) {
|
||||
return Types.OTHER;
|
||||
}
|
||||
return Types.VARCHAR;
|
||||
}
|
||||
|
||||
private static boolean isSupportedScalar(Object value) {
|
||||
return value instanceof String
|
||||
|| value instanceof Character
|
||||
|| value instanceof Boolean
|
||||
|| value instanceof Byte
|
||||
|| value instanceof Short
|
||||
|| value instanceof Integer
|
||||
|| value instanceof Long
|
||||
|| value instanceof Float
|
||||
|| value instanceof Double
|
||||
|| value instanceof java.math.BigDecimal
|
||||
|| value instanceof java.math.BigInteger
|
||||
|| value instanceof byte[]
|
||||
|| value instanceof java.sql.Date
|
||||
|| value instanceof java.sql.Time
|
||||
|| value instanceof java.sql.Timestamp
|
||||
|| value instanceof java.time.LocalDate
|
||||
|| value instanceof java.time.LocalTime
|
||||
|| value instanceof java.time.LocalDateTime
|
||||
|| value instanceof java.time.OffsetTime
|
||||
|| value instanceof java.time.OffsetDateTime
|
||||
|| value instanceof java.time.Instant
|
||||
|| value instanceof java.util.UUID;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.easyagents.federation.sql.execute;
|
||||
|
||||
import java.sql.Statement;
|
||||
|
||||
/**
|
||||
* Adapter 用于登记和清理可取消 Statement 的回调。
|
||||
*/
|
||||
public interface StatementLifecycle {
|
||||
|
||||
/**
|
||||
* 登记正在执行的 Statement。
|
||||
*
|
||||
* @param statement JDBC Statement
|
||||
*/
|
||||
void register(Statement statement);
|
||||
|
||||
/**
|
||||
* 清除已结束的 Statement。
|
||||
*
|
||||
* @param statement JDBC Statement
|
||||
*/
|
||||
void unregister(Statement statement);
|
||||
|
||||
/**
|
||||
* 返回当前查询是否已收到主动取消请求。
|
||||
*
|
||||
* @return 是否已请求取消
|
||||
*/
|
||||
default boolean cancellationRequested() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前查询是否已经超过统一执行时限。
|
||||
*
|
||||
* @return 是否已超时
|
||||
*/
|
||||
default boolean timeoutRequested() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 一列用于成本估算的轻量统计。
|
||||
*
|
||||
* @param distinctCount 估算不同值数量;未知时为 0
|
||||
* @param nullFraction 空值比例,范围为 0 到 1
|
||||
* @param averageWidthBytes 平均列宽字节数;未知时为 0
|
||||
*/
|
||||
public record FederationColumnStatistics(
|
||||
double distinctCount,
|
||||
double nullFraction,
|
||||
long averageWidthBytes
|
||||
) implements Serializable {
|
||||
|
||||
/**
|
||||
* 校验列统计。
|
||||
*/
|
||||
public FederationColumnStatistics {
|
||||
if (!Double.isFinite(distinctCount) || distinctCount < 0) {
|
||||
throw new IllegalArgumentException("distinctCount must be finite and non-negative");
|
||||
}
|
||||
if (!Double.isFinite(nullFraction) || nullFraction < 0 || nullFraction > 1) {
|
||||
throw new IllegalArgumentException("nullFraction must be between 0 and 1");
|
||||
}
|
||||
if (averageWidthBytes < 0) {
|
||||
throw new IllegalArgumentException("averageWidthBytes must be non-negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 一个物理分片的轻量搬运成本估算。
|
||||
*
|
||||
* @param estimatedRows 分片输出估算行数
|
||||
* @param estimatedRowWidthBytes 分片输出估算行宽
|
||||
* @param estimatedTransferBytes 分片到本地执行器的估算搬运字节数
|
||||
* @param statisticsSource 统计来源
|
||||
* @param statisticsSnapshotVersion 统计快照版本
|
||||
* @param statisticsCollectedAt 外部统计采集时间;缺失时为 epoch
|
||||
* @param statisticsMissing 是否完全使用 Calcite 默认估算
|
||||
* @param statisticsStatus 统计完整性与时效状态
|
||||
* @param estimateAvailable 当前数值是否为有效估算;兼容旧计划缺少估算时为 false
|
||||
*/
|
||||
public record FederationCostEstimate(
|
||||
double estimatedRows,
|
||||
long estimatedRowWidthBytes,
|
||||
double estimatedTransferBytes,
|
||||
String statisticsSource,
|
||||
String statisticsSnapshotVersion,
|
||||
Instant statisticsCollectedAt,
|
||||
boolean statisticsMissing,
|
||||
FederationStatisticsStatus statisticsStatus,
|
||||
boolean estimateAvailable
|
||||
) implements Serializable {
|
||||
|
||||
/**
|
||||
* 校验并规范化成本估算。
|
||||
*/
|
||||
public FederationCostEstimate {
|
||||
if (!Double.isFinite(estimatedRows) || estimatedRows < 0
|
||||
|| estimatedRowWidthBytes < 0
|
||||
|| !Double.isFinite(estimatedTransferBytes)
|
||||
|| estimatedTransferBytes < 0) {
|
||||
throw new IllegalArgumentException("cost estimate values must be finite and non-negative");
|
||||
}
|
||||
statisticsSource = statisticsSource == null || statisticsSource.isBlank()
|
||||
? "calcite-default"
|
||||
: statisticsSource;
|
||||
statisticsSnapshotVersion = statisticsSnapshotVersion == null
|
||||
? "none"
|
||||
: statisticsSnapshotVersion;
|
||||
statisticsCollectedAt = statisticsCollectedAt == null
|
||||
? Instant.EPOCH
|
||||
: statisticsCollectedAt;
|
||||
statisticsStatus = statisticsStatus == null
|
||||
? statisticsMissing
|
||||
? FederationStatisticsStatus.MISSING
|
||||
: FederationStatisticsStatus.COMPLETE
|
||||
: statisticsStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带显式统计状态的有效成本估算。
|
||||
*
|
||||
* @param estimatedRows 估算行数
|
||||
* @param estimatedRowWidthBytes 估算行宽
|
||||
* @param estimatedTransferBytes 估算搬运字节
|
||||
* @param statisticsSource 统计来源
|
||||
* @param statisticsSnapshotVersion 统计版本
|
||||
* @param statisticsCollectedAt 采集时间
|
||||
* @param statisticsMissing 是否缺失统计
|
||||
* @param statisticsStatus 统计状态
|
||||
*/
|
||||
public FederationCostEstimate(
|
||||
double estimatedRows,
|
||||
long estimatedRowWidthBytes,
|
||||
double estimatedTransferBytes,
|
||||
String statisticsSource,
|
||||
String statisticsSnapshotVersion,
|
||||
Instant statisticsCollectedAt,
|
||||
boolean statisticsMissing,
|
||||
FederationStatisticsStatus statisticsStatus
|
||||
) {
|
||||
this(
|
||||
estimatedRows,
|
||||
estimatedRowWidthBytes,
|
||||
estimatedTransferBytes,
|
||||
statisticsSource,
|
||||
statisticsSnapshotVersion,
|
||||
statisticsCollectedAt,
|
||||
statisticsMissing,
|
||||
statisticsStatus,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建旧字段集合的兼容成本估算。
|
||||
*
|
||||
* @param estimatedRows 估算行数
|
||||
* @param estimatedRowWidthBytes 估算行宽
|
||||
* @param estimatedTransferBytes 估算搬运字节
|
||||
* @param statisticsSource 统计来源
|
||||
* @param statisticsSnapshotVersion 统计版本
|
||||
* @param statisticsCollectedAt 采集时间
|
||||
* @param statisticsMissing 是否缺失统计
|
||||
*/
|
||||
public FederationCostEstimate(
|
||||
double estimatedRows,
|
||||
long estimatedRowWidthBytes,
|
||||
double estimatedTransferBytes,
|
||||
String statisticsSource,
|
||||
String statisticsSnapshotVersion,
|
||||
Instant statisticsCollectedAt,
|
||||
boolean statisticsMissing
|
||||
) {
|
||||
this(
|
||||
estimatedRows,
|
||||
estimatedRowWidthBytes,
|
||||
estimatedTransferBytes,
|
||||
statisticsSource,
|
||||
statisticsSnapshotVersion,
|
||||
statisticsCollectedAt,
|
||||
statisticsMissing,
|
||||
statisticsMissing
|
||||
? FederationStatisticsStatus.MISSING
|
||||
: FederationStatisticsStatus.COMPLETE,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 联邦查询的调用方资源上限;Engine 会与自己的硬上限取更严格值。
|
||||
*
|
||||
* @param maximumReferencedSources 单条 SQL 最多实际引用的数据源数
|
||||
* @param maximumFragments 最多物理查询分片数
|
||||
* @param maximumConcurrentFragments 最大并发分片数
|
||||
* @param maximumIntermediateRows 最多读取的中间结果行数
|
||||
* @param maximumIntermediateBytes 最多读取的中间结果估算字节数
|
||||
* @param maximumExecutionTimeMillis 联邦执行总时限
|
||||
*/
|
||||
public record FederationExecutionPolicy(
|
||||
int maximumReferencedSources,
|
||||
int maximumFragments,
|
||||
int maximumConcurrentFragments,
|
||||
long maximumIntermediateRows,
|
||||
long maximumIntermediateBytes,
|
||||
long maximumExecutionTimeMillis
|
||||
) implements Serializable {
|
||||
|
||||
private static final FederationExecutionPolicy BASIC = new FederationExecutionPolicy(
|
||||
2,
|
||||
8,
|
||||
2,
|
||||
100_000,
|
||||
64L * 1024L * 1024L,
|
||||
60_000
|
||||
);
|
||||
|
||||
/**
|
||||
* 校验资源上限。
|
||||
*/
|
||||
public FederationExecutionPolicy {
|
||||
if (maximumReferencedSources <= 0
|
||||
|| maximumFragments <= 0
|
||||
|| maximumConcurrentFragments <= 0
|
||||
|| maximumIntermediateRows <= 0
|
||||
|| maximumIntermediateBytes <= 0
|
||||
|| maximumExecutionTimeMillis <= 0) {
|
||||
throw new IllegalArgumentException("federation execution limits must be positive");
|
||||
}
|
||||
if (maximumConcurrentFragments > maximumFragments) {
|
||||
throw new IllegalArgumentException(
|
||||
"maximumConcurrentFragments must not exceed maximumFragments"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回适合首批联邦查询的保守默认策略。
|
||||
*
|
||||
* @return 默认策略
|
||||
*/
|
||||
public static FederationExecutionPolicy basic() {
|
||||
return BASIC;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将两个策略收敛为逐项更严格的有效策略。
|
||||
*
|
||||
* @param other 另一个策略
|
||||
* @return 有效策略
|
||||
*/
|
||||
public FederationExecutionPolicy intersect(FederationExecutionPolicy other) {
|
||||
if (other == null) {
|
||||
return this;
|
||||
}
|
||||
return new FederationExecutionPolicy(
|
||||
Math.min(maximumReferencedSources, other.maximumReferencedSources),
|
||||
Math.min(maximumFragments, other.maximumFragments),
|
||||
Math.min(maximumConcurrentFragments, other.maximumConcurrentFragments),
|
||||
Math.min(maximumIntermediateRows, other.maximumIntermediateRows),
|
||||
Math.min(maximumIntermediateBytes, other.maximumIntermediateBytes),
|
||||
Math.min(maximumExecutionTimeMillis, other.maximumExecutionTimeMillis)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
import com.easyagents.federation.sql.execute.FederationColumn;
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 一个可交给物理数据源 Adapter 执行的查询分片。
|
||||
*
|
||||
* @param fragmentId 计划内唯一分片标识
|
||||
* @param bindingName 查询范围 Binding 名称
|
||||
* @param sourceId 物理数据源标识
|
||||
* @param executableSql 目标数据库方言 SQL
|
||||
* @param parameterMapping 分片占位符到原始查询参数的零基索引映射
|
||||
* @param columns 分片输出列
|
||||
* @param costEstimate 分片搬运成本估算
|
||||
* @param pushedDownOperators 已下推至物理源的关系算子
|
||||
*/
|
||||
public record FederationFragmentPlan(
|
||||
String fragmentId,
|
||||
String bindingName,
|
||||
SourceId sourceId,
|
||||
String executableSql,
|
||||
List<Integer> parameterMapping,
|
||||
List<FederationColumn> columns,
|
||||
FederationCostEstimate costEstimate,
|
||||
List<String> pushedDownOperators
|
||||
) {
|
||||
|
||||
/**
|
||||
* 校验并创建不可变分片计划。
|
||||
*/
|
||||
public FederationFragmentPlan {
|
||||
if (fragmentId == null || fragmentId.isBlank()
|
||||
|| bindingName == null || bindingName.isBlank()
|
||||
|| sourceId == null || executableSql == null || executableSql.isBlank()) {
|
||||
throw new IllegalArgumentException("fragment identity and SQL must be provided");
|
||||
}
|
||||
parameterMapping = List.copyOf(parameterMapping == null ? List.of() : parameterMapping);
|
||||
columns = List.copyOf(columns == null ? List.of() : columns);
|
||||
if (costEstimate == null) {
|
||||
throw new IllegalArgumentException("costEstimate must be provided");
|
||||
}
|
||||
pushedDownOperators = List.copyOf(
|
||||
pushedDownOperators == null ? List.of() : pushedDownOperators
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建不携带显式成本输入的兼容分片计划。
|
||||
*
|
||||
* @param fragmentId 计划内唯一分片标识
|
||||
* @param bindingName 查询范围 Binding 名称
|
||||
* @param sourceId 物理数据源标识
|
||||
* @param executableSql 目标数据库方言 SQL
|
||||
* @param parameterMapping 参数映射
|
||||
* @param columns 输出列
|
||||
*/
|
||||
public FederationFragmentPlan(
|
||||
String fragmentId,
|
||||
String bindingName,
|
||||
SourceId sourceId,
|
||||
String executableSql,
|
||||
List<Integer> parameterMapping,
|
||||
List<FederationColumn> columns
|
||||
) {
|
||||
this(
|
||||
fragmentId,
|
||||
bindingName,
|
||||
sourceId,
|
||||
executableSql,
|
||||
parameterMapping,
|
||||
columns,
|
||||
new FederationCostEstimate(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"calcite-default",
|
||||
"none",
|
||||
java.time.Instant.EPOCH,
|
||||
true,
|
||||
FederationStatisticsStatus.MISSING,
|
||||
false
|
||||
),
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
/**
|
||||
* 联邦本地 Join 的执行算法。
|
||||
*/
|
||||
public enum FederationJoinAlgorithm {
|
||||
|
||||
/** 在构建侧建立哈希表后探测。 */
|
||||
HASH_JOIN
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 一次跨源 Join 的优化结果。
|
||||
*
|
||||
* @param stageIndex 执行阶段序号,从 1 开始
|
||||
* @param leftBindings 左输入包含的 Binding 集合
|
||||
* @param rightBindings 右输入包含的 Binding 集合
|
||||
* @param buildBinding 哈希表构建侧 Binding
|
||||
* @param algorithm Join 算法
|
||||
* @param reason 选择原因
|
||||
* @param estimatedBuildBytes 构建侧估算字节数
|
||||
*/
|
||||
public record FederationJoinOptimization(
|
||||
int stageIndex,
|
||||
List<String> leftBindings,
|
||||
List<String> rightBindings,
|
||||
String buildBinding,
|
||||
FederationJoinAlgorithm algorithm,
|
||||
FederationJoinSelectionReason reason,
|
||||
double estimatedBuildBytes
|
||||
) implements Serializable {
|
||||
|
||||
/**
|
||||
* 校验并规范化优化结果。
|
||||
*/
|
||||
public FederationJoinOptimization {
|
||||
if (stageIndex <= 0) {
|
||||
throw new IllegalArgumentException("stageIndex must be positive");
|
||||
}
|
||||
leftBindings = List.copyOf(leftBindings == null ? List.of() : leftBindings);
|
||||
rightBindings = List.copyOf(rightBindings == null ? List.of() : rightBindings);
|
||||
if (leftBindings.isEmpty() || rightBindings.isEmpty()
|
||||
|| leftBindings.stream().anyMatch(value -> value == null || value.isBlank())
|
||||
|| rightBindings.stream().anyMatch(value -> value == null || value.isBlank())
|
||||
|| buildBinding == null || buildBinding.isBlank()) {
|
||||
throw new IllegalArgumentException("join binding names must not be blank");
|
||||
}
|
||||
if (!Double.isFinite(estimatedBuildBytes) || estimatedBuildBytes < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"estimatedBuildBytes must be finite and non-negative"
|
||||
);
|
||||
}
|
||||
algorithm = algorithm == null ? FederationJoinAlgorithm.HASH_JOIN : algorithm;
|
||||
reason = reason == null
|
||||
? FederationJoinSelectionReason.INCOMPLETE_STATISTICS
|
||||
: reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建兼容的两输入单阶段优化结果。
|
||||
*
|
||||
* @param leftBinding 左输入 Binding
|
||||
* @param rightBinding 右输入 Binding
|
||||
* @param buildBinding 哈希构建侧 Binding
|
||||
* @param algorithm Join 算法
|
||||
* @param reason 选择原因
|
||||
* @param estimatedBuildBytes 预计构建字节数
|
||||
*/
|
||||
public FederationJoinOptimization(
|
||||
String leftBinding,
|
||||
String rightBinding,
|
||||
String buildBinding,
|
||||
FederationJoinAlgorithm algorithm,
|
||||
FederationJoinSelectionReason reason,
|
||||
double estimatedBuildBytes
|
||||
) {
|
||||
this(
|
||||
1,
|
||||
List.of(leftBinding),
|
||||
List.of(rightBinding),
|
||||
buildBinding,
|
||||
algorithm,
|
||||
reason,
|
||||
estimatedBuildBytes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回左输入的紧凑展示名称。
|
||||
*
|
||||
* @return 单个 Binding 或多个 Binding 的组合名称
|
||||
*/
|
||||
public String leftBinding() {
|
||||
return String.join(" + ", leftBindings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回右输入的紧凑展示名称。
|
||||
*
|
||||
* @return 单个 Binding 或多个 Binding 的组合名称
|
||||
*/
|
||||
public String rightBinding() {
|
||||
return String.join(" + ", rightBindings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
/**
|
||||
* Join 构建侧的选择原因。
|
||||
*/
|
||||
public enum FederationJoinSelectionReason {
|
||||
|
||||
/** 可信表级统计表明当前构建侧搬运量更小。 */
|
||||
SMALLER_BUILD_SIDE,
|
||||
|
||||
/** 外连接语义要求保留输入顺序。 */
|
||||
JOIN_SEMANTICS,
|
||||
|
||||
/** 统计不完整,保留稳定默认顺序。 */
|
||||
INCOMPLETE_STATISTICS
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 查询范围内一张逻辑表到物理 Binding 表的不可变映射。
|
||||
*
|
||||
* @param logicalName 查询方可见的全局唯一逻辑表名
|
||||
* @param bindingName 物理数据源 Binding 名称
|
||||
* @param schemaName Binding 内的查询逻辑 Schema 名称
|
||||
* @param sourceTableName 数据源 Definition 中的实际表名
|
||||
*/
|
||||
public record FederationLogicalTableDefinition(
|
||||
String logicalName,
|
||||
String bindingName,
|
||||
String schemaName,
|
||||
String sourceTableName
|
||||
) implements Serializable {
|
||||
|
||||
/**
|
||||
* 校验逻辑表映射的必填字段。
|
||||
*/
|
||||
public FederationLogicalTableDefinition {
|
||||
requireText(logicalName, "logicalName");
|
||||
requireText(bindingName, "bindingName");
|
||||
requireText(schemaName, "schemaName");
|
||||
requireText(sourceTableName, "sourceTableName");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建逻辑表映射。
|
||||
*
|
||||
* @param logicalName 查询方可见逻辑表名
|
||||
* @param bindingName 物理数据源 Binding 名称
|
||||
* @param schemaName 查询逻辑 Schema 名称
|
||||
* @param sourceTableName 数据源中的实际表名
|
||||
* @return 逻辑表映射
|
||||
*/
|
||||
public static FederationLogicalTableDefinition of(
|
||||
String logicalName,
|
||||
String bindingName,
|
||||
String schemaName,
|
||||
String sourceTableName
|
||||
) {
|
||||
return new FederationLogicalTableDefinition(
|
||||
logicalName,
|
||||
bindingName,
|
||||
schemaName,
|
||||
sourceTableName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验映射字段包含有效文本。
|
||||
*
|
||||
* @param value 字段值
|
||||
* @param field 字段名称
|
||||
* @throws IllegalArgumentException 字段为空时抛出
|
||||
*/
|
||||
private static void requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
/**
|
||||
* 根据 SQL 实际引用物理数据源数量确定的查询模式。
|
||||
*/
|
||||
public enum FederationQueryMode {
|
||||
/** 单一物理数据源完整下推。 */
|
||||
SINGLE_SOURCE,
|
||||
/** 多物理数据源分片下推并由 Core 合并。 */
|
||||
FEDERATED
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.io.Serializable;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 调用方传入的不可变查询范围,描述 SQL 可见的物理数据源 Binding。
|
||||
*
|
||||
* <p>该定义不保存 DataSource、连接池或凭据。Core 只在编译和执行期间解析它,
|
||||
* 虚拟数据源的持久化、发布和分布式一致性由调用方负责。</p>
|
||||
*
|
||||
* @param definitionId 调用方定义标识
|
||||
* @param revision 查询范围版本
|
||||
* @param bindings Binding 名称到物理数据源定义的映射
|
||||
* @param defaultBinding 默认 Binding 名称
|
||||
* @param logicalTables 查询方可见的逻辑表映射;空列表保留原始物理表解析语义
|
||||
* @param executionPolicy 调用方联邦资源上限
|
||||
*/
|
||||
public record FederationQueryScopeDefinition(
|
||||
String definitionId,
|
||||
long revision,
|
||||
Map<String, FederationSourceBindingDefinition> bindings,
|
||||
String defaultBinding,
|
||||
List<FederationLogicalTableDefinition> logicalTables,
|
||||
FederationExecutionPolicy executionPolicy
|
||||
) implements Serializable {
|
||||
|
||||
/**
|
||||
* 校验并创建不可变查询范围。
|
||||
*/
|
||||
public FederationQueryScopeDefinition {
|
||||
if (definitionId == null || definitionId.isBlank()) {
|
||||
throw new IllegalArgumentException("definitionId must not be blank");
|
||||
}
|
||||
if (revision < 0) {
|
||||
throw new IllegalArgumentException("scope revision must not be negative");
|
||||
}
|
||||
if (bindings == null || bindings.isEmpty()) {
|
||||
throw new IllegalArgumentException("scope bindings must not be empty");
|
||||
}
|
||||
LinkedHashMap<String, FederationSourceBindingDefinition> copied = new LinkedHashMap<>();
|
||||
Set<String> normalizedBindingNames = new HashSet<>();
|
||||
bindings.forEach((bindingName, binding) -> {
|
||||
if (bindingName == null || bindingName.isBlank() || binding == null) {
|
||||
throw new IllegalArgumentException("binding name and definition must be provided");
|
||||
}
|
||||
if (!normalizedBindingNames.add(bindingName.toUpperCase(Locale.ROOT))) {
|
||||
throw new IllegalArgumentException(
|
||||
"binding names must be unique ignoring unquoted identifier case"
|
||||
);
|
||||
}
|
||||
copied.put(bindingName, binding);
|
||||
});
|
||||
bindings = Collections.unmodifiableMap(copied);
|
||||
if (defaultBinding == null || !bindings.containsKey(defaultBinding)) {
|
||||
throw new IllegalArgumentException("defaultBinding must reference a declared binding");
|
||||
}
|
||||
List<FederationLogicalTableDefinition> copiedTables = logicalTables == null
|
||||
? List.of()
|
||||
: List.copyOf(logicalTables);
|
||||
Set<String> normalizedLogicalNames = new HashSet<>();
|
||||
for (FederationLogicalTableDefinition table : copiedTables) {
|
||||
if (table == null) {
|
||||
throw new IllegalArgumentException("logical table definition must not be null");
|
||||
}
|
||||
if (!bindings.containsKey(table.bindingName())) {
|
||||
throw new IllegalArgumentException(
|
||||
"logical table binding must reference a declared binding: "
|
||||
+ table.bindingName()
|
||||
);
|
||||
}
|
||||
if (!normalizedLogicalNames.add(table.logicalName().toUpperCase(Locale.ROOT))) {
|
||||
throw new IllegalArgumentException(
|
||||
"logical table names must be unique ignoring unquoted identifier case"
|
||||
);
|
||||
}
|
||||
}
|
||||
logicalTables = copiedTables;
|
||||
executionPolicy = executionPolicy == null
|
||||
? FederationExecutionPolicy.basic()
|
||||
: executionPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建不声明逻辑表映射的兼容查询范围。
|
||||
*
|
||||
* @param definitionId 调用方定义标识
|
||||
* @param revision 查询范围版本
|
||||
* @param bindings Binding 名称到物理数据源定义的映射
|
||||
* @param defaultBinding 默认 Binding 名称
|
||||
* @param executionPolicy 调用方联邦资源上限
|
||||
*/
|
||||
public FederationQueryScopeDefinition(
|
||||
String definitionId,
|
||||
long revision,
|
||||
Map<String, FederationSourceBindingDefinition> bindings,
|
||||
String defaultBinding,
|
||||
FederationExecutionPolicy executionPolicy
|
||||
) {
|
||||
this(
|
||||
definitionId,
|
||||
revision,
|
||||
bindings,
|
||||
defaultBinding,
|
||||
List.of(),
|
||||
executionPolicy
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单物理数据源查询范围。
|
||||
*
|
||||
* @param sourceId 物理数据源标识,同时作为默认 Binding 名称
|
||||
* @param minimumRevision 最低 Definition 版本
|
||||
* @return 单源查询范围
|
||||
*/
|
||||
public static FederationQueryScopeDefinition single(
|
||||
SourceId sourceId,
|
||||
long minimumRevision
|
||||
) {
|
||||
if (sourceId == null) {
|
||||
throw new IllegalArgumentException("sourceId must not be null");
|
||||
}
|
||||
return single(
|
||||
"source:" + sourceId.value(),
|
||||
minimumRevision,
|
||||
sourceId.value(),
|
||||
sourceId,
|
||||
minimumRevision
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建调用方管理的虚拟联邦查询范围。
|
||||
*
|
||||
* @param definitionId 查询范围标识
|
||||
* @param revision 查询范围版本
|
||||
* @param bindings SQL 逻辑 Binding 到物理数据源的映射
|
||||
* @param defaultBinding 默认 Binding
|
||||
* @param executionPolicy 调用方资源上限
|
||||
* @return 虚拟联邦查询范围
|
||||
*/
|
||||
public static FederationQueryScopeDefinition virtual(
|
||||
String definitionId,
|
||||
long revision,
|
||||
Map<String, FederationSourceBindingDefinition> bindings,
|
||||
String defaultBinding,
|
||||
FederationExecutionPolicy executionPolicy
|
||||
) {
|
||||
return new FederationQueryScopeDefinition(
|
||||
definitionId,
|
||||
revision,
|
||||
bindings,
|
||||
defaultBinding,
|
||||
List.of(),
|
||||
executionPolicy
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带逻辑表映射的虚拟联邦查询范围。
|
||||
*
|
||||
* @param definitionId 查询范围标识
|
||||
* @param revision 查询范围版本
|
||||
* @param bindings SQL 逻辑 Binding 到物理数据源的映射
|
||||
* @param defaultBinding 默认 Binding
|
||||
* @param logicalTables 查询方可见的逻辑表映射
|
||||
* @param executionPolicy 调用方资源上限
|
||||
* @return 虚拟联邦查询范围
|
||||
*/
|
||||
public static FederationQueryScopeDefinition virtual(
|
||||
String definitionId,
|
||||
long revision,
|
||||
Map<String, FederationSourceBindingDefinition> bindings,
|
||||
String defaultBinding,
|
||||
List<FederationLogicalTableDefinition> logicalTables,
|
||||
FederationExecutionPolicy executionPolicy
|
||||
) {
|
||||
return new FederationQueryScopeDefinition(
|
||||
definitionId,
|
||||
revision,
|
||||
bindings,
|
||||
defaultBinding,
|
||||
logicalTables,
|
||||
executionPolicy
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带独立范围版本的单物理数据源查询范围。
|
||||
*
|
||||
* @param definitionId 查询范围标识
|
||||
* @param scopeRevision 查询范围版本
|
||||
* @param bindingName 默认 Binding 名称
|
||||
* @param sourceId 物理数据源标识
|
||||
* @param minimumRevision 最低 Definition 版本
|
||||
* @return 单源查询范围
|
||||
*/
|
||||
public static FederationQueryScopeDefinition single(
|
||||
String definitionId,
|
||||
long scopeRevision,
|
||||
String bindingName,
|
||||
SourceId sourceId,
|
||||
long minimumRevision
|
||||
) {
|
||||
return new FederationQueryScopeDefinition(
|
||||
definitionId,
|
||||
scopeRevision,
|
||||
Map.of(bindingName, FederationSourceBindingDefinition.of(sourceId, minimumRevision)),
|
||||
bindingName,
|
||||
List.of(),
|
||||
FederationExecutionPolicy.basic()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回默认 Binding。
|
||||
*
|
||||
* @return 默认 Binding 定义
|
||||
*/
|
||||
public FederationSourceBindingDefinition defaultBindingDefinition() {
|
||||
return bindings.get(defaultBinding);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回用于计划缓存与跨节点一致性判断的稳定摘要。
|
||||
*
|
||||
* @return SHA-256 十六进制摘要
|
||||
*/
|
||||
public String checksum() {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
update(digest, "federation-query-scope-v2");
|
||||
update(digest, definitionId);
|
||||
update(digest, Long.toString(revision));
|
||||
update(digest, defaultBinding);
|
||||
List<Map.Entry<String, FederationSourceBindingDefinition>> orderedBindings =
|
||||
new ArrayList<>(bindings.entrySet());
|
||||
orderedBindings.sort(Map.Entry.comparingByKey());
|
||||
updateCount(digest, orderedBindings.size());
|
||||
for (Map.Entry<String, FederationSourceBindingDefinition> entry : orderedBindings) {
|
||||
update(digest, "binding");
|
||||
update(digest, entry.getKey());
|
||||
FederationSourceBindingDefinition binding = entry.getValue();
|
||||
update(digest, binding.sourceId().value());
|
||||
update(digest, Long.toString(binding.minimumRevision()));
|
||||
List<Map.Entry<String, String>> mappings =
|
||||
new ArrayList<>(binding.schemaMappings().entrySet());
|
||||
mappings.sort(Comparator.comparing(Map.Entry::getKey));
|
||||
updateCount(digest, mappings.size());
|
||||
for (Map.Entry<String, String> mapping : mappings) {
|
||||
update(digest, "schema-mapping");
|
||||
update(digest, mapping.getKey());
|
||||
update(digest, mapping.getValue());
|
||||
}
|
||||
}
|
||||
List<FederationLogicalTableDefinition> orderedTables =
|
||||
new ArrayList<>(logicalTables);
|
||||
orderedTables.sort(Comparator.comparing(
|
||||
table -> table.logicalName().toUpperCase(Locale.ROOT)
|
||||
));
|
||||
updateCount(digest, orderedTables.size());
|
||||
for (FederationLogicalTableDefinition table : orderedTables) {
|
||||
update(digest, "logical-table");
|
||||
update(digest, table.logicalName());
|
||||
update(digest, table.bindingName());
|
||||
update(digest, table.schemaName());
|
||||
update(digest, table.sourceTableName());
|
||||
}
|
||||
update(digest, "execution-policy");
|
||||
update(digest, Integer.toString(executionPolicy.maximumReferencedSources()));
|
||||
update(digest, Integer.toString(executionPolicy.maximumFragments()));
|
||||
update(digest, Integer.toString(executionPolicy.maximumConcurrentFragments()));
|
||||
update(digest, Long.toString(executionPolicy.maximumIntermediateRows()));
|
||||
update(digest, Long.toString(executionPolicy.maximumIntermediateBytes()));
|
||||
update(digest, Long.toString(executionPolicy.maximumExecutionTimeMillis()));
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is not available", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void update(MessageDigest digest, String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
updateCount(digest, bytes.length);
|
||||
digest.update(bytes);
|
||||
}
|
||||
|
||||
private static void updateCount(MessageDigest digest, int value) {
|
||||
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(value).array());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.io.Serializable;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 将查询范围内的一个 Binding 绑定到已经登记的物理数据源。
|
||||
*
|
||||
* @param sourceId 物理数据源标识
|
||||
* @param minimumRevision 查询要求的最低 Definition 版本
|
||||
* @param schemaMappings 查询逻辑 Schema 到物理 Definition 逻辑 Schema 的映射;空映射表示同名暴露全部 Schema
|
||||
*/
|
||||
public record FederationSourceBindingDefinition(
|
||||
SourceId sourceId,
|
||||
long minimumRevision,
|
||||
Map<String, String> schemaMappings
|
||||
) implements Serializable {
|
||||
|
||||
/**
|
||||
* 校验并创建不可变 Binding 定义。
|
||||
*/
|
||||
public FederationSourceBindingDefinition {
|
||||
if (sourceId == null) {
|
||||
throw new IllegalArgumentException("sourceId must not be null");
|
||||
}
|
||||
if (minimumRevision < 0) {
|
||||
throw new IllegalArgumentException("minimumRevision must not be negative");
|
||||
}
|
||||
LinkedHashMap<String, String> copied = new LinkedHashMap<>();
|
||||
Set<String> normalizedSchemaNames = new HashSet<>();
|
||||
if (schemaMappings != null) {
|
||||
schemaMappings.forEach((querySchema, sourceSchema) -> {
|
||||
if (querySchema == null || querySchema.isBlank()
|
||||
|| sourceSchema == null || sourceSchema.isBlank()) {
|
||||
throw new IllegalArgumentException("schema mapping names must not be blank");
|
||||
}
|
||||
if (!normalizedSchemaNames.add(querySchema.toUpperCase(Locale.ROOT))) {
|
||||
throw new IllegalArgumentException(
|
||||
"query schema names must be unique ignoring unquoted identifier case"
|
||||
);
|
||||
}
|
||||
copied.put(querySchema, sourceSchema);
|
||||
});
|
||||
}
|
||||
schemaMappings = Collections.unmodifiableMap(copied);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建不改写 Schema 名称的物理数据源 Binding。
|
||||
*
|
||||
* @param sourceId 物理数据源标识
|
||||
* @param minimumRevision 最低 Definition 版本
|
||||
* @return Binding 定义
|
||||
*/
|
||||
public static FederationSourceBindingDefinition of(
|
||||
SourceId sourceId,
|
||||
long minimumRevision
|
||||
) {
|
||||
return new FederationSourceBindingDefinition(sourceId, minimumRevision, Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建显式映射查询 Schema 的物理数据源 Binding。
|
||||
*
|
||||
* @param sourceId 物理数据源标识
|
||||
* @param minimumRevision 最低 Definition 版本
|
||||
* @param schemaMappings 查询逻辑 Schema 到 Definition 逻辑 Schema 的映射
|
||||
* @return Binding 定义
|
||||
*/
|
||||
public static FederationSourceBindingDefinition of(
|
||||
SourceId sourceId,
|
||||
long minimumRevision,
|
||||
Map<String, String> schemaMappings
|
||||
) {
|
||||
return new FederationSourceBindingDefinition(
|
||||
sourceId,
|
||||
minimumRevision,
|
||||
schemaMappings
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
|
||||
/**
|
||||
* 编译计划绑定的节点本地物理数据源运行身份。
|
||||
*
|
||||
* @param bindingName 查询范围 Binding 名称
|
||||
* @param sourceId 物理数据源标识
|
||||
* @param sourceRevision Definition 版本
|
||||
* @param sourceChecksum Definition 校验和
|
||||
* @param adapterId Adapter 标识
|
||||
* @param runtimeFingerprint 数据库与驱动运行指纹
|
||||
*/
|
||||
public record FederationSourceRuntimeIdentity(
|
||||
String bindingName,
|
||||
SourceId sourceId,
|
||||
long sourceRevision,
|
||||
String sourceChecksum,
|
||||
String adapterId,
|
||||
String runtimeFingerprint
|
||||
) {
|
||||
|
||||
/**
|
||||
* 校验运行身份字段。
|
||||
*/
|
||||
public FederationSourceRuntimeIdentity {
|
||||
if (bindingName == null || bindingName.isBlank()
|
||||
|| sourceId == null || sourceRevision < 0
|
||||
|| sourceChecksum == null || sourceChecksum.isBlank()
|
||||
|| adapterId == null || adapterId.isBlank()
|
||||
|| runtimeFingerprint == null || runtimeFingerprint.isBlank()) {
|
||||
throw new IllegalArgumentException("source runtime identity is incomplete");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 一次编译期间不可变引用的统计快照。
|
||||
*
|
||||
* <p>构造时会复制完整统计映射,确保版本、数据与有效期属于同一个冻结视图。</p>
|
||||
*/
|
||||
public final class FederationStatisticsSnapshot {
|
||||
|
||||
private final String version;
|
||||
private final Instant capturedAt;
|
||||
private final Map<TableKey, FederationTableStatistics> statisticsByTable;
|
||||
private final Instant validUntil;
|
||||
|
||||
/**
|
||||
* 创建统计快照。
|
||||
*
|
||||
* @param version 稳定快照版本
|
||||
* @param statisticsByTable 按物理表索引的统计映射
|
||||
*/
|
||||
public FederationStatisticsSnapshot(
|
||||
String version,
|
||||
Map<TableKey, FederationTableStatistics> statisticsByTable
|
||||
) {
|
||||
this(version, Instant.now(), statisticsByTable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带固定评估时刻的统计快照。
|
||||
*
|
||||
* @param version 稳定快照版本
|
||||
* @param capturedAt 快照捕获和有效期评估时刻
|
||||
* @param statisticsByTable 按物理表索引的统计映射
|
||||
*/
|
||||
public FederationStatisticsSnapshot(
|
||||
String version,
|
||||
Instant capturedAt,
|
||||
Map<TableKey, FederationTableStatistics> statisticsByTable
|
||||
) {
|
||||
this.version = version == null || version.isBlank() ? "none" : version;
|
||||
this.capturedAt = Objects.requireNonNull(capturedAt, "capturedAt");
|
||||
this.statisticsByTable = Map.copyOf(new LinkedHashMap<>(
|
||||
statisticsByTable == null ? Map.of() : statisticsByTable
|
||||
));
|
||||
this.validUntil = this.statisticsByTable.values().stream()
|
||||
.filter(statistics -> {
|
||||
FederationStatisticsStatus status = statistics.effectiveStatus(capturedAt);
|
||||
return status == FederationStatisticsStatus.COMPLETE
|
||||
|| status == FederationStatisticsStatus.PARTIAL;
|
||||
})
|
||||
.map(FederationTableStatistics::expiresAt)
|
||||
.min(Instant::compareTo)
|
||||
.orElse(Instant.MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回空统计快照。
|
||||
*
|
||||
* @return 空快照
|
||||
*/
|
||||
public static FederationStatisticsSnapshot empty() {
|
||||
return new FederationStatisticsSnapshot("none", Instant.now(), Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回快照版本。
|
||||
*
|
||||
* @return 稳定版本
|
||||
*/
|
||||
public String version() {
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回本次编译统一使用的统计有效期评估时刻。
|
||||
*
|
||||
* @return 快照捕获时刻
|
||||
*/
|
||||
public Instant capturedAt() {
|
||||
return capturedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回该快照内最早的统计失效时间。
|
||||
*
|
||||
* @return 最早失效时间;空快照为 {@link Instant#MAX}
|
||||
*/
|
||||
public Instant validUntil() {
|
||||
return validUntil;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询冻结快照中的表统计。
|
||||
*
|
||||
* @param sourceId 物理源
|
||||
* @param schema Source Definition 暴露的逻辑 Schema 名称
|
||||
* @param table 表名称
|
||||
* @return 表统计;缺失时为 {@code null}
|
||||
*/
|
||||
public FederationTableStatistics statistics(
|
||||
SourceId sourceId,
|
||||
String schema,
|
||||
String table
|
||||
) {
|
||||
return statisticsByTable.get(new TableKey(sourceId, schema, table));
|
||||
}
|
||||
|
||||
/**
|
||||
* 截取指定物理表的稳定统计身份与最早失效时间。
|
||||
*
|
||||
* <p>该结果只依赖查询实际引用的表。未引用表的刷新不会使已有计划失效。</p>
|
||||
*
|
||||
* @param tables 查询实际引用的物理表键
|
||||
* @return 查询级统计选择结果
|
||||
*/
|
||||
public Selection select(Set<TableKey> tables) {
|
||||
List<TableKey> ordered = (tables == null ? Set.<TableKey>of() : tables).stream()
|
||||
.sorted(Comparator
|
||||
.comparing((TableKey key) -> key.sourceId().value())
|
||||
.thenComparing(TableKey::schema)
|
||||
.thenComparing(TableKey::table))
|
||||
.toList();
|
||||
MessageDigest digest = sha256();
|
||||
Instant selectedValidUntil = Instant.MAX;
|
||||
for (TableKey key : ordered) {
|
||||
update(digest, key.sourceId().value());
|
||||
update(digest, key.schema());
|
||||
update(digest, key.table());
|
||||
FederationTableStatistics statistics = statisticsByTable.get(key);
|
||||
if (statistics == null) {
|
||||
update(digest, "missing");
|
||||
continue;
|
||||
}
|
||||
FederationStatisticsStatus effectiveStatus = statistics.effectiveStatus(capturedAt);
|
||||
update(digest, effectiveStatus.name());
|
||||
update(digest, Double.toString(statistics.estimatedRows()));
|
||||
update(digest, Long.toString(statistics.averageRowWidthBytes()));
|
||||
update(digest, statistics.collectedAt().toString());
|
||||
update(digest, statistics.source());
|
||||
update(digest, statistics.expiresAt().toString());
|
||||
statistics.columns().entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey(String.CASE_INSENSITIVE_ORDER))
|
||||
.forEach(entry -> {
|
||||
update(digest, entry.getKey().toLowerCase(Locale.ROOT));
|
||||
update(digest, entry.getValue().toString());
|
||||
});
|
||||
statistics.uniqueKeys().stream()
|
||||
.map(columns -> columns.stream()
|
||||
.map(value -> value.toLowerCase(Locale.ROOT))
|
||||
.sorted()
|
||||
.toList())
|
||||
.map(columns -> String.join("\u0001", columns))
|
||||
.sorted()
|
||||
.forEach(value -> update(digest, value));
|
||||
if ((effectiveStatus == FederationStatisticsStatus.COMPLETE
|
||||
|| effectiveStatus == FederationStatisticsStatus.PARTIAL)
|
||||
&& statistics.expiresAt().isBefore(selectedValidUntil)) {
|
||||
selectedValidUntil = statistics.expiresAt();
|
||||
}
|
||||
}
|
||||
return new Selection(
|
||||
HexFormat.of().formatHex(digest.digest()),
|
||||
selectedValidUntil
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询级统计选择结果。
|
||||
*
|
||||
* @param fingerprint 实际引用表统计的稳定指纹
|
||||
* @param validUntil 实际引用表统计的最早失效时间
|
||||
*/
|
||||
public record Selection(String fingerprint, Instant validUntil) {
|
||||
|
||||
/** 校验查询级统计选择结果。 */
|
||||
public Selection {
|
||||
fingerprint = Objects.requireNonNull(fingerprint, "fingerprint");
|
||||
validUntil = validUntil == null ? Instant.MAX : validUntil;
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void update(MessageDigest digest, String value) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
digest.update((byte) (bytes.length >>> 24));
|
||||
digest.update((byte) (bytes.length >>> 16));
|
||||
digest.update((byte) (bytes.length >>> 8));
|
||||
digest.update((byte) bytes.length);
|
||||
digest.update(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据源表统计键。
|
||||
*
|
||||
* @param sourceId 物理源
|
||||
* @param schema Source Definition 暴露的逻辑 Schema 名称
|
||||
* @param table 表名称
|
||||
*/
|
||||
public record TableKey(SourceId sourceId, String schema, String table) {
|
||||
|
||||
/**
|
||||
* 规范化物理表键,保证常见数据库标识符大小写差异不影响命中。
|
||||
*/
|
||||
public TableKey {
|
||||
sourceId = Objects.requireNonNull(sourceId, "sourceId");
|
||||
schema = normalize(schema);
|
||||
table = normalize(table);
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value == null ? "" : value.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
/**
|
||||
* 联邦查询成本统计的可用状态。
|
||||
*/
|
||||
public enum FederationStatisticsStatus {
|
||||
|
||||
/** 所需表均具有当前可信统计。 */
|
||||
COMPLETE,
|
||||
|
||||
/** 仅部分表或字段具有可信统计。 */
|
||||
PARTIAL,
|
||||
|
||||
/** 没有可用的外部统计。 */
|
||||
MISSING,
|
||||
|
||||
/** 统计存在但已超过调用方声明的有效期。 */
|
||||
STALE
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 调用方或 Adapter 提供的一张物理表的轻量统计快照。
|
||||
*
|
||||
* @param estimatedRows 估算总行数
|
||||
* @param averageRowWidthBytes 平均物理行宽字节数
|
||||
* @param collectedAt 统计采集时间
|
||||
* @param source 统计来源,例如 catalog、adapter 或业务统计服务
|
||||
* @param columns 按物理列名索引的列统计
|
||||
* @param uniqueKeys 唯一键列集合
|
||||
* @param expiresAt 统计失效时间;不失效时为 {@link Instant#MAX}
|
||||
* @param status Provider 声明的统计完整状态
|
||||
*/
|
||||
public record FederationTableStatistics(
|
||||
double estimatedRows,
|
||||
long averageRowWidthBytes,
|
||||
Instant collectedAt,
|
||||
String source,
|
||||
Map<String, FederationColumnStatistics> columns,
|
||||
List<List<String>> uniqueKeys,
|
||||
Instant expiresAt,
|
||||
FederationStatisticsStatus status
|
||||
) implements Serializable {
|
||||
|
||||
/**
|
||||
* 校验并规范化统计值。
|
||||
*/
|
||||
public FederationTableStatistics {
|
||||
if (!Double.isFinite(estimatedRows) || estimatedRows < 0) {
|
||||
throw new IllegalArgumentException("estimatedRows must be finite and non-negative");
|
||||
}
|
||||
if (averageRowWidthBytes <= 0) {
|
||||
throw new IllegalArgumentException("averageRowWidthBytes must be positive");
|
||||
}
|
||||
collectedAt = collectedAt == null ? Instant.EPOCH : collectedAt;
|
||||
source = source == null || source.isBlank() ? "unspecified" : source;
|
||||
columns = Map.copyOf(columns == null ? Map.of() : columns);
|
||||
uniqueKeys = (uniqueKeys == null ? List.<List<String>>of() : uniqueKeys).stream()
|
||||
.map(List::copyOf)
|
||||
.toList();
|
||||
expiresAt = expiresAt == null ? Instant.MAX : expiresAt;
|
||||
status = status == null ? FederationStatisticsStatus.COMPLETE : status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建仅包含表级统计的兼容快照。
|
||||
*
|
||||
* @param estimatedRows 估算总行数
|
||||
* @param averageRowWidthBytes 平均行宽
|
||||
* @param collectedAt 采集时间
|
||||
* @param source 统计来源
|
||||
*/
|
||||
public FederationTableStatistics(
|
||||
double estimatedRows,
|
||||
long averageRowWidthBytes,
|
||||
Instant collectedAt,
|
||||
String source
|
||||
) {
|
||||
this(
|
||||
estimatedRows,
|
||||
averageRowWidthBytes,
|
||||
collectedAt,
|
||||
source,
|
||||
Map.of(),
|
||||
List.of(),
|
||||
Instant.MAX,
|
||||
FederationStatisticsStatus.COMPLETE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回指定时刻的有效状态。
|
||||
*
|
||||
* @param now 当前时间
|
||||
* @return 计入有效期后的状态
|
||||
*/
|
||||
public FederationStatisticsStatus effectiveStatus(Instant now) {
|
||||
if (status != FederationStatisticsStatus.MISSING
|
||||
&& !expiresAt.isAfter(now == null ? Instant.now() : now)) {
|
||||
return FederationStatisticsStatus.STALE;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.easyagents.federation.sql.federation;
|
||||
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
|
||||
/**
|
||||
* 为联邦编译提供轻量、无数据库查询副作用的表统计快照。
|
||||
*
|
||||
* <p>实现必须直接返回已冻结快照,不得在编译热路径执行 {@code COUNT(*)}。统计数据变化时,
|
||||
* 快照版本必须同步变化,使节点本地计划缓存自然隔离旧成本计划。</p>
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FederationTableStatisticsProvider {
|
||||
|
||||
/**
|
||||
* 捕获一次编译使用的不可变统计快照。
|
||||
*
|
||||
* @return 同时冻结版本、数据与有效期的快照
|
||||
*/
|
||||
FederationStatisticsSnapshot snapshot();
|
||||
|
||||
/**
|
||||
* 查询当前快照中的一张物理表统计。
|
||||
*
|
||||
* <p>编译器会先捕获一次 {@link #snapshot()} 并复用,调用方仅应将本方法用于诊断读取。</p>
|
||||
*
|
||||
* @param sourceId 物理数据源标识
|
||||
* @param schema 物理 Schema 名称
|
||||
* @param table 物理表名称
|
||||
* @return 统计快照;没有可信统计时返回 {@code null}
|
||||
*/
|
||||
default FederationTableStatistics statistics(
|
||||
SourceId sourceId,
|
||||
String schema,
|
||||
String table
|
||||
) {
|
||||
return snapshot().statistics(sourceId, schema, table);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前统计快照版本。
|
||||
*
|
||||
* @return 非空版本字符串
|
||||
*/
|
||||
default String snapshotVersion() {
|
||||
return snapshot().version();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回不提供外部统计的实现。
|
||||
*
|
||||
* @return 空统计 Provider
|
||||
*/
|
||||
static FederationTableStatisticsProvider none() {
|
||||
return FederationStatisticsSnapshot::empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
package com.easyagents.federation.sql.runtime;
|
||||
|
||||
import com.easyagents.federation.sql.adapter.FederationStatisticsCollectionContext;
|
||||
import com.easyagents.federation.sql.adapter.FederationStatisticsCollector;
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import com.easyagents.federation.sql.federation.FederationStatisticsSnapshot;
|
||||
import com.easyagents.federation.sql.federation.FederationTableStatistics;
|
||||
import com.easyagents.federation.sql.federation.FederationTableStatisticsProvider;
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* 使用数据库 Adapter 自动采集、缓存并降级联邦查询统计。
|
||||
*/
|
||||
final class AdapterFederationStatisticsProvider
|
||||
implements FederationTableStatisticsProvider, AutoCloseable {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(
|
||||
AdapterFederationStatisticsProvider.class
|
||||
);
|
||||
private static final Duration DEFAULT_TTL = Duration.ofMinutes(30);
|
||||
private static final Duration DEFAULT_FAILURE_RETRY_DELAY = Duration.ofMinutes(1);
|
||||
private static final int DEFAULT_QUERY_TIMEOUT_SECONDS = 5;
|
||||
private static final int MAXIMUM_PARALLEL_REFRESHES = 4;
|
||||
private static final int MAXIMUM_PENDING_REFRESHES = 64;
|
||||
|
||||
private final Duration ttl;
|
||||
private final Duration failureRetryDelay;
|
||||
private final int queryTimeoutSeconds;
|
||||
private final ConcurrentMap<SourceId, Object> sourceLocks = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<SourceRevision, CompletableFuture<Void>> inFlight =
|
||||
new ConcurrentHashMap<>();
|
||||
private final ExecutorService refreshExecutor;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private volatile RegistryState state = RegistryState.empty();
|
||||
private long versionSequence;
|
||||
|
||||
/**
|
||||
* 使用默认有效期、失败限频和有界并行度创建自动统计 Provider。
|
||||
*/
|
||||
AdapterFederationStatisticsProvider() {
|
||||
this(
|
||||
DEFAULT_TTL,
|
||||
DEFAULT_FAILURE_RETRY_DELAY,
|
||||
DEFAULT_QUERY_TIMEOUT_SECONDS,
|
||||
Math.min(
|
||||
MAXIMUM_PARALLEL_REFRESHES,
|
||||
Math.max(1, Runtime.getRuntime().availableProcessors())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可测试的自动统计 Provider。
|
||||
*
|
||||
* @param ttl 统计有效期
|
||||
* @param failureRetryDelay 失败后的最短重试间隔
|
||||
* @param queryTimeoutSeconds 单条目录查询超时
|
||||
* @param parallelism 不同物理源的最大并行采集数
|
||||
*/
|
||||
AdapterFederationStatisticsProvider(
|
||||
Duration ttl,
|
||||
Duration failureRetryDelay,
|
||||
int queryTimeoutSeconds,
|
||||
int parallelism
|
||||
) {
|
||||
this.ttl = positive(ttl, "ttl");
|
||||
this.failureRetryDelay = positive(failureRetryDelay, "failureRetryDelay");
|
||||
if (queryTimeoutSeconds <= 0) {
|
||||
throw new IllegalArgumentException("queryTimeoutSeconds must be positive");
|
||||
}
|
||||
if (parallelism <= 0) {
|
||||
throw new IllegalArgumentException("parallelism must be positive");
|
||||
}
|
||||
this.queryTimeoutSeconds = queryTimeoutSeconds;
|
||||
this.refreshExecutor = new ThreadPoolExecutor(
|
||||
parallelism,
|
||||
parallelism,
|
||||
0L,
|
||||
TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(MAXIMUM_PENDING_REFRESHES),
|
||||
daemonThreadFactory()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前冻结统计快照。
|
||||
*
|
||||
* @return 无数据库访问副作用的不可变快照
|
||||
*/
|
||||
@Override
|
||||
public FederationStatisticsSnapshot snapshot() {
|
||||
return state.snapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步刷新查询实际涉及的物理源统计。
|
||||
*
|
||||
* <p>同一 source revision 的并发刷新合并为一个任务,不同物理源最多按固定
|
||||
* 并行度同时读取目录。该方法不阻塞调用线程;需要等待统计的 Explain 调用可
|
||||
* 在自身截止时间内等待返回的 Future。失败时保留旧统计并限频。</p>
|
||||
*
|
||||
* @param querySnapshot 已持有 Runtime lease 的查询范围快照
|
||||
* @return 所有已安排刷新完成时结束的 Future
|
||||
*/
|
||||
CompletableFuture<Void> refreshIfNeeded(FederationQueryScopeSnapshot querySnapshot) {
|
||||
if (closed.get()) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
Map<SourceId, SourceRuntime> runtimes = new LinkedHashMap<>();
|
||||
querySnapshot.runtimesByBinding().values().forEach(runtime ->
|
||||
runtimes.putIfAbsent(runtime.definition().sourceId(), runtime)
|
||||
);
|
||||
Instant now = Instant.now();
|
||||
List<CompletableFuture<Void>> refreshes = new ArrayList<>();
|
||||
for (SourceRuntime runtime : runtimes.values()) {
|
||||
if (!fresh(state.sources().get(runtime.definition().sourceId()), runtime, now)) {
|
||||
refreshes.add(refreshAsync(runtime));
|
||||
}
|
||||
}
|
||||
return refreshes.isEmpty()
|
||||
? CompletableFuture.completedFuture(null)
|
||||
: CompletableFuture.allOf(refreshes.toArray(CompletableFuture[]::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Runtime 最终关闭后清除同 revision 的节点本地统计。
|
||||
*
|
||||
* @param runtime 已关闭 Runtime
|
||||
*/
|
||||
void invalidate(SourceRuntime runtime) {
|
||||
if (closed.get()) {
|
||||
return;
|
||||
}
|
||||
SourceId sourceId = runtime.definition().sourceId();
|
||||
Object lock = sourceLocks.computeIfAbsent(sourceId, ignored -> new Object());
|
||||
synchronized (lock) {
|
||||
SourceStatistics current = state.sources().get(sourceId);
|
||||
if (current != null && current.revision() == runtime.definition().revision()) {
|
||||
replace(sourceId, null);
|
||||
}
|
||||
}
|
||||
// 锁对象必须覆盖数据源的完整生命周期,避免旧 revision 关闭与新 revision
|
||||
// 刷新交错时为同一数据源创建两个临界区。锁会在 Provider 关闭时统一释放。
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭后台采集器并释放节点本地统计。
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
refreshExecutor.shutdownNow();
|
||||
try {
|
||||
if (!refreshExecutor.awaitTermination(
|
||||
queryTimeoutSeconds + 1L,
|
||||
TimeUnit.SECONDS
|
||||
)) {
|
||||
LOG.warn(
|
||||
"federation statistics refresh did not stop within the bounded shutdown window"
|
||||
);
|
||||
}
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
LOG.warn("interrupted while closing federation statistics refresh", exception);
|
||||
}
|
||||
inFlight.clear();
|
||||
sourceLocks.clear();
|
||||
synchronized (this) {
|
||||
state = RegistryState.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并同一 source revision 的并发刷新。
|
||||
*
|
||||
* @param runtime 待刷新 Runtime
|
||||
* @return 可等待的共享刷新任务
|
||||
*/
|
||||
private CompletableFuture<Void> refreshAsync(SourceRuntime runtime) {
|
||||
SourceRevision key = new SourceRevision(
|
||||
runtime.definition().sourceId(),
|
||||
runtime.definition().revision()
|
||||
);
|
||||
CompletableFuture<Void> created = new CompletableFuture<>();
|
||||
CompletableFuture<Void> existing = inFlight.putIfAbsent(key, created);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
Runnable task = () -> {
|
||||
try {
|
||||
try (SourceRuntime.RuntimeLease lease = runtime.acquire()) {
|
||||
// 后台采集必须持有独立租约,避免查询快照释放后旧 Runtime
|
||||
// 在目录连接仍被使用时关闭连接池。
|
||||
refresh(lease.runtime());
|
||||
}
|
||||
// 租约释放可能触发旧 Runtime 关闭,完成信号必须晚于资源收口。
|
||||
created.complete(null);
|
||||
} catch (FederationSqlException exception) {
|
||||
if (exception.errorCode() == FederationSqlErrorCode.SOURCE_REVISION_NOT_READY) {
|
||||
// 已退休 revision 的统计没有继续采集的价值,保留现有估算即可。
|
||||
created.complete(null);
|
||||
} else {
|
||||
created.completeExceptionally(exception);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
created.completeExceptionally(exception);
|
||||
} finally {
|
||||
inFlight.remove(key, created);
|
||||
}
|
||||
};
|
||||
try {
|
||||
refreshExecutor.execute(task);
|
||||
} catch (RejectedExecutionException exception) {
|
||||
// 查询线程不能在统计线程池饱和时退化为同步目录查询。
|
||||
created.complete(null);
|
||||
inFlight.remove(key, created);
|
||||
if (!closed.get()) {
|
||||
LOG.warn(
|
||||
"federation statistics refresh rejected; using existing or default estimates, sourceId={}, revision={}",
|
||||
runtime.definition().sourceId(),
|
||||
runtime.definition().revision()
|
||||
);
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在源级临界区读取数据库目录并发布统计。
|
||||
*
|
||||
* @param runtime 数据源 Runtime
|
||||
*/
|
||||
private void refresh(SourceRuntime runtime) {
|
||||
SourceId sourceId = runtime.definition().sourceId();
|
||||
long revision = runtime.definition().revision();
|
||||
Object lock = sourceLocks.computeIfAbsent(sourceId, ignored -> new Object());
|
||||
synchronized (lock) {
|
||||
Instant now = Instant.now();
|
||||
SourceStatistics current = state.sources().get(sourceId);
|
||||
if (current != null && current.revision() > revision) {
|
||||
return;
|
||||
}
|
||||
if (fresh(current, runtime, now)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> tables =
|
||||
collect(runtime, now);
|
||||
if (closed.get()) {
|
||||
return;
|
||||
}
|
||||
replace(sourceId, new SourceStatistics(
|
||||
revision,
|
||||
validateSource(sourceId, tables),
|
||||
now.plus(ttl)
|
||||
));
|
||||
} catch (SQLException | RuntimeException exception) {
|
||||
if (!closed.get()) {
|
||||
throttleAfterFailure(sourceId, revision, now);
|
||||
}
|
||||
LOG.warn(
|
||||
"federation statistics collection failed; using existing or default estimates, sourceId={}, revision={}",
|
||||
sourceId,
|
||||
revision,
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 借用运行时连接并调用 Adapter 统计采集器。
|
||||
*
|
||||
* @param runtime 数据源 Runtime
|
||||
* @param collectedAt 采集时间
|
||||
* @return 表统计映射
|
||||
* @throws SQLException 获取连接或读取目录失败
|
||||
*/
|
||||
private Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> collect(
|
||||
SourceRuntime runtime,
|
||||
Instant collectedAt
|
||||
) throws SQLException {
|
||||
FederationStatisticsCollector collector = runtime.adapter().statisticsCollector()
|
||||
.orElse(null);
|
||||
if (collector == null) {
|
||||
return Map.of();
|
||||
}
|
||||
try (Connection connection = runtime.handle().dataSource().getConnection()) {
|
||||
markReadOnly(connection);
|
||||
Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> collected =
|
||||
collector.collect(new FederationStatisticsCollectionContext(
|
||||
runtime.definition(),
|
||||
connection,
|
||||
collectedAt,
|
||||
collectedAt.plus(ttl),
|
||||
queryTimeoutSeconds
|
||||
));
|
||||
return Map.copyOf(collected == null ? Map.of() : collected);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尽力将目录连接标记为只读;驱动不支持时仍由只读 SQL 保证安全。
|
||||
*
|
||||
* @param connection JDBC 连接
|
||||
*/
|
||||
private void markReadOnly(Connection connection) {
|
||||
try {
|
||||
if (!connection.isReadOnly()) {
|
||||
connection.setReadOnly(true);
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
LOG.debug("JDBC driver does not support setting statistics connection read-only", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Adapter 只能发布当前物理源的统计。
|
||||
*
|
||||
* @param sourceId 当前物理源
|
||||
* @param tables Adapter 统计
|
||||
* @return 冻结统计映射
|
||||
*/
|
||||
private Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> validateSource(
|
||||
SourceId sourceId,
|
||||
Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> tables
|
||||
) {
|
||||
for (FederationStatisticsSnapshot.TableKey key : tables.keySet()) {
|
||||
if (!sourceId.equals(key.sourceId())) {
|
||||
throw new IllegalArgumentException(
|
||||
"statistics collector returned a table for another source: " + key.sourceId()
|
||||
);
|
||||
}
|
||||
}
|
||||
return Map.copyOf(tables);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前 revision 的统计是否仍在刷新窗口内。
|
||||
*
|
||||
* @param statistics 已缓存源统计
|
||||
* @param runtime 当前 Runtime
|
||||
* @param now 当前时间
|
||||
* @return 可直接使用时为 true
|
||||
*/
|
||||
private boolean fresh(SourceStatistics statistics, SourceRuntime runtime, Instant now) {
|
||||
return statistics != null
|
||||
&& statistics.revision() == runtime.definition().revision()
|
||||
&& statistics.refreshAfter().isAfter(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集失败时保留同 revision 旧值并限制后续重试频率。
|
||||
*
|
||||
* @param sourceId 物理源
|
||||
* @param revision 当前 revision
|
||||
* @param failedAt 失败时间
|
||||
*/
|
||||
private void throttleAfterFailure(SourceId sourceId, long revision, Instant failedAt) {
|
||||
SourceStatistics current = state.sources().get(sourceId);
|
||||
Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> tables =
|
||||
current != null && current.revision() == revision ? current.tables() : Map.of();
|
||||
replace(sourceId, new SourceStatistics(
|
||||
revision,
|
||||
tables,
|
||||
failedAt.plus(failureRetryDelay)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子替换单源统计并重建聚合快照。
|
||||
*
|
||||
* @param sourceId 物理源
|
||||
* @param statistics 新统计;null 表示移除
|
||||
*/
|
||||
private synchronized void replace(SourceId sourceId, SourceStatistics statistics) {
|
||||
Map<SourceId, SourceStatistics> sources = new LinkedHashMap<>(state.sources());
|
||||
if (statistics == null) {
|
||||
sources.remove(sourceId);
|
||||
} else {
|
||||
sources.put(sourceId, statistics);
|
||||
}
|
||||
Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> tables =
|
||||
new LinkedHashMap<>();
|
||||
sources.values().forEach(value -> tables.putAll(value.tables()));
|
||||
versionSequence++;
|
||||
state = new RegistryState(
|
||||
Map.copyOf(sources),
|
||||
new FederationStatisticsSnapshot(
|
||||
"adapter-" + versionSequence,
|
||||
Instant.now(),
|
||||
tables
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验正 Duration。
|
||||
*
|
||||
* @param value Duration
|
||||
* @param name 参数名
|
||||
* @return 原值
|
||||
*/
|
||||
private static Duration positive(Duration value, String name) {
|
||||
if (value == null || value.isZero() || value.isNegative()) {
|
||||
throw new IllegalArgumentException(name + " must be positive");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建统计采集守护线程工厂。
|
||||
*
|
||||
* @return 守护线程工厂
|
||||
*/
|
||||
private static ThreadFactory daemonThreadFactory() {
|
||||
AtomicInteger sequence = new AtomicInteger();
|
||||
return task -> {
|
||||
Thread thread = new Thread(
|
||||
task,
|
||||
"federation-statistics-" + sequence.incrementAndGet()
|
||||
);
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 单物理源统计及刷新边界。
|
||||
*
|
||||
* @param revision Definition revision
|
||||
* @param tables 表统计
|
||||
* @param refreshAfter 下次允许刷新时间
|
||||
*/
|
||||
private record SourceStatistics(
|
||||
long revision,
|
||||
Map<FederationStatisticsSnapshot.TableKey, FederationTableStatistics> tables,
|
||||
Instant refreshAfter
|
||||
) {
|
||||
|
||||
/**
|
||||
* 冻结源统计。
|
||||
*/
|
||||
private SourceStatistics {
|
||||
tables = Map.copyOf(tables == null ? Map.of() : tables);
|
||||
refreshAfter = refreshAfter == null ? Instant.EPOCH : refreshAfter;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正在采集的物理源 revision。
|
||||
*
|
||||
* @param sourceId 物理源
|
||||
* @param revision Definition revision
|
||||
*/
|
||||
private record SourceRevision(SourceId sourceId, long revision) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点当前聚合统计状态。
|
||||
*
|
||||
* @param sources 按物理源索引的统计
|
||||
* @param snapshot 冻结聚合快照
|
||||
*/
|
||||
private record RegistryState(
|
||||
Map<SourceId, SourceStatistics> sources,
|
||||
FederationStatisticsSnapshot snapshot
|
||||
) {
|
||||
|
||||
/**
|
||||
* 创建空统计状态。
|
||||
*
|
||||
* @return 空状态
|
||||
*/
|
||||
private static RegistryState empty() {
|
||||
return new RegistryState(Map.of(), FederationStatisticsSnapshot.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 冻结聚合状态。
|
||||
*/
|
||||
private RegistryState {
|
||||
sources = Map.copyOf(sources == null ? Map.of() : sources);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
package com.easyagents.federation.sql.runtime;
|
||||
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import com.easyagents.federation.sql.compile.FederationSqlPlan;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.function.Supplier;
|
||||
import org.apache.calcite.rel.RelNode;
|
||||
import org.apache.calcite.rel.RelVisitor;
|
||||
|
||||
/**
|
||||
* 有界 LRU 计划缓存,并对相同冷键执行 single-flight 编译。
|
||||
*/
|
||||
final class BoundedPlanCache implements AutoCloseable {
|
||||
|
||||
private final int maximumEntries;
|
||||
private final int maximumConcurrentCompilations;
|
||||
private final long maximumWeightBytes;
|
||||
private final long ttlNanos;
|
||||
private final LongSupplier nanoTime;
|
||||
private final Map<PlanCacheKey, CacheEntry> entries;
|
||||
private final ConcurrentHashMap<PlanCacheKey, CompletableFuture<FederationSqlPlan>> inFlight =
|
||||
new ConcurrentHashMap<>();
|
||||
private final Semaphore compileSlots;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private long currentWeightBytes;
|
||||
|
||||
/**
|
||||
* 创建有界计划缓存。
|
||||
*
|
||||
* @param maximumEntries 最大条目数
|
||||
*/
|
||||
BoundedPlanCache(int maximumEntries) {
|
||||
this(
|
||||
maximumEntries,
|
||||
Math.min(maximumEntries, defaultCompilationConcurrency()),
|
||||
defaultMaximumWeight(maximumEntries),
|
||||
Duration.ofMinutes(30),
|
||||
System::nanoTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建同时限制缓存容量与冷编译并发的计划缓存。
|
||||
*
|
||||
* @param maximumEntries 最大条目数
|
||||
* @param maximumConcurrentCompilations 最大并发冷编译数
|
||||
*/
|
||||
BoundedPlanCache(int maximumEntries, int maximumConcurrentCompilations) {
|
||||
this(
|
||||
maximumEntries,
|
||||
maximumConcurrentCompilations,
|
||||
defaultMaximumWeight(maximumEntries),
|
||||
Duration.ofMinutes(30),
|
||||
System::nanoTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建同时限制条目、估算权重、存活时间和冷编译并发的计划缓存。
|
||||
*
|
||||
* @param maximumEntries 最大条目数
|
||||
* @param maximumConcurrentCompilations 最大并发冷编译数
|
||||
* @param maximumWeightBytes 最大估算权重
|
||||
* @param timeToLive 条目存活时间
|
||||
*/
|
||||
BoundedPlanCache(
|
||||
int maximumEntries,
|
||||
int maximumConcurrentCompilations,
|
||||
long maximumWeightBytes,
|
||||
Duration timeToLive
|
||||
) {
|
||||
this(
|
||||
maximumEntries,
|
||||
maximumConcurrentCompilations,
|
||||
maximumWeightBytes,
|
||||
timeToLive,
|
||||
System::nanoTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建使用指定单调时钟的计划缓存,供回绕边界测试使用。
|
||||
*
|
||||
* @param maximumEntries 最大条目数
|
||||
* @param maximumConcurrentCompilations 最大并发冷编译数
|
||||
* @param maximumWeightBytes 最大估算权重
|
||||
* @param timeToLive 条目存活时间
|
||||
* @param nanoTime 单调时钟
|
||||
*/
|
||||
BoundedPlanCache(
|
||||
int maximumEntries,
|
||||
int maximumConcurrentCompilations,
|
||||
long maximumWeightBytes,
|
||||
Duration timeToLive,
|
||||
LongSupplier nanoTime
|
||||
) {
|
||||
if (maximumEntries <= 0) {
|
||||
throw new IllegalArgumentException("maximumEntries must be positive");
|
||||
}
|
||||
if (maximumConcurrentCompilations <= 0) {
|
||||
throw new IllegalArgumentException("maximumConcurrentCompilations must be positive");
|
||||
}
|
||||
if (maximumWeightBytes <= 0) {
|
||||
throw new IllegalArgumentException("maximumWeightBytes must be positive");
|
||||
}
|
||||
if (timeToLive == null || timeToLive.isZero() || timeToLive.isNegative()) {
|
||||
throw new IllegalArgumentException("timeToLive must be positive");
|
||||
}
|
||||
this.maximumEntries = maximumEntries;
|
||||
this.maximumConcurrentCompilations = maximumConcurrentCompilations;
|
||||
this.maximumWeightBytes = maximumWeightBytes;
|
||||
this.ttlNanos = saturatingNanos(timeToLive);
|
||||
this.nanoTime = java.util.Objects.requireNonNull(nanoTime, "nanoTime");
|
||||
this.compileSlots = new Semaphore(maximumConcurrentCompilations, true);
|
||||
this.entries = new LinkedHashMap<>(16, 0.75f, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 命中计划或由单个线程完成冷编译。
|
||||
*
|
||||
* @param key 缓存键
|
||||
* @param compiler 冷编译函数
|
||||
* @return 编译计划
|
||||
*/
|
||||
FederationSqlPlan getOrCompile(PlanCacheKey key, Supplier<FederationSqlPlan> compiler) {
|
||||
return getOrCompileWithStatus(key, compiler, WaitGuard.unbounded()).plan();
|
||||
}
|
||||
|
||||
/**
|
||||
* 命中计划或完成冷编译,并返回是否复用了缓存或并发 single-flight。
|
||||
*
|
||||
* @param key 缓存键
|
||||
* @param compiler 冷编译函数
|
||||
* @return 缓存查询结果
|
||||
*/
|
||||
LookupResult getOrCompileWithStatus(
|
||||
PlanCacheKey key,
|
||||
Supplier<FederationSqlPlan> compiler
|
||||
) {
|
||||
return getOrCompileWithStatus(key, compiler, WaitGuard.unbounded());
|
||||
}
|
||||
|
||||
/**
|
||||
* 命中计划或完成冷编译,并让 single-flight 与编译槽等待响应查询终态。
|
||||
*
|
||||
* @param key 缓存键
|
||||
* @param compiler 冷编译函数
|
||||
* @param waitGuard 等待终态检查器
|
||||
* @return 缓存查询结果
|
||||
*/
|
||||
LookupResult getOrCompileWithStatus(
|
||||
PlanCacheKey key,
|
||||
Supplier<FederationSqlPlan> compiler,
|
||||
WaitGuard waitGuard
|
||||
) {
|
||||
return getOrCompileWithStatus(key, compiler, waitGuard, plan -> true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 命中计划或完成冷编译,并按调用方当前上下文校验缓存计划。
|
||||
*
|
||||
* @param key 缓存键
|
||||
* @param compiler 冷编译函数
|
||||
* @param waitGuard 等待终态检查器
|
||||
* @param cachedPlanValidator 缓存计划是否仍适用于当前上下文
|
||||
* @return 缓存查询结果
|
||||
*/
|
||||
LookupResult getOrCompileWithStatus(
|
||||
PlanCacheKey key,
|
||||
Supplier<FederationSqlPlan> compiler,
|
||||
WaitGuard waitGuard,
|
||||
Predicate<FederationSqlPlan> cachedPlanValidator
|
||||
) {
|
||||
return getOrCompileWithStatus(
|
||||
key,
|
||||
compiler,
|
||||
waitGuard,
|
||||
cachedPlanValidator,
|
||||
Instant.MIN
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 命中计划或完成冷编译,并阻止较旧上下文淘汰或覆盖较新的缓存计划。
|
||||
*
|
||||
* @param key 缓存键
|
||||
* @param compiler 冷编译函数
|
||||
* @param waitGuard 等待终态检查器
|
||||
* @param cachedPlanValidator 缓存计划是否仍适用于当前上下文
|
||||
* @param contextGeneration 当前上下文的可比较代际
|
||||
* @return 缓存查询结果
|
||||
*/
|
||||
LookupResult getOrCompileWithStatus(
|
||||
PlanCacheKey key,
|
||||
Supplier<FederationSqlPlan> compiler,
|
||||
WaitGuard waitGuard,
|
||||
Predicate<FederationSqlPlan> cachedPlanValidator,
|
||||
Instant contextGeneration
|
||||
) {
|
||||
Instant generation = java.util.Objects.requireNonNull(
|
||||
contextGeneration,
|
||||
"contextGeneration"
|
||||
);
|
||||
while (true) {
|
||||
ensureOpen();
|
||||
waitGuard.ensureAllowed();
|
||||
CacheEntry cached;
|
||||
synchronized (entries) {
|
||||
cached = cachedEntry(key, nanoTime.getAsLong());
|
||||
}
|
||||
if (cached != null && cachedPlanValidator.test(cached.plan())) {
|
||||
return new LookupResult(cached.plan(), true);
|
||||
}
|
||||
if (cached != null) {
|
||||
synchronized (entries) {
|
||||
removeEntryIfNotNewer(key, cached.plan(), generation);
|
||||
}
|
||||
}
|
||||
|
||||
CompletableFuture<FederationSqlPlan> existing = inFlight.get(key);
|
||||
if (existing != null) {
|
||||
FederationSqlPlan sharedPlan = await(existing, waitGuard);
|
||||
if (cachedPlanValidator.test(sharedPlan)) {
|
||||
return new LookupResult(sharedPlan, true);
|
||||
}
|
||||
synchronized (entries) {
|
||||
removeEntryIfNotNewer(key, sharedPlan, generation);
|
||||
}
|
||||
awaitInFlightRemoval(key, existing, waitGuard);
|
||||
continue;
|
||||
}
|
||||
|
||||
acquireCompileSlot(waitGuard);
|
||||
CompletableFuture<FederationSqlPlan> ownFuture = new CompletableFuture<>();
|
||||
try {
|
||||
ensureOpen();
|
||||
CacheEntry cachedAfterPermit;
|
||||
synchronized (entries) {
|
||||
cachedAfterPermit = cachedEntry(key, nanoTime.getAsLong());
|
||||
}
|
||||
if (cachedAfterPermit != null
|
||||
&& cachedPlanValidator.test(cachedAfterPermit.plan())) {
|
||||
compileSlots.release();
|
||||
return new LookupResult(cachedAfterPermit.plan(), true);
|
||||
}
|
||||
if (cachedAfterPermit != null) {
|
||||
synchronized (entries) {
|
||||
removeEntryIfNotNewer(
|
||||
key,
|
||||
cachedAfterPermit.plan(),
|
||||
generation
|
||||
);
|
||||
}
|
||||
}
|
||||
synchronized (entries) {
|
||||
// 与 close 使用同一线性化锁,禁止关闭后登记新的编译任务。
|
||||
ensureOpen();
|
||||
existing = inFlight.putIfAbsent(key, ownFuture);
|
||||
}
|
||||
} catch (Throwable throwable) {
|
||||
compileSlots.release();
|
||||
throw throwable;
|
||||
}
|
||||
if (existing != null) {
|
||||
// 相同键只短暂占用配额完成握手,等待期间不阻塞其他冷键。
|
||||
compileSlots.release();
|
||||
FederationSqlPlan sharedPlan = await(existing, waitGuard);
|
||||
if (cachedPlanValidator.test(sharedPlan)) {
|
||||
return new LookupResult(sharedPlan, true);
|
||||
}
|
||||
synchronized (entries) {
|
||||
removeEntryIfNotNewer(key, sharedPlan, generation);
|
||||
}
|
||||
awaitInFlightRemoval(key, existing, waitGuard);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
waitGuard.ensureAllowed();
|
||||
FederationSqlPlan plan = compiler.get();
|
||||
waitGuard.ensureAllowed();
|
||||
long weight = estimateWeight(plan);
|
||||
boolean cacheable = weight <= maximumWeightBytes
|
||||
&& cachedPlanValidator.test(plan);
|
||||
synchronized (entries) {
|
||||
// 缓存写入、成功发布与 close 共享线性化点,避免关闭后的迟到发布。
|
||||
ensureOpen();
|
||||
if (cacheable) {
|
||||
if (putEntry(key, plan, weight, nanoTime.getAsLong(), generation)) {
|
||||
evictToBounds();
|
||||
}
|
||||
}
|
||||
ownFuture.complete(plan);
|
||||
}
|
||||
return new LookupResult(plan, false);
|
||||
} catch (Throwable throwable) {
|
||||
ownFuture.completeExceptionally(throwable);
|
||||
throw throwable;
|
||||
} finally {
|
||||
inFlight.remove(key, ownFuture);
|
||||
compileSlots.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitInFlightRemoval(
|
||||
PlanCacheKey key,
|
||||
CompletableFuture<FederationSqlPlan> completed,
|
||||
WaitGuard waitGuard
|
||||
) {
|
||||
while (inFlight.get(key) == completed) {
|
||||
ensureOpen();
|
||||
waitGuard.ensureAllowed();
|
||||
LockSupport.parkNanos(Math.min(
|
||||
boundedWaitNanos(waitGuard.remainingNanos()),
|
||||
TimeUnit.MILLISECONDS.toNanos(1)
|
||||
));
|
||||
if (Thread.interrupted()) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.SQL_COMPILE_FAILED,
|
||||
"waiting for a stale in-flight SQL compilation was interrupted"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计划缓存查询结果。
|
||||
*
|
||||
* @param plan 计划
|
||||
* @param cacheHit 是否复用缓存或同键 single-flight
|
||||
*/
|
||||
record LookupResult(FederationSqlPlan plan, boolean cacheHit) {
|
||||
}
|
||||
|
||||
private static int defaultCompilationConcurrency() {
|
||||
return Math.max(1, Math.min(8, Runtime.getRuntime().availableProcessors()));
|
||||
}
|
||||
|
||||
private static long defaultMaximumWeight(int maximumEntries) {
|
||||
return Math.max(16L * 1024L * 1024L, Math.min(
|
||||
256L * 1024L * 1024L,
|
||||
maximumEntries * 128L * 1024L
|
||||
));
|
||||
}
|
||||
|
||||
private void acquireCompileSlot(WaitGuard waitGuard) {
|
||||
while (true) {
|
||||
ensureOpen();
|
||||
waitGuard.ensureAllowed();
|
||||
long waitNanos = boundedWaitNanos(waitGuard.remainingNanos());
|
||||
try {
|
||||
if (compileSlots.tryAcquire(waitNanos, TimeUnit.NANOSECONDS)) {
|
||||
try {
|
||||
ensureOpen();
|
||||
} catch (RuntimeException exception) {
|
||||
compileSlots.release();
|
||||
throw exception;
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.SQL_COMPILE_FAILED,
|
||||
"waiting for a SQL compile slot was interrupted",
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureOpen() {
|
||||
if (closed.get()) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.ENGINE_CLOSED,
|
||||
"plan cache is closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static FederationSqlPlan await(
|
||||
CompletableFuture<FederationSqlPlan> future,
|
||||
WaitGuard waitGuard
|
||||
) {
|
||||
while (true) {
|
||||
waitGuard.ensureAllowed();
|
||||
try {
|
||||
return future.get(
|
||||
boundedWaitNanos(waitGuard.remainingNanos()),
|
||||
TimeUnit.NANOSECONDS
|
||||
);
|
||||
} catch (TimeoutException ignored) {
|
||||
// 短轮询使同键等待能及时响应取消和统一截止时间。
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.SQL_COMPILE_FAILED,
|
||||
"waiting for an in-flight SQL compilation was interrupted",
|
||||
exception
|
||||
);
|
||||
} catch (ExecutionException exception) {
|
||||
if (exception.getCause() instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
throw new CompletionException(exception.getCause());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long boundedWaitNanos(long remainingNanos) {
|
||||
return Math.max(
|
||||
1L,
|
||||
Math.min(remainingNanos, TimeUnit.MILLISECONDS.toNanos(50))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 冷编译等待检查器。
|
||||
*/
|
||||
interface WaitGuard {
|
||||
|
||||
/** 检查等待是否仍允许继续。 */
|
||||
void ensureAllowed();
|
||||
|
||||
/**
|
||||
* 返回剩余等待时间。
|
||||
*
|
||||
* @return 剩余纳秒
|
||||
*/
|
||||
long remainingNanos();
|
||||
|
||||
/**
|
||||
* 返回无限制检查器。
|
||||
*
|
||||
* @return 无限制检查器
|
||||
*/
|
||||
static WaitGuard unbounded() {
|
||||
return UnboundedHolder.INSTANCE;
|
||||
}
|
||||
|
||||
/** 无限制实例持有者。 */
|
||||
final class UnboundedHolder {
|
||||
private static final WaitGuard INSTANCE = new WaitGuard() {
|
||||
@Override
|
||||
public void ensureAllowed() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public long remainingNanos() {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
};
|
||||
|
||||
private UnboundedHolder() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前缓存条目数,供测试与指标桥接读取。
|
||||
*
|
||||
* @return 条目数
|
||||
*/
|
||||
int size() {
|
||||
synchronized (entries) {
|
||||
removeExpired(nanoTime.getAsLong());
|
||||
return entries.size();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前缓存估算权重,供测试和监控读取。
|
||||
*
|
||||
* @return 当前估算权重
|
||||
*/
|
||||
long weightBytes() {
|
||||
synchronized (entries) {
|
||||
removeExpired(nanoTime.getAsLong());
|
||||
return currentWeightBytes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前可用冷编译槽,供关闭竞态测试确认许可没有泄漏。
|
||||
*
|
||||
* @return 可用冷编译槽
|
||||
*/
|
||||
int availableCompileSlots() {
|
||||
return compileSlots.availablePermits();
|
||||
}
|
||||
|
||||
/**
|
||||
* 精确移除引用已关闭 Runtime 身份的计划。
|
||||
*
|
||||
* @param runtime 已关闭 Runtime
|
||||
*/
|
||||
void invalidateRuntime(SourceRuntime runtime) {
|
||||
invalidateRuntimeIdentity(
|
||||
runtime.definition().sourceId(),
|
||||
runtime.definition().revision(),
|
||||
runtime.sourceChecksum(),
|
||||
runtime.runtimeFingerprint()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 精确移除引用指定 Runtime 身份的计划,供生命周期回调与回归测试复用。
|
||||
*
|
||||
* @param sourceId 物理源标识
|
||||
* @param revision Definition 版本
|
||||
* @param checksum Definition 校验和
|
||||
* @param runtimeFingerprint 数据库与驱动指纹
|
||||
*/
|
||||
void invalidateRuntimeIdentity(
|
||||
com.easyagents.federation.sql.source.SourceId sourceId,
|
||||
long revision,
|
||||
String checksum,
|
||||
String runtimeFingerprint
|
||||
) {
|
||||
synchronized (entries) {
|
||||
Iterator<Map.Entry<PlanCacheKey, CacheEntry>> iterator = entries.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
CacheEntry entry = iterator.next().getValue();
|
||||
boolean matches = entry.plan().sourceRuntimeIdentities().stream().anyMatch(identity ->
|
||||
identity.sourceId().equals(sourceId)
|
||||
&& identity.sourceRevision() == revision
|
||||
&& identity.sourceChecksum().equals(checksum)
|
||||
&& identity.runtimeFingerprint().equals(runtimeFingerprint)
|
||||
);
|
||||
if (matches) {
|
||||
currentWeightBytes -= entry.weightBytes();
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空计划和进行中的编译记录。
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
synchronized (entries) {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
entries.clear();
|
||||
currentWeightBytes = 0;
|
||||
FederationSqlException failure = new FederationSqlException(
|
||||
FederationSqlErrorCode.ENGINE_CLOSED,
|
||||
"plan cache was closed during compilation"
|
||||
);
|
||||
inFlight.values().forEach(future -> future.completeExceptionally(failure));
|
||||
inFlight.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private CacheEntry cachedEntry(PlanCacheKey key, long now) {
|
||||
CacheEntry cached = entries.get(key);
|
||||
if (cached == null) {
|
||||
return null;
|
||||
}
|
||||
if (isExpired(cached, now)) {
|
||||
removeEntryIfSame(key, cached.plan());
|
||||
return null;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
private void removeEntryIfSame(PlanCacheKey key, FederationSqlPlan plan) {
|
||||
CacheEntry current = entries.get(key);
|
||||
if (current != null && current.plan() == plan) {
|
||||
entries.remove(key);
|
||||
currentWeightBytes -= current.weightBytes();
|
||||
}
|
||||
}
|
||||
|
||||
private void removeEntryIfNotNewer(
|
||||
PlanCacheKey key,
|
||||
FederationSqlPlan plan,
|
||||
Instant contextGeneration
|
||||
) {
|
||||
CacheEntry current = entries.get(key);
|
||||
if (current != null
|
||||
&& current.plan() == plan
|
||||
&& !current.contextGeneration().isAfter(contextGeneration)) {
|
||||
entries.remove(key);
|
||||
currentWeightBytes -= current.weightBytes();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean putEntry(
|
||||
PlanCacheKey key,
|
||||
FederationSqlPlan plan,
|
||||
long weightBytes,
|
||||
long now,
|
||||
Instant contextGeneration
|
||||
) {
|
||||
long statisticsTtlNanos = remainingStatisticsTtlNanos(
|
||||
plan.statisticsValidUntil(),
|
||||
Instant.now()
|
||||
);
|
||||
long effectiveTtlNanos = Math.min(ttlNanos, statisticsTtlNanos);
|
||||
if (effectiveTtlNanos <= 0L) {
|
||||
return false;
|
||||
}
|
||||
CacheEntry current = entries.get(key);
|
||||
if (current != null && current.contextGeneration().isAfter(contextGeneration)) {
|
||||
return false;
|
||||
}
|
||||
CacheEntry previous = entries.put(
|
||||
key,
|
||||
new CacheEntry(
|
||||
plan,
|
||||
weightBytes,
|
||||
now,
|
||||
effectiveTtlNanos,
|
||||
contextGeneration
|
||||
)
|
||||
);
|
||||
if (previous != null) {
|
||||
currentWeightBytes -= previous.weightBytes();
|
||||
}
|
||||
currentWeightBytes += weightBytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void evictToBounds() {
|
||||
Iterator<Map.Entry<PlanCacheKey, CacheEntry>> iterator = entries.entrySet().iterator();
|
||||
while ((entries.size() > maximumEntries || currentWeightBytes > maximumWeightBytes)
|
||||
&& iterator.hasNext()) {
|
||||
CacheEntry eldest = iterator.next().getValue();
|
||||
currentWeightBytes -= eldest.weightBytes();
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private void removeExpired(long now) {
|
||||
Iterator<Map.Entry<PlanCacheKey, CacheEntry>> iterator = entries.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
CacheEntry entry = iterator.next().getValue();
|
||||
if (isExpired(entry, now)) {
|
||||
currentWeightBytes -= entry.weightBytes();
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long estimateWeight(FederationSqlPlan plan) {
|
||||
long[] nodes = {0};
|
||||
if (plan.relRoot() != null && plan.relRoot().rel != null) {
|
||||
new RelVisitor() {
|
||||
@Override
|
||||
public void visit(RelNode node, int ordinal, RelNode parent) {
|
||||
nodes[0]++;
|
||||
super.visit(node, ordinal, parent);
|
||||
}
|
||||
}.go(plan.relRoot().rel);
|
||||
}
|
||||
long characters = plan.normalizedSql().length() + plan.executableSql().length();
|
||||
for (var fragment : plan.fragments()) {
|
||||
characters += fragment.executableSql().length();
|
||||
}
|
||||
return 4_096L
|
||||
+ nodes[0] * 1_024L
|
||||
+ characters * 2L
|
||||
+ plan.columns().size() * 256L
|
||||
+ plan.fragments().size() * 512L;
|
||||
}
|
||||
|
||||
private static long saturatingNanos(Duration duration) {
|
||||
try {
|
||||
return duration.toNanos();
|
||||
} catch (ArithmeticException ignored) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
private static long remainingStatisticsTtlNanos(Instant validUntil, Instant now) {
|
||||
if (validUntil == null || Instant.MAX.equals(validUntil)) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
if (!validUntil.isAfter(now)) {
|
||||
return 0L;
|
||||
}
|
||||
return saturatingNanos(Duration.between(now, validUntil));
|
||||
}
|
||||
|
||||
private static boolean isExpired(CacheEntry entry, long now) {
|
||||
return entry.ttlNanos() != Long.MAX_VALUE
|
||||
&& now - entry.createdAtNanos() >= entry.ttlNanos();
|
||||
}
|
||||
|
||||
private record CacheEntry(
|
||||
FederationSqlPlan plan,
|
||||
long weightBytes,
|
||||
long createdAtNanos,
|
||||
long ttlNanos,
|
||||
Instant contextGeneration
|
||||
) {
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,423 @@
|
||||
package com.easyagents.federation.sql.runtime;
|
||||
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import com.easyagents.federation.sql.api.SqlCompletionItem;
|
||||
import com.easyagents.federation.sql.api.SqlCompletionKind;
|
||||
import com.easyagents.federation.sql.api.SqlCompletionRequest;
|
||||
import com.easyagents.federation.sql.api.SqlCompletionResult;
|
||||
import com.easyagents.federation.sql.federation.FederationLogicalTableDefinition;
|
||||
import com.easyagents.federation.sql.federation.FederationQueryScopeDefinition;
|
||||
import com.easyagents.federation.sql.federation.FederationSourceBindingDefinition;
|
||||
import com.easyagents.federation.sql.source.FederationSchemaDefinition;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import org.apache.calcite.config.CalciteConnectionConfigImpl;
|
||||
import org.apache.calcite.config.CalciteConnectionProperty;
|
||||
import org.apache.calcite.jdbc.CalciteSchema;
|
||||
import org.apache.calcite.jdbc.JavaTypeFactoryImpl;
|
||||
import org.apache.calcite.prepare.CalciteCatalogReader;
|
||||
import org.apache.calcite.schema.Schema;
|
||||
import org.apache.calcite.schema.SchemaPlus;
|
||||
import org.apache.calcite.schema.Table;
|
||||
import org.apache.calcite.schema.impl.AbstractSchema;
|
||||
import org.apache.calcite.sql.SqlFunction;
|
||||
import org.apache.calcite.sql.SqlOperatorTable;
|
||||
import org.apache.calcite.sql.advise.SqlAdvisor;
|
||||
import org.apache.calcite.sql.advise.SqlAdvisorValidator;
|
||||
import org.apache.calcite.sql.fun.SqlStdOperatorTable;
|
||||
import org.apache.calcite.sql.parser.SqlParser;
|
||||
import org.apache.calcite.sql.util.SqlOperatorTables;
|
||||
import org.apache.calcite.sql.validate.SqlMoniker;
|
||||
import org.apache.calcite.sql.validate.SqlMonikerImpl;
|
||||
import org.apache.calcite.sql.validate.SqlMonikerType;
|
||||
import org.apache.calcite.sql.validate.SqlValidator;
|
||||
|
||||
/**
|
||||
* 基于 Calcite Advisor 的请求级 SQL 补全器。
|
||||
*/
|
||||
final class CalciteSqlCompleter {
|
||||
|
||||
/**
|
||||
* 在当前 Runtime 快照内生成上下文补全候选。
|
||||
*
|
||||
* @param request 补全请求
|
||||
* @param snapshot 查询范围 Runtime 快照
|
||||
* @return 替换区间与候选列表
|
||||
*/
|
||||
SqlCompletionResult complete(
|
||||
SqlCompletionRequest request,
|
||||
FederationQueryScopeSnapshot snapshot
|
||||
) {
|
||||
try {
|
||||
CompletionCatalog catalog = buildCatalog(request.queryScope(), snapshot);
|
||||
SourceRuntime plannerRuntime = plannerRuntime(request.queryScope(), snapshot);
|
||||
SqlParser.Config parserConfig = plannerRuntime.adapter()
|
||||
.parserConfig(plannerRuntime.dialect());
|
||||
JavaTypeFactoryImpl typeFactory = new JavaTypeFactoryImpl(
|
||||
plannerRuntime.adapter().typeSystem()
|
||||
);
|
||||
CalciteCatalogReader catalogReader = new CalciteCatalogReader(
|
||||
CalciteSchema.from(catalog.root()),
|
||||
catalog.defaultPath(),
|
||||
typeFactory,
|
||||
connectionConfig(parserConfig)
|
||||
);
|
||||
SqlOperatorTable operatorTable = operatorTable(snapshot);
|
||||
SqlAdvisorValidator validator = new SqlAdvisorValidator(
|
||||
operatorTable,
|
||||
catalogReader,
|
||||
typeFactory,
|
||||
SqlValidator.Config.DEFAULT
|
||||
.withConformance(parserConfig.conformance())
|
||||
.withIdentifierExpansion(true)
|
||||
.withLenientOperatorLookup(true)
|
||||
);
|
||||
SqlAdvisor advisor = new SqlAdvisor(validator, parserConfig);
|
||||
String[] replaced = {""};
|
||||
List<SqlMoniker> hints = new ArrayList<>(advisor.getCompletionHints(
|
||||
request.sql(),
|
||||
request.cursorOffset(),
|
||||
replaced
|
||||
));
|
||||
// Advisor 在语句首部仅有半截关键字时可能没有候选,直接复用当前
|
||||
// Calcite Parser 的期望 Token 补齐,避免维护一份易漂移的关键字表。
|
||||
hints.addAll(statementStartHints(
|
||||
advisor,
|
||||
request.sql(),
|
||||
request.cursorOffset(),
|
||||
replaced[0]
|
||||
));
|
||||
// SqlAdvisor 对末尾三段 Schema 路径可能不返回候选,继续使用同一 Calcite
|
||||
// CatalogReader 补齐该路径,避免实现第二套 SQL 元数据目录。
|
||||
hints.addAll(qualifiedCatalogHints(
|
||||
catalogReader,
|
||||
request.sql(),
|
||||
request.cursorOffset(),
|
||||
replaced[0]
|
||||
));
|
||||
int replaceStart = request.cursorOffset() - replaced[0].length();
|
||||
return new SqlCompletionResult(
|
||||
replaceStart,
|
||||
request.cursorOffset(),
|
||||
completionItems(advisor, operatorTable, hints, replaced[0])
|
||||
);
|
||||
} catch (FederationSqlException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.SQL_COMPLETION_FAILED,
|
||||
"Calcite SQL completion failed",
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static CompletionCatalog buildCatalog(
|
||||
FederationQueryScopeDefinition scope,
|
||||
FederationQueryScopeSnapshot snapshot
|
||||
) {
|
||||
return scope.logicalTables().isEmpty()
|
||||
? buildPhysicalCatalog(scope, snapshot)
|
||||
: buildLogicalCatalog(scope, snapshot);
|
||||
}
|
||||
|
||||
private static CompletionCatalog buildLogicalCatalog(
|
||||
FederationQueryScopeDefinition scope,
|
||||
FederationQueryScopeSnapshot snapshot
|
||||
) {
|
||||
SchemaPlus root = CalciteSchema.createRootSchema(true, false).plus();
|
||||
Map<String, SchemaPlus> bindingSchemas = new LinkedHashMap<>();
|
||||
Map<String, SchemaPlus> logicalSchemas = new LinkedHashMap<>();
|
||||
for (FederationLogicalTableDefinition definition : scope.logicalTables()) {
|
||||
SourceRuntime runtime = snapshot.runtime(definition.bindingName());
|
||||
Table sourceTable = sourceTable(scope, runtime, definition);
|
||||
SchemaPlus bindingSchema = bindingSchemas.computeIfAbsent(
|
||||
definition.bindingName(),
|
||||
name -> root.add(name, new AbstractSchema())
|
||||
);
|
||||
String schemaKey = definition.bindingName() + '\u0000' + definition.schemaName();
|
||||
SchemaPlus logicalSchema = logicalSchemas.computeIfAbsent(
|
||||
schemaKey,
|
||||
ignored -> bindingSchema.add(definition.schemaName(), new AbstractSchema())
|
||||
);
|
||||
// 逻辑目录只挂载显式授权别名,避免 Advisor 泄露同 Schema 的其他物理表。
|
||||
root.add(definition.logicalName(), sourceTable);
|
||||
logicalSchema.add(definition.logicalName(), sourceTable);
|
||||
}
|
||||
return new CompletionCatalog(root, List.of());
|
||||
}
|
||||
|
||||
private static CompletionCatalog buildPhysicalCatalog(
|
||||
FederationQueryScopeDefinition scope,
|
||||
FederationQueryScopeSnapshot snapshot
|
||||
) {
|
||||
SchemaPlus root = CalciteSchema.createRootSchema(true, false).plus();
|
||||
List<String> defaultPath = List.of();
|
||||
for (Map.Entry<String, SourceRuntime> entry : snapshot.runtimesByBinding().entrySet()) {
|
||||
String bindingName = entry.getKey();
|
||||
SourceRuntime runtime = entry.getValue();
|
||||
FederationSourceBindingDefinition binding = scope.bindings().get(bindingName);
|
||||
SchemaPlus bindingSchema = root.add(bindingName, new AbstractSchema());
|
||||
Map<String, String> mappings = schemaMappings(binding, runtime);
|
||||
for (Map.Entry<String, String> mapping : mappings.entrySet()) {
|
||||
SchemaPlus sourceSchema = sourceSchema(runtime, mapping.getValue());
|
||||
Schema mounted = sourceSchema.unwrap(Schema.class);
|
||||
bindingSchema.add(mapping.getKey(), mounted);
|
||||
}
|
||||
if (bindingName.equals(scope.defaultBinding())) {
|
||||
defaultPath = mappings.size() == 1
|
||||
? List.of(bindingName, mappings.keySet().iterator().next())
|
||||
: List.of(bindingName);
|
||||
}
|
||||
}
|
||||
return new CompletionCatalog(root, defaultPath);
|
||||
}
|
||||
|
||||
private static Table sourceTable(
|
||||
FederationQueryScopeDefinition scope,
|
||||
SourceRuntime runtime,
|
||||
FederationLogicalTableDefinition definition
|
||||
) {
|
||||
FederationSourceBindingDefinition binding = scope.bindings()
|
||||
.get(definition.bindingName());
|
||||
String physicalSchema = mappedSchema(binding, definition.schemaName());
|
||||
SchemaPlus schema = sourceSchema(runtime, physicalSchema);
|
||||
Table table = schema.getTable(definition.sourceTableName());
|
||||
if (table == null) {
|
||||
String actualName = schema.getTableNames().stream()
|
||||
.filter(name -> name.equalsIgnoreCase(definition.sourceTableName()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
table = actualName == null ? null : schema.getTable(actualName);
|
||||
}
|
||||
if (table == null) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.INVALID_QUERY_SCOPE,
|
||||
"logical table maps an unknown source table: " + definition.logicalName()
|
||||
);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
private static SchemaPlus sourceSchema(SourceRuntime runtime, String schemaName) {
|
||||
SchemaPlus sourceRoot = runtime.rootSchema()
|
||||
.getSubSchema(runtime.definition().sourceId().value());
|
||||
SchemaPlus sourceSchema = sourceRoot == null ? null : sourceRoot.getSubSchema(schemaName);
|
||||
if (sourceSchema == null && sourceRoot != null) {
|
||||
String actualName = sourceRoot.getSubSchemaNames().stream()
|
||||
.filter(name -> name.equalsIgnoreCase(schemaName))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
sourceSchema = actualName == null ? null : sourceRoot.getSubSchema(actualName);
|
||||
}
|
||||
if (sourceSchema == null) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.INVALID_QUERY_SCOPE,
|
||||
"query scope maps an unknown source schema: " + schemaName
|
||||
);
|
||||
}
|
||||
return sourceSchema;
|
||||
}
|
||||
|
||||
private static String mappedSchema(
|
||||
FederationSourceBindingDefinition binding,
|
||||
String logicalSchema
|
||||
) {
|
||||
if (binding == null || binding.schemaMappings().isEmpty()) {
|
||||
return logicalSchema;
|
||||
}
|
||||
return binding.schemaMappings().entrySet().stream()
|
||||
.filter(entry -> entry.getKey().equalsIgnoreCase(logicalSchema))
|
||||
.map(Map.Entry::getValue)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new FederationSqlException(
|
||||
FederationSqlErrorCode.INVALID_QUERY_SCOPE,
|
||||
"logical table references an unknown mapped schema: " + logicalSchema
|
||||
));
|
||||
}
|
||||
|
||||
private static Map<String, String> schemaMappings(
|
||||
FederationSourceBindingDefinition binding,
|
||||
SourceRuntime runtime
|
||||
) {
|
||||
if (binding != null && !binding.schemaMappings().isEmpty()) {
|
||||
return binding.schemaMappings();
|
||||
}
|
||||
LinkedHashMap<String, String> mappings = new LinkedHashMap<>();
|
||||
for (FederationSchemaDefinition definition : runtime.definition().schemas()) {
|
||||
mappings.put(definition.logicalName(), definition.logicalName());
|
||||
}
|
||||
return mappings;
|
||||
}
|
||||
|
||||
private static CalciteConnectionConfigImpl connectionConfig(
|
||||
SqlParser.Config parserConfig
|
||||
) {
|
||||
return new CalciteConnectionConfigImpl(new Properties())
|
||||
.set(
|
||||
CalciteConnectionProperty.CASE_SENSITIVE,
|
||||
Boolean.toString(parserConfig.caseSensitive())
|
||||
)
|
||||
.set(CalciteConnectionProperty.QUOTING, parserConfig.quoting().name())
|
||||
.set(
|
||||
CalciteConnectionProperty.UNQUOTED_CASING,
|
||||
parserConfig.unquotedCasing().name()
|
||||
)
|
||||
.set(
|
||||
CalciteConnectionProperty.QUOTED_CASING,
|
||||
parserConfig.quotedCasing().name()
|
||||
)
|
||||
.set(
|
||||
CalciteConnectionProperty.CONFORMANCE,
|
||||
parserConfig.conformance().toString()
|
||||
);
|
||||
}
|
||||
|
||||
private static SqlOperatorTable operatorTable(FederationQueryScopeSnapshot snapshot) {
|
||||
List<SqlOperatorTable> adapterTables = snapshot.runtimesByBinding().values().stream()
|
||||
.map(runtime -> runtime.adapter().operatorTable())
|
||||
.distinct()
|
||||
.toList();
|
||||
List<SqlOperatorTable> tables = new ArrayList<>(adapterTables.size() + 1);
|
||||
// Adapter 只声明厂商扩展;标准 SQL 函数始终由 Calcite 标准表提供。
|
||||
tables.add(SqlStdOperatorTable.instance());
|
||||
tables.addAll(adapterTables);
|
||||
return SqlOperatorTables.chain(tables);
|
||||
}
|
||||
|
||||
private static SourceRuntime plannerRuntime(
|
||||
FederationQueryScopeDefinition scope,
|
||||
FederationQueryScopeSnapshot snapshot
|
||||
) {
|
||||
SourceRuntime runtime = snapshot.runtimesByBinding().get(scope.defaultBinding());
|
||||
return runtime == null
|
||||
? snapshot.runtimesByBinding().values().iterator().next()
|
||||
: runtime;
|
||||
}
|
||||
|
||||
private static List<SqlCompletionItem> completionItems(
|
||||
SqlAdvisor advisor,
|
||||
SqlOperatorTable operatorTable,
|
||||
List<SqlMoniker> hints,
|
||||
String replacedWord
|
||||
) {
|
||||
LinkedHashMap<String, SqlCompletionItem> distinct = new LinkedHashMap<>();
|
||||
for (SqlMoniker hint : hints) {
|
||||
List<String> qualifiedName = hint.getFullyQualifiedNames();
|
||||
String label = qualifiedName.isEmpty()
|
||||
? hint.id()
|
||||
: qualifiedName.get(qualifiedName.size() - 1);
|
||||
String insertText = advisor.getReplacement(hint, replacedWord);
|
||||
SqlCompletionKind kind = completionKind(
|
||||
hint.getType(),
|
||||
label,
|
||||
operatorTable
|
||||
);
|
||||
SqlCompletionItem item = new SqlCompletionItem(
|
||||
label,
|
||||
insertText,
|
||||
kind,
|
||||
qualifiedName
|
||||
);
|
||||
distinct.putIfAbsent(kind + "\u0000" + insertText + "\u0000" + qualifiedName, item);
|
||||
}
|
||||
return new ArrayList<>(distinct.values());
|
||||
}
|
||||
|
||||
private static List<SqlMoniker> qualifiedCatalogHints(
|
||||
CalciteCatalogReader catalogReader,
|
||||
String sql,
|
||||
int cursorOffset,
|
||||
String replacedWord
|
||||
) {
|
||||
int tokenStart = cursorOffset;
|
||||
while (tokenStart > 0) {
|
||||
char current = sql.charAt(tokenStart - 1);
|
||||
if (current != '.' && !Character.isJavaIdentifierPart(current)) {
|
||||
break;
|
||||
}
|
||||
tokenStart--;
|
||||
}
|
||||
String token = sql.substring(tokenStart, cursorOffset);
|
||||
int lastDot = token.lastIndexOf('.');
|
||||
if (lastDot <= 0) {
|
||||
return List.of();
|
||||
}
|
||||
String qualifier = token.substring(0, lastDot);
|
||||
List<String> path = List.of(qualifier.split("\\."));
|
||||
return catalogReader.getAllSchemaObjectNames(path).stream()
|
||||
.filter(moniker -> {
|
||||
List<String> names = moniker.getFullyQualifiedNames();
|
||||
String name = names.isEmpty() ? moniker.id() : names.get(names.size() - 1);
|
||||
return replacedWord.isEmpty()
|
||||
|| name.regionMatches(true, 0, replacedWord, 0, replacedWord.length());
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static List<SqlMoniker> statementStartHints(
|
||||
SqlAdvisor advisor,
|
||||
String sql,
|
||||
int cursorOffset,
|
||||
String replacedWord
|
||||
) {
|
||||
int wordStart = cursorOffset - replacedWord.length();
|
||||
if (wordStart < 0 || !sql.substring(0, wordStart).isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return advisor.getReservedAndKeyWords().stream()
|
||||
.filter(keyword -> keyword.regionMatches(
|
||||
true,
|
||||
0,
|
||||
replacedWord,
|
||||
0,
|
||||
replacedWord.length()
|
||||
))
|
||||
.map(keyword -> (SqlMoniker) new SqlMonikerImpl(
|
||||
List.of(keyword),
|
||||
SqlMonikerType.KEYWORD
|
||||
))
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static SqlCompletionKind completionKind(
|
||||
SqlMonikerType type,
|
||||
String label,
|
||||
SqlOperatorTable operatorTable
|
||||
) {
|
||||
// SqlAdvisor 会把 COUNT 等可直接输入的标准函数标记为 KEYWORD,继续以
|
||||
// Calcite OperatorTable 校正分类,避免前端维护函数名称清单。
|
||||
if (type == SqlMonikerType.FUNCTION || operatorTable.getOperatorList().stream()
|
||||
.anyMatch(operator -> operator instanceof SqlFunction
|
||||
&& operator.getName().equalsIgnoreCase(label))) {
|
||||
return SqlCompletionKind.FUNCTION;
|
||||
}
|
||||
if (type == SqlMonikerType.KEYWORD) {
|
||||
return SqlCompletionKind.KEYWORD;
|
||||
}
|
||||
if (type == SqlMonikerType.TABLE) {
|
||||
return SqlCompletionKind.TABLE;
|
||||
}
|
||||
if (type == SqlMonikerType.VIEW) {
|
||||
return SqlCompletionKind.VIEW;
|
||||
}
|
||||
if (type == SqlMonikerType.SCHEMA) {
|
||||
return SqlCompletionKind.SCHEMA;
|
||||
}
|
||||
if (type == SqlMonikerType.CATALOG) {
|
||||
return SqlCompletionKind.CATALOG;
|
||||
}
|
||||
if (type == SqlMonikerType.COLUMN) {
|
||||
return SqlCompletionKind.COLUMN;
|
||||
}
|
||||
return SqlCompletionKind.OTHER;
|
||||
}
|
||||
|
||||
private record CompletionCatalog(SchemaPlus root, List<String> defaultPath) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.easyagents.federation.sql.runtime;
|
||||
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
|
||||
/**
|
||||
* Core 内部用于在主动取消并关闭游标前定稿指标的回调。
|
||||
*/
|
||||
interface CancellationAwareFederationCursor {
|
||||
|
||||
/**
|
||||
* 标记查询已收到主动取消请求。
|
||||
*/
|
||||
void markCancelled();
|
||||
|
||||
/**
|
||||
* 标记查询因统一执行时限结束。
|
||||
*/
|
||||
void markTimedOut();
|
||||
|
||||
/**
|
||||
* 标记查询失败的稳定错误码。
|
||||
*
|
||||
* @param errorCode 错误码
|
||||
*/
|
||||
void markFailed(FederationSqlErrorCode errorCode);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.easyagents.federation.sql.runtime;
|
||||
|
||||
import com.easyagents.federation.sql.federation.FederationFragmentPlan;
|
||||
import org.apache.calcite.rel.type.RelDataType;
|
||||
|
||||
/**
|
||||
* Core 节点本地保存的分片执行元数据。
|
||||
*
|
||||
* @param plan 公共分片计划
|
||||
* @param rowType Calcite 输出行类型
|
||||
*/
|
||||
record CompiledFederationFragment(
|
||||
FederationFragmentPlan plan,
|
||||
RelDataType rowType
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.easyagents.federation.sql.runtime;
|
||||
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* 按逆序幂等关闭准入许可与 Runtime lease 的资源组合。
|
||||
*/
|
||||
final class CompositeQueryResources implements AutoCloseable {
|
||||
|
||||
private final List<AutoCloseable> resources;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
/**
|
||||
* 创建资源组合。
|
||||
*
|
||||
* @param resources 按获取顺序排列的资源
|
||||
*/
|
||||
CompositeQueryResources(AutoCloseable... resources) {
|
||||
this.resources = List.of(resources);
|
||||
}
|
||||
|
||||
/**
|
||||
* 逆序关闭资源并保留全部失败原因。
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
FederationSqlException failure = null;
|
||||
for (int index = resources.size() - 1; index >= 0; index--) {
|
||||
try {
|
||||
resources.get(index).close();
|
||||
} catch (Exception exception) {
|
||||
FederationSqlException wrapped = new FederationSqlException(
|
||||
FederationSqlErrorCode.RESOURCE_CLOSE_FAILED,
|
||||
"failed to close query resource",
|
||||
exception
|
||||
);
|
||||
if (failure == null) {
|
||||
failure = wrapped;
|
||||
} else {
|
||||
failure.addSuppressed(wrapped);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,846 @@
|
||||
package com.easyagents.federation.sql.runtime;
|
||||
|
||||
import com.easyagents.federation.sql.adapter.AdapterDialectContext;
|
||||
import com.easyagents.federation.sql.adapter.AdapterHints;
|
||||
import com.easyagents.federation.sql.adapter.AdapterSchemaContext;
|
||||
import com.easyagents.federation.sql.adapter.FederationSqlAdapterProvider;
|
||||
import com.easyagents.federation.sql.adapter.FederationSqlAdapterRegistry;
|
||||
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||
import com.easyagents.federation.sql.source.ActiveSourceState;
|
||||
import com.easyagents.federation.sql.source.FederationDataSourceHandle;
|
||||
import com.easyagents.federation.sql.source.FederationDataSourceResolver;
|
||||
import com.easyagents.federation.sql.source.FederationSchemaDefinition;
|
||||
import com.easyagents.federation.sql.source.FederationSourceDefinition;
|
||||
import com.easyagents.federation.sql.source.FederationSourceManager;
|
||||
import com.easyagents.federation.sql.source.FederationSourceState;
|
||||
import com.easyagents.federation.sql.source.FederationSourceStateProvider;
|
||||
import com.easyagents.federation.sql.source.FederationSourceView;
|
||||
import com.easyagents.federation.sql.source.PreparedSourceRuntime;
|
||||
import com.easyagents.federation.sql.source.SourceApplyOptions;
|
||||
import com.easyagents.federation.sql.source.SourceApplyResult;
|
||||
import com.easyagents.federation.sql.source.SourceApplyStatus;
|
||||
import com.easyagents.federation.sql.source.SourceId;
|
||||
import com.easyagents.federation.sql.source.SourceProbeResult;
|
||||
import com.easyagents.federation.sql.source.SourceRemoveResult;
|
||||
import com.easyagents.federation.sql.source.SourceRuntimeStatus;
|
||||
import com.easyagents.federation.sql.source.SourceSnapshotResult;
|
||||
import com.easyagents.federation.sql.source.SourceStateSubscription;
|
||||
import com.easyagents.federation.sql.source.SourceTombstone;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
import org.apache.calcite.jdbc.CalciteSchema;
|
||||
import org.apache.calcite.schema.Schema;
|
||||
import org.apache.calcite.schema.SchemaPlus;
|
||||
import org.apache.calcite.schema.impl.AbstractSchema;
|
||||
import org.apache.calcite.sql.SqlDialect;
|
||||
|
||||
/**
|
||||
* 以 revision、single-flight 初始化和 Runtime lease 管理数据源的默认实现。
|
||||
*/
|
||||
public final class DefaultFederationSourceManager implements FederationSourceManager, AutoCloseable {
|
||||
|
||||
private final FederationDataSourceResolver resolver;
|
||||
private final FederationSqlAdapterRegistry adapters;
|
||||
private final FederationSourceStateProvider stateProvider;
|
||||
private final Map<SourceId, SourceSlot> slots = new ConcurrentHashMap<>();
|
||||
private final Set<PreparedRuntime> preparedRuntimes = ConcurrentHashMap.newKeySet();
|
||||
private final Set<SourceRuntime> retiringRuntimes = ConcurrentHashMap.newKeySet();
|
||||
private final List<Consumer<SourceRuntime>> runtimeClosedListeners =
|
||||
new CopyOnWriteArrayList<>();
|
||||
private final Set<FederationDataSourceHandle> pendingHandleClosures =
|
||||
ConcurrentHashMap.newKeySet();
|
||||
private final Object sourceCatalogLock = new Object();
|
||||
private volatile SourceCatalogSnapshot sourceCatalogSnapshot = new SourceCatalogSnapshot(0, Set.of());
|
||||
private final ReentrantReadWriteLock lifecycleLock = new ReentrantReadWriteLock();
|
||||
private final Lock operationLock = lifecycleLock.readLock();
|
||||
private final Lock closeLock = lifecycleLock.writeLock();
|
||||
private final SourceStateSubscription subscription;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private final AtomicBoolean subscriptionClosed = new AtomicBoolean();
|
||||
private final Object subscriptionCloseLock = new Object();
|
||||
|
||||
/**
|
||||
* 创建数据源管理器并恢复共享快照、订阅变更提示。
|
||||
*
|
||||
* @param resolver DataSource 解析器
|
||||
* @param adapters Adapter 注册表
|
||||
* @param stateProvider 共享状态 Provider
|
||||
*/
|
||||
public DefaultFederationSourceManager(
|
||||
FederationDataSourceResolver resolver,
|
||||
FederationSqlAdapterRegistry adapters,
|
||||
FederationSourceStateProvider stateProvider
|
||||
) {
|
||||
this.resolver = resolver;
|
||||
this.adapters = adapters;
|
||||
this.stateProvider = stateProvider;
|
||||
applySnapshot(stateProvider.loadSnapshot());
|
||||
this.subscription = stateProvider.subscribe(this::applySharedState);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Resolver 探测数据源。
|
||||
*
|
||||
* @param definition 数据源定义
|
||||
* @return 探测结果
|
||||
*/
|
||||
@Override
|
||||
public SourceProbeResult probe(FederationSourceDefinition definition) {
|
||||
operationLock.lock();
|
||||
try {
|
||||
ensureOpen();
|
||||
return probeLocked(definition, resolver.resolve(definition));
|
||||
} finally {
|
||||
operationLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用临时句柄探测并确定性关闭全部临时资源。
|
||||
*
|
||||
* @param definition 数据源定义
|
||||
* @param temporaryHandle 临时句柄
|
||||
* @return 探测结果
|
||||
*/
|
||||
@Override
|
||||
public SourceProbeResult probe(
|
||||
FederationSourceDefinition definition,
|
||||
FederationDataSourceHandle temporaryHandle
|
||||
) {
|
||||
operationLock.lock();
|
||||
try {
|
||||
return probeLocked(definition, temporaryHandle);
|
||||
} finally {
|
||||
operationLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private SourceProbeResult probeLocked(
|
||||
FederationSourceDefinition definition,
|
||||
FederationDataSourceHandle temporaryHandle
|
||||
) {
|
||||
SourceRuntime runtime = null;
|
||||
boolean buildAttempted = false;
|
||||
try {
|
||||
ensureOpen();
|
||||
buildAttempted = true;
|
||||
runtime = buildRuntime(definition, temporaryHandle);
|
||||
return new SourceProbeResult(
|
||||
definition.sourceId(),
|
||||
true,
|
||||
temporaryHandle.fingerprint(),
|
||||
runtime.compatibility().diagnostic()
|
||||
);
|
||||
} catch (RuntimeException exception) {
|
||||
if (!buildAttempted) {
|
||||
closeHandleAndSuppress(temporaryHandle, exception);
|
||||
}
|
||||
throw exception;
|
||||
} finally {
|
||||
if (runtime != null) {
|
||||
forceCloseRuntime(runtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用 Definition,并可选择立即预热。
|
||||
*
|
||||
* @param definition 数据源定义
|
||||
* @param options 应用策略
|
||||
* @return 应用结果
|
||||
*/
|
||||
@Override
|
||||
public SourceApplyResult apply(FederationSourceDefinition definition, SourceApplyOptions options) {
|
||||
operationLock.lock();
|
||||
try {
|
||||
ensureOpen();
|
||||
SourceApplyResult result = applyState(ActiveSourceState.of(definition));
|
||||
if (options.prewarm() && result.status() != SourceApplyStatus.IGNORED_STALE
|
||||
&& result.status() != SourceApplyStatus.CONFLICT) {
|
||||
ensureReadyInternal(definition.sourceId(), definition.revision());
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
operationLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建与共享 Slot 隔离的 Runtime,供调用方在外部状态 CAS 前验证全部本地资源。
|
||||
*
|
||||
* @param definition 即将发布的数据源 Definition
|
||||
* @return 预构建 Runtime
|
||||
*/
|
||||
@Override
|
||||
public PreparedSourceRuntime prepare(FederationSourceDefinition definition) {
|
||||
operationLock.lock();
|
||||
try {
|
||||
ensureOpen();
|
||||
FederationDataSourceHandle handle = resolver.resolve(definition);
|
||||
SourceRuntime runtime = buildRuntime(definition, handle);
|
||||
PreparedRuntime prepared = new PreparedRuntime(runtime);
|
||||
preparedRuntimes.add(prepared);
|
||||
return prepared;
|
||||
} finally {
|
||||
operationLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子接管并发布由当前管理器创建的预构建 Runtime。
|
||||
*
|
||||
* @param prepared 预构建 Runtime
|
||||
* @return Definition 应用结果
|
||||
*/
|
||||
@Override
|
||||
public SourceApplyResult commit(PreparedSourceRuntime prepared) {
|
||||
operationLock.lock();
|
||||
SourceRuntime candidate = null;
|
||||
boolean installed = false;
|
||||
try {
|
||||
ensureOpen();
|
||||
if (!(prepared instanceof PreparedRuntime preparedRuntime)
|
||||
|| !preparedRuntimes.contains(preparedRuntime)) {
|
||||
throw new IllegalArgumentException(
|
||||
"prepared runtime was not created by this source manager or is no longer open"
|
||||
);
|
||||
}
|
||||
candidate = preparedRuntime.take();
|
||||
preparedRuntimes.remove(preparedRuntime);
|
||||
PreparedCommitOutcome outcome = commitPreparedRuntime(candidate);
|
||||
installed = outcome.installed();
|
||||
return outcome.result();
|
||||
} finally {
|
||||
if (candidate != null && !installed) {
|
||||
forceCloseRuntime(candidate);
|
||||
}
|
||||
operationLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用共享状态快照并按结果分类计数。
|
||||
*
|
||||
* @param states 状态集合
|
||||
* @return 应用统计
|
||||
*/
|
||||
@Override
|
||||
public SourceSnapshotResult applySnapshot(Collection<FederationSourceState> states) {
|
||||
operationLock.lock();
|
||||
try {
|
||||
ensureOpen();
|
||||
int applied = 0;
|
||||
int idempotent = 0;
|
||||
int stale = 0;
|
||||
int conflicts = 0;
|
||||
if (states == null) {
|
||||
return new SourceSnapshotResult(0, 0, 0, 0);
|
||||
}
|
||||
for (FederationSourceState state : states) {
|
||||
SourceApplyResult result = applyState(state);
|
||||
switch (result.status()) {
|
||||
case APPLIED -> applied++;
|
||||
case IDEMPOTENT -> idempotent++;
|
||||
case IGNORED_STALE -> stale++;
|
||||
case CONFLICT -> conflicts++;
|
||||
}
|
||||
}
|
||||
return new SourceSnapshotResult(applied, idempotent, stale, conflicts);
|
||||
} finally {
|
||||
operationLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用删除墓碑。
|
||||
*
|
||||
* @param tombstone 删除墓碑
|
||||
* @return 删除结果
|
||||
*/
|
||||
@Override
|
||||
public SourceRemoveResult remove(SourceTombstone tombstone) {
|
||||
operationLock.lock();
|
||||
try {
|
||||
ensureOpen();
|
||||
return new SourceRemoveResult(applyState(tombstone));
|
||||
} finally {
|
||||
operationLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回节点本地数据源视图。
|
||||
*
|
||||
* @param sourceId 数据源标识
|
||||
* @return 可选视图
|
||||
*/
|
||||
@Override
|
||||
public Optional<FederationSourceView> view(SourceId sourceId) {
|
||||
operationLock.lock();
|
||||
try {
|
||||
ensureOpen();
|
||||
SourceSlot slot = slots.get(sourceId);
|
||||
if (slot == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
synchronized (slot) {
|
||||
return Optional.of(toView(slot));
|
||||
}
|
||||
} finally {
|
||||
operationLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保节点本地 Runtime 达到最低 revision;同一 Slot 内初始化天然 single-flight。
|
||||
*
|
||||
* @param sourceId 数据源标识
|
||||
* @param minimumRevision 最低版本
|
||||
* @return 就绪视图
|
||||
*/
|
||||
@Override
|
||||
public FederationSourceView ensureReady(SourceId sourceId, long minimumRevision) {
|
||||
operationLock.lock();
|
||||
try {
|
||||
ensureOpen();
|
||||
return ensureReadyInternal(sourceId, minimumRevision);
|
||||
} finally {
|
||||
operationLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private FederationSourceView ensureReadyInternal(SourceId sourceId, long minimumRevision) {
|
||||
SourceSlot slot = refreshSlotIfNeeded(sourceId, minimumRevision);
|
||||
synchronized (slot) {
|
||||
ensureReadyLocked(slot, sourceId, minimumRevision);
|
||||
return toView(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子确保并获取 Runtime lease,避免 revision 切换与执行获取之间的竞态。
|
||||
*
|
||||
* @param sourceId 数据源标识
|
||||
* @param minimumRevision 最低版本
|
||||
* @return Runtime lease
|
||||
*/
|
||||
public SourceRuntime.RuntimeLease acquireRuntime(SourceId sourceId, long minimumRevision) {
|
||||
operationLock.lock();
|
||||
try {
|
||||
ensureOpen();
|
||||
SourceSlot slot = refreshSlotIfNeeded(sourceId, minimumRevision);
|
||||
synchronized (slot) {
|
||||
ensureReadyLocked(slot, sourceId, minimumRevision);
|
||||
return slot.current.acquire();
|
||||
}
|
||||
} finally {
|
||||
operationLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子返回注册表代次与 SourceId 集合,供计划缓存和编译共享同一语义快照。
|
||||
*
|
||||
* @return 注册表快照
|
||||
*/
|
||||
SourceCatalogSnapshot catalogSnapshot() {
|
||||
operationLock.lock();
|
||||
try {
|
||||
ensureOpen();
|
||||
return sourceCatalogSnapshot;
|
||||
} finally {
|
||||
operationLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private SourceSlot refreshSlotIfNeeded(SourceId sourceId, long minimumRevision) {
|
||||
SourceSlot slot = slots.get(sourceId);
|
||||
if (slot == null || slot.desired.revision() < minimumRevision) {
|
||||
stateProvider.find(sourceId).ifPresent(this::applySharedState);
|
||||
slot = slots.get(sourceId);
|
||||
}
|
||||
if (slot == null) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.SOURCE_NOT_FOUND,
|
||||
"source is not defined: " + sourceId
|
||||
);
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
|
||||
private void ensureReadyLocked(SourceSlot slot, SourceId sourceId, long minimumRevision) {
|
||||
if (slot.desired instanceof SourceTombstone) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.SOURCE_REMOVED,
|
||||
"source has been removed: " + sourceId
|
||||
);
|
||||
}
|
||||
ActiveSourceState active = (ActiveSourceState) slot.desired;
|
||||
if (active.revision() < minimumRevision) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.SOURCE_REVISION_NOT_READY,
|
||||
"source revision " + active.revision() + " is lower than required " + minimumRevision
|
||||
);
|
||||
}
|
||||
if (slot.current != null && slot.current.definition().revision() >= minimumRevision
|
||||
&& slot.current.definition().revision() == active.revision()) {
|
||||
return;
|
||||
}
|
||||
|
||||
SourceRuntime candidate;
|
||||
try {
|
||||
FederationDataSourceHandle handle = resolver.resolve(active.definition());
|
||||
candidate = buildRuntime(active.definition(), handle);
|
||||
} catch (FederationSqlException exception) {
|
||||
slot.lastFailure = exception.getMessage();
|
||||
if (slot.current != null && slot.current.definition().revision() >= minimumRevision) {
|
||||
return;
|
||||
}
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
slot.lastFailure = exception.getMessage();
|
||||
if (slot.current != null && slot.current.definition().revision() >= minimumRevision) {
|
||||
return;
|
||||
}
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.SOURCE_INITIALIZATION_FAILED,
|
||||
"failed to initialize source " + sourceId,
|
||||
exception
|
||||
);
|
||||
}
|
||||
|
||||
SourceRuntime previous = slot.current;
|
||||
slot.current = candidate;
|
||||
slot.lastFailure = null;
|
||||
if (previous != null) {
|
||||
retireRuntime(previous);
|
||||
}
|
||||
}
|
||||
|
||||
private SourceRuntime buildRuntime(
|
||||
FederationSourceDefinition definition,
|
||||
FederationDataSourceHandle handle
|
||||
) {
|
||||
try {
|
||||
FederationSqlAdapterProvider adapter = adapters.require(definition.adapterId());
|
||||
try (Connection connection = handle.dataSource().getConnection()) {
|
||||
DatabaseMetaData metadata = connection.getMetaData();
|
||||
AdapterHints hints = new AdapterHints(definition.adapterOptions());
|
||||
if (!adapter.supports(metadata, hints)) {
|
||||
throw new FederationSqlException(
|
||||
FederationSqlErrorCode.ADAPTER_UNSUPPORTED,
|
||||
"adapter " + adapter.adapterId() + " does not support database "
|
||||
+ metadata.getDatabaseProductName()
|
||||
);
|
||||
}
|
||||
SqlDialect dialect = adapter.createDialect(new AdapterDialectContext(metadata, definition));
|
||||
// 动态 JDBC 元数据必须在冷编译时可见;有界计划缓存负责热查询性能。
|
||||
SchemaPlus root = CalciteSchema.createRootSchema(true, false).plus();
|
||||
SchemaPlus sourceSchema = root.add(definition.sourceId().value(), new AbstractSchema());
|
||||
SchemaPlus onlySchema = null;
|
||||
for (FederationSchemaDefinition schemaDefinition : definition.schemas()) {
|
||||
Schema schema = adapter.createSchema(new AdapterSchemaContext(
|
||||
sourceSchema,
|
||||
definition,
|
||||
schemaDefinition,
|
||||
handle,
|
||||
dialect
|
||||
));
|
||||
SchemaPlus added = sourceSchema.add(schemaDefinition.logicalName(), schema);
|
||||
onlySchema = definition.schemas().size() == 1 ? added : null;
|
||||
}
|
||||
SchemaPlus defaultSchema = onlySchema == null ? sourceSchema : onlySchema;
|
||||
return new SourceRuntime(
|
||||
definition,
|
||||
handle,
|
||||
adapter,
|
||||
dialect,
|
||||
root,
|
||||
defaultSchema,
|
||||
adapter.compatibility(metadata, hints),
|
||||
this::runtimeClosed
|
||||
);
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
FederationSqlException failure = new FederationSqlException(
|
||||
FederationSqlErrorCode.SOURCE_INITIALIZATION_FAILED,
|
||||
"failed to inspect source " + definition.sourceId(),
|
||||
exception
|
||||
);
|
||||
closeHandleAndSuppress(handle, failure);
|
||||
throw failure;
|
||||
} catch (RuntimeException exception) {
|
||||
closeHandleAndSuppress(handle, exception);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private void closeHandleAndSuppress(
|
||||
FederationDataSourceHandle handle,
|
||||
RuntimeException original
|
||||
) {
|
||||
try {
|
||||
handle.close();
|
||||
} catch (RuntimeException closeException) {
|
||||
pendingHandleClosures.add(handle);
|
||||
original.addSuppressed(closeException);
|
||||
}
|
||||
}
|
||||
|
||||
private void applySharedState(FederationSourceState state) {
|
||||
operationLock.lock();
|
||||
try {
|
||||
if (!closed.get()) {
|
||||
applyState(state);
|
||||
}
|
||||
} finally {
|
||||
operationLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private SourceApplyResult applyState(FederationSourceState state) {
|
||||
synchronized (sourceCatalogLock) {
|
||||
SourceSlot newSlot = new SourceSlot(state);
|
||||
SourceSlot existing = slots.putIfAbsent(state.sourceId(), newSlot);
|
||||
if (existing == null) {
|
||||
if (state instanceof ActiveSourceState) {
|
||||
publishSourceCatalogSnapshot();
|
||||
}
|
||||
return result(state, state.revision(), SourceApplyStatus.APPLIED);
|
||||
}
|
||||
SourceSlot slot = existing;
|
||||
synchronized (slot) {
|
||||
FederationSourceState current = slot.desired;
|
||||
if (state.revision() < current.revision()) {
|
||||
return result(state, current.revision(), SourceApplyStatus.IGNORED_STALE);
|
||||
}
|
||||
if (state.revision() == current.revision()) {
|
||||
SourceApplyStatus status = state.checksum().equals(current.checksum())
|
||||
? SourceApplyStatus.IDEMPOTENT
|
||||
: SourceApplyStatus.CONFLICT;
|
||||
return result(state, current.revision(), status);
|
||||
}
|
||||
slot.desired = state;
|
||||
if ((current instanceof ActiveSourceState) != (state instanceof ActiveSourceState)) {
|
||||
publishSourceCatalogSnapshot();
|
||||
}
|
||||
if (state instanceof SourceTombstone && slot.current != null) {
|
||||
retireRuntime(slot.current);
|
||||
slot.current = null;
|
||||
}
|
||||
return result(state, state.revision(), SourceApplyStatus.APPLIED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PreparedCommitOutcome commitPreparedRuntime(SourceRuntime candidate) {
|
||||
ActiveSourceState state = ActiveSourceState.of(candidate.definition());
|
||||
synchronized (sourceCatalogLock) {
|
||||
SourceSlot newSlot = new SourceSlot(state);
|
||||
newSlot.current = candidate;
|
||||
SourceSlot existing = slots.putIfAbsent(state.sourceId(), newSlot);
|
||||
if (existing == null) {
|
||||
publishSourceCatalogSnapshot();
|
||||
return new PreparedCommitOutcome(
|
||||
result(state, state.revision(), SourceApplyStatus.APPLIED), true
|
||||
);
|
||||
}
|
||||
SourceSlot slot = existing;
|
||||
synchronized (slot) {
|
||||
FederationSourceState current = slot.desired;
|
||||
if (state.revision() < current.revision()) {
|
||||
return new PreparedCommitOutcome(
|
||||
result(state, current.revision(), SourceApplyStatus.IGNORED_STALE), false
|
||||
);
|
||||
}
|
||||
SourceApplyStatus status;
|
||||
if (state.revision() == current.revision()) {
|
||||
if (!state.checksum().equals(current.checksum())) {
|
||||
return new PreparedCommitOutcome(
|
||||
result(state, current.revision(), SourceApplyStatus.CONFLICT), false
|
||||
);
|
||||
}
|
||||
status = SourceApplyStatus.IDEMPOTENT;
|
||||
} else {
|
||||
status = SourceApplyStatus.APPLIED;
|
||||
slot.desired = state;
|
||||
if (current instanceof SourceTombstone) {
|
||||
publishSourceCatalogSnapshot();
|
||||
}
|
||||
}
|
||||
if (slot.current != null
|
||||
&& slot.current.definition().revision() == state.revision()
|
||||
&& slot.current.sourceChecksum().equals(state.checksum())) {
|
||||
return new PreparedCommitOutcome(
|
||||
result(state, state.revision(), status), false
|
||||
);
|
||||
}
|
||||
SourceRuntime previous = slot.current;
|
||||
slot.current = candidate;
|
||||
slot.lastFailure = null;
|
||||
if (previous != null) {
|
||||
try {
|
||||
retireRuntime(previous);
|
||||
} catch (RuntimeException exception) {
|
||||
// 新 Runtime 已原子接管;记录旧 Handle 关闭错误且不回滚可用的新版本。
|
||||
slot.lastFailure = "previous runtime retirement failed: " + exception.getMessage();
|
||||
}
|
||||
}
|
||||
return new PreparedCommitOutcome(
|
||||
result(state, state.revision(), status), true
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Set<SourceId> activeSourceIds() {
|
||||
Set<SourceId> sourceIds = new HashSet<>();
|
||||
slots.forEach((sourceId, slot) -> {
|
||||
if (slot.desired instanceof ActiveSourceState) {
|
||||
sourceIds.add(sourceId);
|
||||
}
|
||||
});
|
||||
return Set.copyOf(sourceIds);
|
||||
}
|
||||
|
||||
private void publishSourceCatalogSnapshot() {
|
||||
sourceCatalogSnapshot = new SourceCatalogSnapshot(
|
||||
sourceCatalogSnapshot.generation() + 1,
|
||||
activeSourceIds()
|
||||
);
|
||||
}
|
||||
|
||||
private static SourceApplyResult result(
|
||||
FederationSourceState requested,
|
||||
long effectiveRevision,
|
||||
SourceApplyStatus status
|
||||
) {
|
||||
return new SourceApplyResult(
|
||||
requested.sourceId(),
|
||||
requested.revision(),
|
||||
effectiveRevision,
|
||||
status
|
||||
);
|
||||
}
|
||||
|
||||
private static FederationSourceView toView(SourceSlot slot) {
|
||||
SourceRuntimeStatus status;
|
||||
if (slot.desired instanceof SourceTombstone) {
|
||||
status = SourceRuntimeStatus.REMOVED;
|
||||
} else if (slot.current != null) {
|
||||
status = SourceRuntimeStatus.READY;
|
||||
} else if (slot.lastFailure != null) {
|
||||
status = SourceRuntimeStatus.FAILED;
|
||||
} else {
|
||||
status = SourceRuntimeStatus.DEFINED;
|
||||
}
|
||||
return new FederationSourceView(
|
||||
slot.desired.sourceId(),
|
||||
slot.desired.revision(),
|
||||
slot.current == null ? -1 : slot.current.definition().revision(),
|
||||
status,
|
||||
slot.desired.checksum(),
|
||||
slot.lastFailure
|
||||
);
|
||||
}
|
||||
|
||||
private void ensureOpen() {
|
||||
if (closed.get()) {
|
||||
throw new FederationSqlException(FederationSqlErrorCode.ENGINE_CLOSED, "source manager is closed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记旧 Runtime 并按租约排空语义关闭。
|
||||
*
|
||||
* @param runtime 待退役 Runtime
|
||||
*/
|
||||
private void retireRuntime(SourceRuntime runtime) {
|
||||
retiringRuntimes.add(runtime);
|
||||
runtime.retire();
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加节点本地 Runtime 成功关闭后的监听器。
|
||||
*
|
||||
* @param listener 关闭监听器
|
||||
*/
|
||||
void addRuntimeClosedListener(Consumer<SourceRuntime> listener) {
|
||||
runtimeClosedListeners.add(java.util.Objects.requireNonNull(listener, "listener"));
|
||||
}
|
||||
|
||||
private void runtimeClosed(SourceRuntime runtime) {
|
||||
retiringRuntimes.remove(runtime);
|
||||
RuntimeException failure = null;
|
||||
for (Consumer<SourceRuntime> listener : runtimeClosedListeners) {
|
||||
try {
|
||||
listener.accept(runtime);
|
||||
} catch (RuntimeException exception) {
|
||||
if (failure == null) {
|
||||
failure = exception;
|
||||
} else {
|
||||
failure.addSuppressed(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记候选 Runtime 并立即关闭其 Handle。
|
||||
*
|
||||
* @param runtime 待关闭 Runtime
|
||||
*/
|
||||
private void forceCloseRuntime(SourceRuntime runtime) {
|
||||
retiringRuntimes.add(runtime);
|
||||
runtime.forceClose();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭订阅并让所有 Runtime 进入排空;无活跃 lease 的 Handle 会立即关闭。
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
RuntimeException failure = null;
|
||||
closeLock.lock();
|
||||
try {
|
||||
// write lock 排除了并发 build/commit,快照包含进入关闭前的全部失败资源。
|
||||
Set<SourceRuntime> retryRuntimes = Set.copyOf(retiringRuntimes);
|
||||
Set<FederationDataSourceHandle> retryHandles =
|
||||
Set.copyOf(pendingHandleClosures);
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
for (SourceSlot slot : slots.values()) {
|
||||
synchronized (slot) {
|
||||
if (slot.current != null) {
|
||||
try {
|
||||
retireRuntime(slot.current);
|
||||
} catch (RuntimeException exception) {
|
||||
failure = append(failure, exception);
|
||||
}
|
||||
slot.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (PreparedRuntime prepared : Set.copyOf(preparedRuntimes)) {
|
||||
try {
|
||||
prepared.close();
|
||||
} catch (RuntimeException exception) {
|
||||
failure = append(failure, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 上一次释放失败的资源在后续 close 调用中重试,成功后由回调移出集合。
|
||||
for (SourceRuntime runtime : retryRuntimes) {
|
||||
try {
|
||||
runtime.retire();
|
||||
} catch (RuntimeException exception) {
|
||||
failure = append(failure, exception);
|
||||
}
|
||||
}
|
||||
for (FederationDataSourceHandle handle : retryHandles) {
|
||||
try {
|
||||
handle.close();
|
||||
pendingHandleClosures.remove(handle);
|
||||
} catch (RuntimeException exception) {
|
||||
failure = append(failure, exception);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
closeLock.unlock();
|
||||
}
|
||||
synchronized (subscriptionCloseLock) {
|
||||
try {
|
||||
if (!subscriptionClosed.get()) {
|
||||
subscription.close();
|
||||
subscriptionClosed.set(true);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
failure = append(failure, exception);
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static RuntimeException append(RuntimeException failure, RuntimeException next) {
|
||||
if (failure == null) {
|
||||
return next;
|
||||
}
|
||||
failure.addSuppressed(next);
|
||||
return failure;
|
||||
}
|
||||
|
||||
private static final class SourceSlot {
|
||||
|
||||
private FederationSourceState desired;
|
||||
private SourceRuntime current;
|
||||
private String lastFailure;
|
||||
|
||||
private SourceSlot(FederationSourceState desired) {
|
||||
this.desired = desired;
|
||||
}
|
||||
}
|
||||
|
||||
private record PreparedCommitOutcome(
|
||||
SourceApplyResult result,
|
||||
boolean installed
|
||||
) {
|
||||
}
|
||||
|
||||
private final class PreparedRuntime implements PreparedSourceRuntime {
|
||||
|
||||
private SourceRuntime runtime;
|
||||
|
||||
private PreparedRuntime(SourceRuntime runtime) {
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized FederationSourceDefinition definition() {
|
||||
if (runtime == null) {
|
||||
throw new IllegalStateException("prepared runtime is no longer open");
|
||||
}
|
||||
return runtime.definition();
|
||||
}
|
||||
|
||||
private synchronized SourceRuntime take() {
|
||||
if (runtime == null) {
|
||||
throw new IllegalStateException("prepared runtime is no longer open");
|
||||
}
|
||||
SourceRuntime claimed = runtime;
|
||||
runtime = null;
|
||||
return claimed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
SourceRuntime discarded;
|
||||
synchronized (this) {
|
||||
discarded = runtime;
|
||||
runtime = null;
|
||||
}
|
||||
preparedRuntimes.remove(this);
|
||||
if (discarded != null) {
|
||||
forceCloseRuntime(discarded);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user