Compare commits

7 Commits

Author SHA1 Message Date
93296eb810 feat: 增强 Agentic RAG 主动检索引导
- 统一组合用户、知识库与异步工具系统提示词

- 补充知识库调用策略与运行时回归测试
2026-08-29 17:01:35 +08:00
47a2706f11 feat: 支持一库一工具 Agentic RAG
- 将知识库声明与 Retriever 绑定为独立 Registration 并注册到 AgentScope Toolkit

- 统一检索分数、最终文档事件与长答案引用语义

- 补齐历史会话、工具类加载器和知识库调用状态回归测试
2026-08-29 15:41:52 +08:00
e146653de7 fix: 保留模型异常后的会话上下文
- 保存模型失败与无正文取消路径中的 AgentScope 会话

- 补充失败和推理中断场景的上下文恢复测试
2026-08-26 22:55:20 +08:00
880f810ba2 chore: 清理过期示例与测试资源
- 移除未接入聚合构建的旧 Hello World 示例

- 删除历史文档与图片测试资源
2026-08-26 18:15:24 +08:00
b4bdc392ee feat: 新增嵌入式分布式调度底座
- 提供通用调度 API、Quartz JDBC Provider 与独立 Starter

- 补充 MySQL、PostgreSQL、H2 建表脚本与接入校验

- 同步完善 Federation 与 Scheduler 模块说明
2026-08-26 18:15:13 +08:00
02b8fdd3ae feat: 新增 SQL 联邦查询与数据库适配底座
- 提供统一编译、逻辑表映射与单源/联邦自动路由

- 增加有界执行、查询生命周期、统计成本优化与执行分析

- 内置 MySQL 与 PostgreSQL JDBC 适配和统计采集
2026-08-25 01:00:49 +08:00
2f67d90144 chore: 进入 v1.2.0 版本开发 2026-08-20 11:41:18 +08:00
227 changed files with 34605 additions and 556 deletions

View File

@@ -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`

View File

@@ -1,14 +1,17 @@
package com.easyagents.agent.runtime;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import com.easyagents.agent.runtime.media.AgentMediaResolver;
import com.easyagents.agent.runtime.memory.AgentMemorySnapshot;
import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder;
import com.easyagents.agent.runtime.persistence.conversation.noop.NoopAgentConversationRecorder;
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore;
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@@ -44,7 +47,12 @@ public class AgentInitRequest {
/**
* 知识库集合实现AgentKnowledgeRetriever接口以进行知识检索动作。
*/
private Map<String, AgentKnowledgeRetriever> knowledgeRetrievers = new LinkedHashMap<>();
private List<AgentKnowledgeRegistration> knowledgeRegistrations = new ArrayList<>();
/**
* 首次构建 Agent 时装载的对话历史快照。
*/
private AgentMemorySnapshot memorySnapshot = new AgentMemorySnapshot();
/**
* 对话事件记录器,用于记录运行时事件流。
@@ -156,17 +164,37 @@ public class AgentInitRequest {
*
* @return 知识库检索器
*/
public Map<String, AgentKnowledgeRetriever> getKnowledgeRetrievers() {
return knowledgeRetrievers;
public List<AgentKnowledgeRegistration> getKnowledgeRegistrations() {
return knowledgeRegistrations;
}
/**
* 设置知识库检索器。
*
* @param knowledgeRetrievers 知识库检索器
* @param knowledgeRegistrations 知识库运行时绑定
*/
public void setKnowledgeRetrievers(Map<String, AgentKnowledgeRetriever> knowledgeRetrievers) {
this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers;
public void setKnowledgeRegistrations(List<AgentKnowledgeRegistration> knowledgeRegistrations) {
this.knowledgeRegistrations = knowledgeRegistrations == null
? new ArrayList<>()
: new ArrayList<>(knowledgeRegistrations);
}
/**
* 获取首次构建 Agent 时的对话历史快照。
*
* @return 对话历史快照
*/
public AgentMemorySnapshot getMemorySnapshot() {
return memorySnapshot;
}
/**
* 设置首次构建 Agent 时的对话历史快照。
*
* @param memorySnapshot 对话历史快照
*/
public void setMemorySnapshot(AgentMemorySnapshot memorySnapshot) {
this.memorySnapshot = memorySnapshot == null ? new AgentMemorySnapshot() : memorySnapshot;
}
/**

View File

@@ -1,6 +1,6 @@
package com.easyagents.agent.runtime;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import com.easyagents.agent.runtime.memory.AgentMemorySnapshot;
import com.easyagents.agent.runtime.message.AgentMessage;
import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder;
@@ -9,7 +9,9 @@ import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore;
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@@ -60,7 +62,7 @@ public class AgentRuntimeExecutionContext {
/**
* 按知识库ID索引的检索器。
*/
private Map<String, AgentKnowledgeRetriever> knowledgeRetrievers = new LinkedHashMap<>();
private List<AgentKnowledgeRegistration> knowledgeRegistrations = new ArrayList<>();
/**
* 会话状态存储。
@@ -231,17 +233,19 @@ public class AgentRuntimeExecutionContext {
*
* @return 知识库检索器
*/
public Map<String, AgentKnowledgeRetriever> getKnowledgeRetrievers() {
return knowledgeRetrievers;
public List<AgentKnowledgeRegistration> getKnowledgeRegistrations() {
return knowledgeRegistrations;
}
/**
* 设置知识库检索器。
*
* @param knowledgeRetrievers 知识库检索器
* @param knowledgeRegistrations 知识库运行时绑定
*/
public void setKnowledgeRetrievers(Map<String, AgentKnowledgeRetriever> knowledgeRetrievers) {
this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers;
public void setKnowledgeRegistrations(List<AgentKnowledgeRegistration> knowledgeRegistrations) {
this.knowledgeRegistrations = knowledgeRegistrations == null
? new ArrayList<>()
: new ArrayList<>(knowledgeRegistrations);
}
/**

View File

@@ -2,309 +2,452 @@ package com.easyagents.agent.runtime.agentscope;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
import com.easyagents.agent.runtime.event.*;
import com.easyagents.agent.runtime.knowledge.*;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.rag.Knowledge;
import io.agentscope.core.rag.model.Document;
import io.agentscope.core.rag.model.DocumentMetadata;
import io.agentscope.core.rag.model.RetrieveConfig;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.event.AgentRuntimeTurnContextHolder;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalRequest;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeToolNames;
import com.easyagents.agent.runtime.tool.AgentToolCategory;
import com.easyagents.agent.runtime.tool.AgentToolContext;
import com.easyagents.agent.runtime.tool.AgentToolResult;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.tool.Toolkit;
import java.util.*;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 将运行时知识库检索器适配为一个聚合 AgentScope Knowledge
* 将中立知识库绑定适配为 AgentScope 一库一工具
*/
public class AgentScopeKnowledgeAdapter {
/**
* 创建聚合 Knowledge
* 根据 Agent 知识库声明创建模型可见工具定义
*
* @param request 运行请求
* @return 聚合 Knowledge未配置知识库时返回 null
* @param context 运行时上下文
* @return 知识库工具定义
* @throws AgentRuntimeException 声明、运行名或 Retriever 绑定不合法时抛出
*/
public Knowledge createAggregateKnowledge(AgentRuntimeExecutionContext request) {
return createAggregateKnowledge(request, (Sinks.Many<AgentRuntimeEvent>) null);
}
/**
* 创建带事件 sink 的聚合 Knowledge。
*
* @param request 运行请求
* @param eventSink 事件 sink
* @return 聚合 Knowledge未配置知识库时返回 null
*/
public Knowledge createAggregateKnowledge(AgentRuntimeExecutionContext request, Sinks.Many<AgentRuntimeEvent> eventSink) {
return createAggregateKnowledge(request, fixedHolder(request, eventSink));
}
/**
* 创建可读取当前运行轮次事件出口的聚合 Knowledge。
*
* @param request 运行时级上下文
* @param turnContextHolder 当前运行轮次上下文持有器
* @return 聚合 Knowledge未配置知识库时返回 null
*/
public Knowledge createAggregateKnowledge(AgentRuntimeExecutionContext request,
AgentRuntimeTurnContextHolder turnContextHolder) {
if (request.getAgentDefinition().getKnowledgeSpecs().isEmpty()) {
return null;
public List<AgentToolSpec> createToolSpecs(AgentRuntimeExecutionContext context) {
if (context == null || context.getAgentDefinition() == null) {
throw new AgentRuntimeException("Agent runtime context and definition are required for knowledge tools.");
}
return new AggregateKnowledge(request, turnContextHolder);
}
private AgentRuntimeTurnContextHolder fixedHolder(AgentRuntimeExecutionContext request,
Sinks.Many<AgentRuntimeEvent> eventSink) {
AgentRuntimeTurnContextHolder holder = new AgentRuntimeTurnContextHolder();
AgentRuntimeEventBridge bridge = new AgentRuntimeEventBridge(request, holder);
holder.set(new AgentRuntimeTurnContext(null, eventSink, bridge));
return holder;
List<AgentKnowledgeSpec> knowledgeSpecs = context.getAgentDefinition().getKnowledgeSpecs();
List<AgentKnowledgeRegistration> registrations = context.getKnowledgeRegistrations();
if (knowledgeSpecs == null || knowledgeSpecs.isEmpty()) {
if (registrations != null && !registrations.isEmpty()) {
throw new AgentRuntimeException("Knowledge registrations require matching knowledge specs.");
}
return List.of();
}
Map<String, AgentKnowledgeRegistration> registrationIndex = registrationIndex(registrations);
if (registrationIndex.size() != knowledgeSpecs.size()) {
throw new AgentRuntimeException("Knowledge specs and registrations must match one-to-one.");
}
List<AgentToolSpec> toolSpecs = new ArrayList<>(knowledgeSpecs.size());
Set<String> toolNames = new LinkedHashSet<>();
for (AgentKnowledgeSpec knowledgeSpec : knowledgeSpecs) {
validateKnowledgeSpec(knowledgeSpec);
AgentKnowledgeRegistration registration = registrationIndex.get(knowledgeSpec.getKnowledgeId());
if (registration == null) {
throw new AgentRuntimeException(
"Knowledge retriever is required: " + knowledgeSpec.getKnowledgeId());
}
String toolName = AgentKnowledgeToolNames.build(knowledgeSpec.getRuntimeName());
if (!toolNames.add(toolName)) {
throw new AgentRuntimeException("Duplicate knowledge tool name: " + toolName);
}
toolSpecs.add(toolSpec(knowledgeSpec, toolName));
}
return toolSpecs;
}
/**
* 将运行时文档转换为 AgentScope 文档
* 将知识库工具注册到现有 AgentScope Toolkit
*
* @param documents 运行时文
* @return AgentScope 文档
* @param context 运行时上下
* @param toolSpecs 知识库工具定义
* @param toolkit AgentScope Toolkit
* @param toolAdapter 中立工具适配器
* @param approvalCoordinator 工具审批协调器
* @param turnContextHolder 当前运行轮次上下文持有器
*/
public List<Document> toDocuments(List<AgentKnowledgeDocument> documents) {
List<Document> converted = new ArrayList<>();
public void registerTools(AgentRuntimeExecutionContext context,
List<AgentToolSpec> toolSpecs,
Toolkit toolkit,
AgentScopeToolAdapter toolAdapter,
AgentToolApprovalCoordinator approvalCoordinator,
AgentRuntimeTurnContextHolder turnContextHolder) {
Objects.requireNonNull(toolkit, "toolkit");
Objects.requireNonNull(toolAdapter, "toolAdapter");
if (toolSpecs == null || toolSpecs.isEmpty()) {
return;
}
Map<String, AgentKnowledgeRegistration> registrationIndex =
registrationIndex(context.getKnowledgeRegistrations());
for (AgentToolSpec toolSpec : toolSpecs) {
String knowledgeId = stringValue(toolSpec.getMetadata().get("knowledgeId"));
AgentKnowledgeRegistration registration = registrationIndex.get(knowledgeId);
if (registration == null) {
throw new AgentRuntimeException("Knowledge registration is required: " + knowledgeId);
}
toolkit.registerAgentTool(toolAdapter.adapt(
toolSpec,
(input, toolContext) -> retrieve(registration, input, toolContext),
context,
approvalCoordinator,
turnContextHolder,
null,
null,
false,
false,
false));
}
}
/**
* 按知识库 ID 建立运行时绑定索引并拒绝重复绑定。
*
* @param registrations 知识库运行时绑定
* @return 以知识库 ID 为键的绑定索引
*/
private Map<String, AgentKnowledgeRegistration> registrationIndex(
List<AgentKnowledgeRegistration> registrations) {
Map<String, AgentKnowledgeRegistration> index = new LinkedHashMap<>();
if (registrations == null) {
return index;
}
for (AgentKnowledgeRegistration registration : registrations) {
if (registration == null || registration.getKnowledgeSpec() == null) {
throw new AgentRuntimeException("Knowledge registration and spec are required.");
}
String knowledgeId = registration.getKnowledgeSpec().getKnowledgeId();
if (knowledgeId == null || knowledgeId.isBlank()) {
throw new AgentRuntimeException("Knowledge id is required.");
}
if (index.putIfAbsent(knowledgeId, registration) != null) {
throw new AgentRuntimeException("Duplicate knowledge registration: " + knowledgeId);
}
}
return index;
}
/**
* 校验知识库声明的标识和英文运行名。
*
* @param knowledgeSpec 知识库声明
* @throws AgentRuntimeException 声明不合法时抛出
*/
private void validateKnowledgeSpec(AgentKnowledgeSpec knowledgeSpec) {
if (knowledgeSpec == null) {
throw new AgentRuntimeException("Knowledge spec is required.");
}
if (knowledgeSpec.getKnowledgeId() == null || knowledgeSpec.getKnowledgeId().isBlank()) {
throw new AgentRuntimeException("Knowledge id is required.");
}
AgentKnowledgeToolNames.build(knowledgeSpec.getRuntimeName());
}
/**
* 创建单个知识库对应的模型工具声明。
*
* @param knowledgeSpec 知识库声明
* @param toolName 已规范化的工具名
* @return 模型可见工具声明
*/
private AgentToolSpec toolSpec(AgentKnowledgeSpec knowledgeSpec, String toolName) {
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName(toolName);
toolSpec.setDescription(toolDescription(knowledgeSpec));
toolSpec.setCategory(AgentToolCategory.KNOWLEDGE);
toolSpec.setParametersSchema(Map.of(
"type", "object",
"properties", Map.of(
"query", Map.of(
"type", "string",
"description", "A standalone search query containing all context needed to retrieve relevant knowledge."
)
),
"required", List.of("query"),
"additionalProperties", false
));
toolSpec.getMetadata().putAll(knowledgeSpec.getMetadata());
toolSpec.getMetadata().put("knowledgeId", knowledgeSpec.getKnowledgeId());
toolSpec.getMetadata().put("knowledgeName", knowledgeSpec.getName());
toolSpec.getMetadata().put("knowledgeRuntimeName", knowledgeSpec.getRuntimeName());
toolSpec.getMetadata().put("toolDisplayName", knowledgeSpec.getName());
return toolSpec;
}
/**
* 生成包含知识库名称、范围和查询要求的工具描述。
*
* @param knowledgeSpec 知识库声明
* @return 模型可见工具描述
*/
private String toolDescription(AgentKnowledgeSpec knowledgeSpec) {
String knowledgeName = hasText(knowledgeSpec.getName())
? knowledgeSpec.getName().trim()
: knowledgeSpec.getRuntimeName().trim();
StringBuilder description = new StringBuilder()
.append("Search the knowledge base \"")
.append(knowledgeName)
.append("\" for relevant and grounded information.");
if (hasText(knowledgeSpec.getDescription())) {
description.append(" Knowledge scope: ")
.append(knowledgeSpec.getDescription().trim())
.append('.');
}
description.append(" Build a standalone query from the current question and necessary conversation context.");
return description.toString();
}
/**
* 执行单库检索并生成模型证据及旁路检索事件。
*
* @param registration 知识库运行时绑定
* @param input Function Call 输入
* @param toolContext 工具执行上下文
* @return 模型可见工具结果
*/
private AgentToolResult retrieve(AgentKnowledgeRegistration registration,
Map<String, Object> input,
AgentToolContext toolContext) {
AgentKnowledgeSpec knowledgeSpec = registration.getKnowledgeSpec();
String query = input == null ? null : stringValue(input.get("query"));
if (query == null || query.isBlank()) {
throw new AgentRuntimeException("Knowledge tool query is required: "
+ AgentKnowledgeToolNames.build(knowledgeSpec.getRuntimeName()));
}
String normalizedQuery = query.trim();
AgentKnowledgeRetrievalRequest retrievalRequest = new AgentKnowledgeRetrievalRequest();
retrievalRequest.setQuery(normalizedQuery);
retrievalRequest.setLimit(knowledgeSpec.getLimit());
retrievalRequest.setScoreThreshold(knowledgeSpec.getScoreThreshold());
retrievalRequest.setKnowledgeSpec(knowledgeSpec);
retrievalRequest.setRuntimeContext(toolContext.getRuntimeContext());
retrievalRequest.getMetadata().put("requestId", toolContext.getRequestId());
retrievalRequest.getMetadata().put("traceId", toolContext.getTraceId());
retrievalRequest.getMetadata().put("sessionId", toolContext.getSessionId());
retrievalRequest.getMetadata().put("toolCallId", toolContext.getToolCallId());
AgentKnowledgeRetrievalResult retrievalResult = registration.getRetriever().retrieve(retrievalRequest);
if (retrievalResult == null) {
throw new AgentRuntimeException("Knowledge retriever returned null result: "
+ knowledgeSpec.getKnowledgeId());
}
List<AgentKnowledgeDocument> documents = finalDocuments(knowledgeSpec, retrievalResult.getDocuments());
toolContext.emitEvent(retrievalEvent(toolContext, knowledgeSpec, retrievalRequest, documents));
List<Map<String, Object>> summaries = documentSummaries(documents);
AgentToolResult toolResult = AgentToolResult.success(modelContent(knowledgeSpec, normalizedQuery, documents));
toolResult.setDisplayContent(summaries);
toolResult.getMetadata().putAll(retrievalResult.getMetadata());
toolResult.getMetadata().put("knowledgeId", knowledgeSpec.getKnowledgeId());
toolResult.getMetadata().put("knowledgeName", knowledgeSpec.getName());
toolResult.getMetadata().put("query", normalizedQuery);
toolResult.getMetadata().put("documentCount", documents.size());
toolResult.getMetadata().put("documents", summaries);
return toolResult;
}
/**
* 对检索结果执行统一阈值过滤、降序排序和绑定数量截断。
*
* @param knowledgeSpec 知识库声明
* @param documents 原始检索文档
* @return 最终进入模型上下文的文档
*/
private List<AgentKnowledgeDocument> finalDocuments(AgentKnowledgeSpec knowledgeSpec,
List<AgentKnowledgeDocument> documents) {
List<AgentKnowledgeDocument> finalDocuments = new ArrayList<>();
if (documents == null) {
return converted;
return finalDocuments;
}
for (AgentKnowledgeDocument document : documents) {
converted.add(toDocument(document));
if (document == null || !passesThreshold(document, knowledgeSpec.getScoreThreshold())) {
continue;
}
preserveKnowledgeMetadata(knowledgeSpec, document);
finalDocuments.add(document);
}
return converted;
}
private Document toDocument(AgentKnowledgeDocument document) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("documentId", document.getDocumentId());
payload.put("documentName", document.getDocumentName());
payload.put("chunkId", document.getChunkId());
payload.put("sourceUri", document.getSourceUri());
payload.put("knowledgeMetadata", document.getKnowledgeMetadata());
payload.put("documentMetadata", document.getMetadata());
payload.putAll(document.getMetadata());
DocumentMetadata metadata = DocumentMetadata.builder()
.content(TextBlock.builder().text(safeContent(document)).build())
.docId(safeDocumentId(document))
.chunkId(safeChunkId(document))
.payload(payload)
.build();
Document converted = new Document(metadata);
converted.setScore(document.getScore());
return converted;
finalDocuments.sort(Comparator.comparing(
AgentKnowledgeDocument::getScore,
Comparator.nullsLast(Comparator.reverseOrder())));
int limit = Math.max(knowledgeSpec.getLimit(), 1);
if (finalDocuments.size() > limit) {
return new ArrayList<>(finalDocuments.subList(0, limit));
}
return finalDocuments;
}
/**
* 获取 AgentScope 要求的非空文档 ID
* 判断文档最终分数是否达到绑定阈值
*
* @param document 知识文档
* @return 非空文档 ID
* @param document 检索文档
* @param scoreThreshold 分数阈值
* @return 达到阈值时为 true
*/
private String safeDocumentId(AgentKnowledgeDocument document) {
if (document.getDocumentId() != null && !document.getDocumentId().isBlank()) {
return document.getDocumentId();
private boolean passesThreshold(AgentKnowledgeDocument document, double scoreThreshold) {
if (scoreThreshold <= 0D) {
return true;
}
if (document.getChunkId() != null && !document.getChunkId().isBlank()) {
return document.getChunkId();
}
return "knowledge-document";
return document.getScore() != null && document.getScore() >= scoreThreshold;
}
/**
* 获取 AgentScope 要求的非空分片 ID
* 将知识库归属信息合并到文档元数据中
*
* @param document 知识文档
* @return 非空分片 ID
* @param knowledgeSpec 知识库声明
* @param document 检索文档
*/
private String safeChunkId(AgentKnowledgeDocument document) {
if (document.getChunkId() != null && !document.getChunkId().isBlank()) {
return document.getChunkId();
}
if (document.getDocumentId() != null && !document.getDocumentId().isBlank()) {
return document.getDocumentId();
}
return "0";
private void preserveKnowledgeMetadata(AgentKnowledgeSpec knowledgeSpec,
AgentKnowledgeDocument document) {
Map<String, Object> knowledgeMetadata = new LinkedHashMap<>(knowledgeSpec.getMetadata());
knowledgeMetadata.put("knowledgeId", knowledgeSpec.getKnowledgeId());
knowledgeMetadata.put("knowledgeName", knowledgeSpec.getName());
knowledgeMetadata.put("knowledgeRuntimeName", knowledgeSpec.getRuntimeName());
knowledgeMetadata.putAll(document.getKnowledgeMetadata());
document.setKnowledgeMetadata(knowledgeMetadata);
document.getMetadata().putIfAbsent("knowledgeId", knowledgeSpec.getKnowledgeId());
document.getMetadata().putIfAbsent("knowledgeName", knowledgeSpec.getName());
}
/**
* 获取 AgentScope 要求的非空文档内容
* 创建与模型最终证据一致的知识库检索事件
*
* @param document 知识文档
* @return 文档内容
* @param toolContext 工具执行上下文
* @param knowledgeSpec 知识库声明
* @param retrievalRequest 检索请求
* @param documents 最终文档
* @return 检索旁路事件
*/
private String safeContent(AgentKnowledgeDocument document) {
return document.getContent() == null ? "" : document.getContent();
private AgentRuntimeEvent retrievalEvent(AgentToolContext toolContext,
AgentKnowledgeSpec knowledgeSpec,
AgentKnowledgeRetrievalRequest retrievalRequest,
List<AgentKnowledgeDocument> documents) {
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
event.setToolCallId(toolContext.getToolCallId());
event.getPayload().put("query", retrievalRequest.getQuery());
event.getPayload().put("knowledgeId", knowledgeSpec.getKnowledgeId());
event.getPayload().put("knowledgeName", knowledgeSpec.getName());
event.getPayload().put("knowledgeType", knowledgeSpec.getMetadata().get("knowledgeType"));
event.getPayload().put("faqCollection", knowledgeSpec.getMetadata().get("faqCollection"));
event.getPayload().put("limit", retrievalRequest.getLimit());
event.getPayload().put("scoreThreshold", retrievalRequest.getScoreThreshold());
event.getPayload().put("documentCount", documents.size());
event.getPayload().put("documents", documentSummaries(documents));
event.getMetadata().put("toolName", AgentKnowledgeToolNames.build(knowledgeSpec.getRuntimeName()));
event.getMetadata().put("knowledgeRuntimeName", knowledgeSpec.getRuntimeName());
return event;
}
/**
* 将检索调用分发到多个知识源的聚合 Knowledge 实现
* 将最终文档转换为 UI 和完成消息引用可消费的稳定摘要
*
* @param documents 最终文档
* @return 文档摘要列表
*/
private class AggregateKnowledge implements Knowledge {
private final AgentRuntimeExecutionContext request;
private final AgentRuntimeTurnContextHolder turnContextHolder;
private AggregateKnowledge(AgentRuntimeExecutionContext request, AgentRuntimeTurnContextHolder turnContextHolder) {
this.request = request;
this.turnContextHolder = turnContextHolder;
}
/**
* 忽略文档新增,因为知识库索引由 EasyFlow 负责。
*
* @param documents 文档列表
* @return 完成信号
*/
@Override
public Mono<Void> addDocuments(List<Document> documents) {
return Mono.error(new UnsupportedOperationException(
"Easy-Agents agent runtime knowledge does not support addDocuments. Use external knowledge service instead."));
}
/**
* 从已配置的知识源检索文档。
*
* @param query 查询
* @param config 检索配置
* @return 文档列表
*/
@Override
public Mono<List<Document>> retrieve(String query, RetrieveConfig config) {
return Mono.fromCallable(() -> retrieveAll(query, config));
}
/**
* 检索并合并所有已配置的知识源。
*
* @param query 查询
* @param config 检索配置
* @return 合并后的文档
*/
private List<Document> retrieveAll(String query, RetrieveConfig config) {
List<AgentKnowledgeDocument> allDocuments = new ArrayList<>();
int globalLimit = config == null || config.getLimit() <= 0 ? 5 : config.getLimit();
double globalThreshold = config == null ? 0D : config.getScoreThreshold();
for (AgentKnowledgeSpec spec : request.getAgentDefinition().getKnowledgeSpecs()) {
AgentKnowledgeRetriever retriever = request.getKnowledgeRetrievers().get(spec.getKnowledgeId());
if (retriever == null) {
throw new AgentRuntimeException("Knowledge retriever is required: " + spec.getKnowledgeId());
}
AgentKnowledgeRetrievalRequest retrievalRequest = new AgentKnowledgeRetrievalRequest();
retrievalRequest.setQuery(query);
retrievalRequest.setLimit(spec.getLimit());
retrievalRequest.setScoreThreshold(Math.max(spec.getScoreThreshold(), globalThreshold));
retrievalRequest.setKnowledgeSpec(spec);
AgentRuntimeExecutionContext currentRequest = currentRequest();
retrievalRequest.setRuntimeContext(currentRequest.getRuntimeContext());
retrievalRequest.getMetadata().put("traceId", currentRequest.getTraceId());
retrievalRequest.getMetadata().put("sessionId", currentRequest.getSessionId());
AgentKnowledgeRetrievalResult result = retriever.retrieve(retrievalRequest);
if (result == null || result.getDocuments() == null) {
emitKnowledgeRetrievalEvent(query, spec, retrievalRequest, new ArrayList<>());
continue;
}
emitKnowledgeRetrievalEvent(query, spec, retrievalRequest, result.getDocuments());
for (AgentKnowledgeDocument document : result.getDocuments()) {
preserveKnowledgeMetadata(spec, document);
allDocuments.add(document);
}
}
allDocuments.sort(Comparator.comparing(
AgentKnowledgeDocument::getScore,
Comparator.nullsLast(Comparator.reverseOrder())
));
if (allDocuments.size() > globalLimit) {
allDocuments = new ArrayList<>(allDocuments.subList(0, globalLimit));
}
return toDocuments(allDocuments);
}
/**
* 在单条文档上保留知识库级元数据。
*
* @param spec 知识库声明
* @param document 文档
*/
private void preserveKnowledgeMetadata(AgentKnowledgeSpec spec, AgentKnowledgeDocument document) {
Map<String, Object> knowledgeMetadata = new LinkedHashMap<>(spec.getMetadata());
knowledgeMetadata.put("knowledgeId", spec.getKnowledgeId());
knowledgeMetadata.put("knowledgeName", spec.getName());
knowledgeMetadata.put("retrievalMode", spec.getRetrievalMode().name());
knowledgeMetadata.putAll(document.getKnowledgeMetadata());
document.setKnowledgeMetadata(knowledgeMetadata);
document.getMetadata().putIfAbsent("knowledgeId", spec.getKnowledgeId());
document.getMetadata().putIfAbsent("knowledgeName", spec.getName());
}
/**
* 发射知识库检索旁路事件,供聊天界面展示检索过程。
*
* <p>知识库检索本身属于 AgentScope RAG 主线路,返回的 Document 会继续进入
* AgentScope 的上下文注入流程;这里发出的 {@code KNOWLEDGE_RETRIEVAL}
* 只是旁路告知调用方,不会回写 memory也不会参与模型消息序列。</p>
*
* @param query 查询
* @param spec 知识库声明
* @param retrievalRequest 检索请求
* @param documents 检索文档
*/
private void emitKnowledgeRetrievalEvent(String query,
AgentKnowledgeSpec spec,
AgentKnowledgeRetrievalRequest retrievalRequest,
List<AgentKnowledgeDocument> documents) {
AgentRuntimeEvent event = currentEventBridge().event(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
event.getPayload().put("query", query);
event.getPayload().put("knowledgeId", spec.getKnowledgeId());
event.getPayload().put("knowledgeName", spec.getName());
event.getPayload().put("knowledgeType", spec.getMetadata().get("knowledgeType"));
event.getPayload().put("faqCollection", spec.getMetadata().get("faqCollection"));
event.getPayload().put("limit", retrievalRequest.getLimit());
event.getPayload().put("scoreThreshold", retrievalRequest.getScoreThreshold());
event.getPayload().put("documentCount", documents == null ? 0 : documents.size());
event.getPayload().put("documents", documentSummaries(documents));
currentEventBridge().emit(event);
}
private AgentRuntimeExecutionContext currentRequest() {
return turnContextHolder == null ? request : turnContextHolder.executionContext(request);
}
private AgentRuntimeEventBridge currentEventBridge() {
if (turnContextHolder != null && turnContextHolder.eventBridge().isPresent()) {
return turnContextHolder.eventBridge().get();
}
return new AgentRuntimeEventBridge(request, turnContextHolder);
}
/**
* 构建用于事件展示的命中片段,保留前端引注需要的原始 chunk 内容。
*
* @param documents 检索文档
* @return 命中片段列表
*/
private List<Map<String, Object>> documentSummaries(List<AgentKnowledgeDocument> documents) {
List<Map<String, Object>> summaries = new ArrayList<>();
if (documents == null) {
return summaries;
}
for (AgentKnowledgeDocument document : documents) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("documentId", document.getDocumentId());
summary.put("documentName", document.getDocumentName());
summary.put("chunkId", document.getChunkId());
summary.put("chunkContent", document.getContent());
summary.put("score", document.getScore());
summary.put("sourceUri", document.getSourceUri());
summary.put("metadata", document.getMetadata());
summaries.add(summary);
}
private List<Map<String, Object>> documentSummaries(List<AgentKnowledgeDocument> documents) {
List<Map<String, Object>> summaries = new ArrayList<>();
if (documents == null) {
return summaries;
}
for (AgentKnowledgeDocument document : documents) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("documentId", document.getDocumentId());
summary.put("documentName", document.getDocumentName());
summary.put("chunkId", document.getChunkId());
summary.put("chunkContent", document.getContent());
summary.put("score", document.getScore());
summary.put("sourceUri", document.getSourceUri());
summary.put("metadata", document.getMetadata());
summaries.add(summary);
}
return summaries;
}
/**
* 格式化模型可见的结构化检索证据。
*
* @param knowledgeSpec 知识库声明
* @param query 实际检索词
* @param documents 最终文档
* @return 模型上下文文本
*/
private String modelContent(AgentKnowledgeSpec knowledgeSpec,
String query,
List<AgentKnowledgeDocument> documents) {
String knowledgeName = hasText(knowledgeSpec.getName())
? knowledgeSpec.getName().trim()
: knowledgeSpec.getRuntimeName().trim();
if (documents.isEmpty()) {
return "No relevant documents were found in knowledge base \""
+ knowledgeName + "\" for query: " + query;
}
StringBuilder content = new StringBuilder()
.append("Retrieved evidence from knowledge base \"")
.append(knowledgeName)
.append("\" for query: ")
.append(query)
.append("\n\n");
for (int index = 0; index < documents.size(); index++) {
AgentKnowledgeDocument document = documents.get(index);
content.append("[Evidence ").append(index + 1).append("]\n");
appendField(content, "Document", document.getDocumentName());
appendField(content, "Document ID", document.getDocumentId());
appendField(content, "Chunk ID", document.getChunkId());
appendField(content, "Source", document.getSourceUri());
if (document.getScore() != null) {
content.append("Score: ").append(document.getScore()).append('\n');
}
content.append("Content:\n")
.append(document.getContent() == null ? "" : document.getContent())
.append("\n\n");
}
return content.toString().stripTrailing();
}
/**
* 追加非空证据字段。
*
* @param content 输出缓冲区
* @param label 字段标签
* @param value 字段值
*/
private void appendField(StringBuilder content, String label, String value) {
if (hasText(value)) {
content.append(label).append(": ").append(value.trim()).append('\n');
}
}
/**
* 判断字符串是否包含非空白文本。
*
* @param value 待判断字符串
* @return 包含文本时为 true
*/
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
/**
* 将可空值转换为字符串。
*
* @param value 原始值
* @return 字符串;原始值为空时返回 null
*/
private String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
}

View File

@@ -13,7 +13,6 @@ import com.easyagents.agent.runtime.hitl.AgentPendingState;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalResolution;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRejectedException;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.knowledge.citation.AgentKnowledgeCitationMatcher;
import com.easyagents.agent.runtime.knowledge.citation.HeuristicKnowledgeCitationMatcher;
import com.easyagents.agent.runtime.message.*;
@@ -22,6 +21,7 @@ import com.easyagents.agent.runtime.mcp.McpSkillRegistration;
import com.easyagents.agent.runtime.mcp.McpSpecValidator;
import com.easyagents.agent.runtime.mcp.McpToolkitAdapter;
import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore;
import com.easyagents.agent.runtime.prompt.SystemPromptComposer;
import com.easyagents.agent.runtime.skill.AgentSkillBinding;
import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext;
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
@@ -35,9 +35,6 @@ import io.agentscope.core.memory.Memory;
import io.agentscope.core.memory.autocontext.AutoContextMemory;
import io.agentscope.core.message.*;
import io.agentscope.core.model.Model;
import io.agentscope.core.rag.Knowledge;
import io.agentscope.core.rag.RAGMode;
import io.agentscope.core.rag.model.RetrieveConfig;
import io.agentscope.core.session.Session;
import io.agentscope.core.skill.SkillBox;
import io.agentscope.core.state.SessionKey;
@@ -58,18 +55,6 @@ import java.util.function.Supplier;
*/
public class AgentScopeReActRuntime implements AgentRuntime {
private static final String ASYNC_TOOL_SYSTEM_PROMPT = """
Async tool protocol:
- Async tools may expose submit, observe, result, cancel, and list sub-tools. Treat these sub-tools as one user-facing tool.
- Do not ask the user to choose submit, observe, result, cancel, or list. These are internal execution phases.
- For a normal user request to use an async tool, call its submit sub-tool first with the user-provided arguments by default.
- After submit returns task_id, immediately call observe with that task_id to check progress.
- If the task is completed and result is available, use the returned result to answer the user.
- If the task is still running after observation, tell the user that the task is running and keep task_id/next_action for later tool calls.
- Use result, list, or cancel directly only when the user explicitly asks to get a known task result, list tasks, or cancel a task.
""";
private final AgentScopeModelFactory modelFactory;
private final AgentScopeToolAdapter toolAdapter;
private final AgentScopeKnowledgeAdapter knowledgeAdapter;
@@ -360,7 +345,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
context.setRuntimeContext(runtimeContext.getRuntimeContext());
context.setUserMessage(userMessage);
context.setToolInvokers(runtimeContext.getToolInvokers());
context.setKnowledgeRetrievers(runtimeContext.getKnowledgeRetrievers());
context.setKnowledgeRegistrations(runtimeContext.getKnowledgeRegistrations());
context.setSessionStore(runtimeContext.getSessionStore());
context.setConversationRecorder(runtimeContext.getConversationRecorder());
context.setMetadata(runtimeContext.getMetadata());
@@ -381,7 +366,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
context.setAgentDefinition(runtimeContext.getAgentDefinition());
context.setRuntimeContext(runtimeContext.getRuntimeContext());
context.setToolInvokers(runtimeContext.getToolInvokers());
context.setKnowledgeRetrievers(runtimeContext.getKnowledgeRetrievers());
context.setKnowledgeRegistrations(runtimeContext.getKnowledgeRegistrations());
context.setSessionStore(runtimeContext.getSessionStore());
context.setConversationRecorder(runtimeContext.getConversationRecorder());
Map<String, Object> metadata = new LinkedHashMap<>(runtimeContext.getMetadata());
@@ -689,6 +674,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
saveSession();
return Flux.just(cancelled(context));
}
saveSession();
return Flux.just(failed(context, error));
}
@@ -733,7 +719,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
* 将取消前已输出的助手内容补写入 AgentScope memory 并保存 session。
*
* <p>AgentScope 的正常完成路径会自行把最终助手消息写入 memory。取消订阅时不会触发
* 完成路径,因此这里仅在已有非空助手内容时补写一次,确保下一轮对话能拿到中断前上下文。</p>
* 完成路径,因此这里仅在已有非空助手内容时补写一次,并始终保存已经进入 memory 的
* 用户消息,确保下一轮对话能拿到中断前上下文。</p>
*
* @param finalText 当前已累计的助手文本
* @param finalMessage 当前已捕获的结构化助手消息
@@ -741,10 +728,9 @@ public class AgentScopeReActRuntime implements AgentRuntime {
private void persistPartialAssistantOnCancel(StringBuilder finalText,
AtomicReference<AgentMessage> finalMessage) {
AgentMessage partialMessage = partialAssistantMessage(finalText, finalMessage);
if (partialMessage == null) {
return;
if (partialMessage != null) {
agent.getMemory().addMessage(messageAdapter.toMsg(partialMessage));
}
agent.getMemory().addMessage(messageAdapter.toMsg(partialMessage));
saveSession();
}
@@ -1109,7 +1095,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
context.setAgentDefinition(request.getAgentDefinition());
context.setRuntimeContext(request.getRuntimeContext());
context.setToolInvokers(request.getToolInvokers());
context.setKnowledgeRetrievers(request.getKnowledgeRetrievers());
context.setKnowledgeRegistrations(request.getKnowledgeRegistrations());
context.setMemorySnapshot(request.getMemorySnapshot());
context.setSessionStore(request.getSessionStore());
context.setConversationRecorder(request.getConversationRecorder());
context.setMetadata(request.getMetadata());
@@ -1128,9 +1115,9 @@ public class AgentScopeReActRuntime implements AgentRuntime {
Toolkit toolkit = new Toolkit();
AgentScopeToolkitBuildResult toolkitBuildResult = buildToolkit(context, toolkit);
Map<String, List<AgentTool>> skillTools = toolkitBuildResult.skillTools();
AgentScopeMemoryBuildResult memoryResult = memoryAdapter.createMemoryResult(null, definition.getMemoryPolicy(), model);
AgentScopeMemoryBuildResult memoryResult = memoryAdapter.createMemoryResult(
context.getMemorySnapshot(), definition.getMemoryPolicy(), model);
Memory memory = memoryResult.getMemory();
Knowledge knowledge = knowledgeAdapter.createAggregateKnowledge(context, turnContextHolder);
SkillBox skillBox = skillAdapter.createSkillBox(definition.getSkillBoxSpec(), toolkit, skillTools,
toolkitBuildResult.skillMcpRegistrations());
// AutoContextInterceptor 是官方 AutoContextHook 的替代实现。这里仍只注册统一 runtime hook
@@ -1141,7 +1128,10 @@ public class AgentScopeReActRuntime implements AgentRuntime {
interceptors.add(new AutoContextInterceptor(eventBridge, memoryResult.getAutoContextConfig()));
}
interceptors.add(new MediaReferenceInterceptor(initRequest.getMediaResolver()));
List<AgentToolSpec> runtimeToolSpecs = mergeToolSpecs(definition.getToolSpecs(), toolkitBuildResult.mcpToolSpecs(),
List<AgentToolSpec> runtimeToolSpecs = mergeToolSpecs(
definition.getToolSpecs(),
toolkitBuildResult.knowledgeToolSpecs(),
toolkitBuildResult.mcpToolSpecs(),
toolkitBuildResult.operateToolSpecs());
interceptors.add(new ToolHitlInterceptor(eventBridge, approvalCoordinator,
runtimeToolSpecs));
@@ -1156,7 +1146,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
ReActAgent.Builder builder = ReActAgent.builder()
.name(definition.getAgentName())
.description(definition.getDescription())
.sysPrompt(systemPrompt(definition))
.sysPrompt(SystemPromptComposer.compose(definition))
.model(model)
.toolkit(toolkit)
.memory(memory)
@@ -1165,41 +1155,12 @@ public class AgentScopeReActRuntime implements AgentRuntime {
.hook(new AgentScopeRuntimeHook(observationManager))
.enablePendingToolRecovery(true)
.statePersistence(AgentScopeSessionAdapter.toStatePersistence(definition.getPersistencePolicy()));
if (knowledge != null) {
builder.knowledge(knowledge)
.ragMode(RAGMode.AGENTIC)
.retrieveConfig(defaultRetrieveConfig(definition));
}
if (skillBox != null) {
builder.skillBox(skillBox);
}
return builder.build();
}
private String systemPrompt(AgentDefinition definition) {
String prompt = definition.getSystemPrompt();
if (!hasAsyncTool(definition)) {
return prompt;
}
if (prompt == null || prompt.isBlank()) {
return ASYNC_TOOL_SYSTEM_PROMPT.strip();
}
return prompt.stripTrailing() + ASYNC_TOOL_SYSTEM_PROMPT;
}
private boolean hasAsyncTool(AgentDefinition definition) {
if (definition == null || definition.getToolSpecs() == null) {
return false;
}
for (AgentToolSpec toolSpec : definition.getToolSpecs()) {
// AsyncToolSpecExpander marks all generated sub-tools with this runtime metadata.
if (toolSpec != null && Boolean.TRUE.equals(toolSpec.getMetadata().get("asyncTool"))) {
return true;
}
}
return false;
}
/**
* 构建 AgentScope Toolkit并返回按 Skill ID 分组的工具。
*
@@ -1211,8 +1172,11 @@ public class AgentScopeReActRuntime implements AgentRuntime {
Toolkit toolkit) {
Map<String, List<AgentTool>> skillTools = new LinkedHashMap<>();
if (!context.getAgentDefinition().getExecutionOptions().isToolCallingEnabled()) {
return new AgentScopeToolkitBuildResult(skillTools, List.of(), List.of(), List.of());
return new AgentScopeToolkitBuildResult(skillTools, List.of(), List.of(), List.of(), List.of());
}
List<AgentToolSpec> knowledgeToolSpecs = knowledgeAdapter.createToolSpecs(context);
validateRuntimeToolConflicts(context.getAgentDefinition().getToolSpecs(), knowledgeToolSpecs,
List.of(), List.of());
for (AgentToolSpec toolSpec : context.getAgentDefinition().getToolSpecs()) {
AgentToolInvoker invoker = context.getToolInvokers().get(toolSpec.getName());
AgentSkillBinding skillBinding = skillContext.getToolBinding(toolSpec.getName());
@@ -1224,6 +1188,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
skillTools.computeIfAbsent(skillBinding.getSkillId(), key -> new ArrayList<>()).add(agentTool);
}
}
knowledgeAdapter.registerTools(context, knowledgeToolSpecs, toolkit, toolAdapter,
approvalCoordinator, turnContextHolder);
McpRegistration mcpRegistration = mcpToolkitAdapter.register(
context.getAgentDefinition().getMcpSpecs(), toolkit);
mcpClients.addAll(mcpRegistration.getClients());
@@ -1231,17 +1197,32 @@ public class AgentScopeReActRuntime implements AgentRuntime {
context.getAgentDefinition().getOperateToolSpecs(), toolkit);
McpSpecValidator.validateToolConflicts(context.getAgentDefinition().getToolSpecs(),
mcpRegistration.getToolSpecs(), context.getAgentDefinition().getOperateToolSpecs());
return new AgentScopeToolkitBuildResult(skillTools, mcpRegistration.getToolSpecs(), operateToolSpecs,
mcpRegistration.getSkillRegistrations());
validateRuntimeToolConflicts(context.getAgentDefinition().getToolSpecs(), knowledgeToolSpecs,
mcpRegistration.getToolSpecs(), operateToolSpecs);
return new AgentScopeToolkitBuildResult(skillTools, knowledgeToolSpecs,
mcpRegistration.getToolSpecs(), operateToolSpecs, mcpRegistration.getSkillRegistrations());
}
/**
* 合并所有运行时工具声明供统一治理与事件展示使用。
*
* @param toolSpecs 普通工具声明
* @param knowledgeToolSpecs 知识库工具声明
* @param mcpToolSpecs MCP 工具声明
* @param operateToolSpecs 操作工具声明
* @return 保持注册顺序的工具声明列表
*/
private List<AgentToolSpec> mergeToolSpecs(List<AgentToolSpec> toolSpecs,
List<AgentToolSpec> knowledgeToolSpecs,
List<AgentToolSpec> mcpToolSpecs,
List<AgentToolSpec> operateToolSpecs) {
List<AgentToolSpec> merged = new ArrayList<>();
if (toolSpecs != null) {
merged.addAll(toolSpecs);
}
if (knowledgeToolSpecs != null) {
merged.addAll(knowledgeToolSpecs);
}
if (mcpToolSpecs != null) {
merged.addAll(mcpToolSpecs);
}
@@ -1251,6 +1232,46 @@ public class AgentScopeReActRuntime implements AgentRuntime {
return merged;
}
/**
* 校验不同来源的运行时工具名称没有冲突。
*
* @param toolSpecs 普通工具声明
* @param knowledgeToolSpecs 知识库工具声明
* @param mcpToolSpecs MCP 工具声明
* @param operateToolSpecs 操作工具声明
* @throws AgentRuntimeException 工具名称重复时抛出
*/
private void validateRuntimeToolConflicts(List<AgentToolSpec> toolSpecs,
List<AgentToolSpec> knowledgeToolSpecs,
List<AgentToolSpec> mcpToolSpecs,
List<AgentToolSpec> operateToolSpecs) {
Set<String> names = new LinkedHashSet<>();
for (List<AgentToolSpec> specs : List.of(
safeToolSpecs(toolSpecs),
safeToolSpecs(knowledgeToolSpecs),
safeToolSpecs(mcpToolSpecs),
safeToolSpecs(operateToolSpecs))) {
for (AgentToolSpec spec : specs) {
if (spec == null || spec.getName() == null || spec.getName().isBlank()) {
continue;
}
if (!names.add(spec.getName())) {
throw new AgentRuntimeException("Agent runtime tool name conflict: " + spec.getName());
}
}
}
}
/**
* 将可空工具列表转换为空安全列表。
*
* @param toolSpecs 工具声明
* @return 非空工具声明列表
*/
private List<AgentToolSpec> safeToolSpecs(List<AgentToolSpec> toolSpecs) {
return toolSpecs == null ? List.of() : toolSpecs;
}
private void closeMcpClients() {
for (McpClientWrapper client : mcpClients) {
if (client == null) {
@@ -1264,28 +1285,6 @@ public class AgentScopeReActRuntime implements AgentRuntime {
mcpClients.clear();
}
/**
* 构建聚合知识库的默认检索配置。
*
* @param definition 智能体定义
* @return 检索配置
*/
private RetrieveConfig defaultRetrieveConfig(AgentDefinition definition) {
int limit = definition.getKnowledgeSpecs().stream()
.mapToInt(AgentKnowledgeSpec::getLimit)
.filter(value -> value > 0)
.sum();
double scoreThreshold = definition.getKnowledgeSpecs().stream()
.mapToDouble(AgentKnowledgeSpec::getScoreThreshold)
.filter(value -> value > 0D)
.min()
.orElse(0D);
return RetrieveConfig.builder()
.limit(limit <= 0 ? 5 : limit)
.scoreThreshold(scoreThreshold)
.build();
}
public AgentInitRequest getInitRequest() {
return initRequest;
}
@@ -1300,6 +1299,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
}
private record AgentScopeToolkitBuildResult(Map<String, List<AgentTool>> skillTools,
List<AgentToolSpec> knowledgeToolSpecs,
List<AgentToolSpec> mcpToolSpecs,
List<AgentToolSpec> operateToolSpecs,
List<McpSkillRegistration> skillMcpRegistrations) {

View File

@@ -170,7 +170,8 @@ public class AgentScopeToolAdapter {
throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName());
}
return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, turnContextHolder,
skillContext, skillBinding, emitNormalToolResult, true, true);
skillContext, skillBinding, emitNormalToolResult, true, true,
resolveInvocationClassLoader(invoker));
}
/**
@@ -203,7 +204,8 @@ public class AgentScopeToolAdapter {
throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName());
}
return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, turnContextHolder,
skillContext, skillBinding, emitNormalToolResult, emitSkillStep, true);
skillContext, skillBinding, emitNormalToolResult, emitSkillStep, true,
resolveInvocationClassLoader(invoker));
}
/**
@@ -238,7 +240,24 @@ public class AgentScopeToolAdapter {
throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName());
}
return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, turnContextHolder,
skillContext, skillBinding, emitNormalToolResult, emitSkillStep, handleApprovalInTool);
skillContext, skillBinding, emitNormalToolResult, emitSkillStep, handleApprovalInTool,
resolveInvocationClassLoader(invoker));
}
/**
* 解析工具执行时应使用的应用类加载器。
*
* <p>AgentScope 可能在 Reactor 工作线程执行工具。Spring Boot 可执行包中的业务类依赖
* 注册工具时的应用类加载器,不能依赖工作线程可能继承到的系统类加载器。</p>
*
* @param invoker 工具调用器
* @return 工具调用器所属类加载器;无法取得时回退到当前线程上下文类加载器
*/
private ClassLoader resolveInvocationClassLoader(AgentToolInvoker invoker) {
ClassLoader invokerClassLoader = invoker.getClass().getClassLoader();
return invokerClassLoader == null
? Thread.currentThread().getContextClassLoader()
: invokerClassLoader;
}
private AgentRuntimeTurnContextHolder fixedHolder(AgentRuntimeExecutionContext request,
@@ -258,7 +277,8 @@ public class AgentScopeToolAdapter {
AgentSkillBinding skillBinding,
boolean emitNormalToolResult,
boolean emitSkillStep,
boolean handleApprovalInTool) implements AgentTool {
boolean handleApprovalInTool,
ClassLoader invocationClassLoader) implements AgentTool {
/**
* 获取工具名称。
@@ -402,15 +422,28 @@ public class AgentScopeToolAdapter {
* @return 工具结果块
*/
private ToolResultBlock invokeTool(ToolCallParam param, Map<String, Object> input) {
AgentToolContext context = buildContext(param);
AgentToolResult result = invoker.invoke(input, context);
ToolResultBlock block = toToolResultBlock(param, result);
// 有状态 runtime 中,普通工具结果由 AgentScope 原生 PostActingEvent
// 旁路观察器统一发出;旧 sink 辅助路径没有统一 hook因此仍允许 adapter 兼容发射。
if (emitNormalToolResult || (emitSkillStep && activeSkillBinding() != null)) {
emit(toolResultEvent(block));
Thread currentThread = Thread.currentThread();
ClassLoader originalClassLoader = currentThread.getContextClassLoader();
boolean switchClassLoader = invocationClassLoader != null
&& invocationClassLoader != originalClassLoader;
if (switchClassLoader) {
currentThread.setContextClassLoader(invocationClassLoader);
}
try {
AgentToolContext context = buildContext(param);
AgentToolResult result = invoker.invoke(input, context);
ToolResultBlock block = toToolResultBlock(param, result);
// 有状态 runtime 中,普通工具结果由 AgentScope 原生 PostActingEvent
// 旁路观察器统一发出;旧 sink 辅助路径没有统一 hook因此仍允许 adapter 兼容发射。
if (emitNormalToolResult || (emitSkillStep && activeSkillBinding() != null)) {
emit(toolResultEvent(block));
}
return block;
} finally {
if (switchClassLoader) {
currentThread.setContextClassLoader(originalClassLoader);
}
}
return block;
}
/**

View File

@@ -107,9 +107,9 @@ public class AgentRuntimeTurnContext {
merged.setUserMessage(executionContext.getUserMessage());
merged.setMemorySnapshot(executionContext.getMemorySnapshot());
merged.setToolInvokers(fallback == null ? executionContext.getToolInvokers() : fallback.getToolInvokers());
merged.setKnowledgeRetrievers(fallback == null
? executionContext.getKnowledgeRetrievers()
: fallback.getKnowledgeRetrievers());
merged.setKnowledgeRegistrations(fallback == null
? executionContext.getKnowledgeRegistrations()
: fallback.getKnowledgeRegistrations());
merged.setSessionStore(fallback == null ? executionContext.getSessionStore() : fallback.getSessionStore());
merged.setConversationRecorder(fallback == null
? executionContext.getConversationRecorder()

View File

@@ -5,10 +5,12 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventBridge;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.event.AgentRuntimeObserver;
import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext;
import com.easyagents.agent.runtime.tool.AgentToolCategory;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PostActingEvent;
import io.agentscope.core.hook.PreActingEvent;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import reactor.core.publisher.Mono;
@@ -130,12 +132,21 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
private void enrichToolPayload(AgentRuntimeEvent runtimeEvent, String toolName) {
AgentToolSpec toolSpec = toolSpecs.get(toolName);
if (toolSpec == null || toolSpec.getMetadata() == null || toolSpec.getMetadata().isEmpty()) {
if (toolSpec == null) {
return;
}
runtimeEvent.getPayload().put("toolCategory", toolSpec.getCategory().name());
if (toolSpec.getMetadata() == null || toolSpec.getMetadata().isEmpty()) {
return;
}
Map<String, Object> metadata = toolSpec.getMetadata();
putIfPresent(runtimeEvent.getPayload(), metadata, "toolDisplayName");
putIfPresent(runtimeEvent.getPayload(), metadata, "skillId");
if (toolSpec.getCategory() == AgentToolCategory.KNOWLEDGE) {
putIfPresent(runtimeEvent.getPayload(), metadata, "knowledgeId");
putIfPresent(runtimeEvent.getPayload(), metadata, "knowledgeName");
putIfPresent(runtimeEvent.getPayload(), metadata, "knowledgeRuntimeName");
}
}
private void putIfPresent(Map<String, Object> payload, Map<String, Object> metadata, String key) {
@@ -149,7 +160,15 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
return false;
}
Object success = result.getMetadata() == null ? null : result.getMetadata().get("success");
return !(success instanceof Boolean) || Boolean.TRUE.equals(success);
if (success instanceof Boolean) {
return Boolean.TRUE.equals(success);
}
// AgentScope 1.x 将工具异常转换为不带 success metadata 的 "Error: ..." 文本结果。
return result.getOutput().stream()
.filter(TextBlock.class::isInstance)
.map(TextBlock.class::cast)
.map(TextBlock::getText)
.noneMatch(text -> text != null && text.startsWith("Error: "));
}
private boolean isSkillTool(String toolName) {

View File

@@ -1,10 +0,0 @@
package com.easyagents.agent.runtime.knowledge;
/**
* 知识库检索策略。
*/
public enum AgentKnowledgePolicy {
AGENTIC,
GENERIC,
DISABLED
}

View File

@@ -0,0 +1,43 @@
package com.easyagents.agent.runtime.knowledge;
import java.util.Objects;
/**
* 知识库声明与检索器的运行时绑定。
*/
public final class AgentKnowledgeRegistration {
private final AgentKnowledgeSpec knowledgeSpec;
private final AgentKnowledgeRetriever retriever;
/**
* 创建知识库运行时绑定。
*
* @param knowledgeSpec 知识库声明
* @param retriever 知识库检索器
* @throws NullPointerException 声明或检索器为空时抛出
*/
public AgentKnowledgeRegistration(AgentKnowledgeSpec knowledgeSpec,
AgentKnowledgeRetriever retriever) {
this.knowledgeSpec = Objects.requireNonNull(knowledgeSpec, "knowledgeSpec");
this.retriever = Objects.requireNonNull(retriever, "retriever");
}
/**
* 获取知识库声明。
*
* @return 知识库声明
*/
public AgentKnowledgeSpec getKnowledgeSpec() {
return knowledgeSpec;
}
/**
* 获取知识库检索器。
*
* @return 知识库检索器
*/
public AgentKnowledgeRetriever getRetriever() {
return retriever;
}
}

View File

@@ -9,9 +9,9 @@ import java.util.Map;
public class AgentKnowledgeSpec {
private String knowledgeId;
private String runtimeName;
private String name;
private String description;
private AgentKnowledgePolicy retrievalMode = AgentKnowledgePolicy.AGENTIC;
private int limit = 5;
private double scoreThreshold = 0D;
private Map<String, Object> metadata = new LinkedHashMap<>();
@@ -34,6 +34,24 @@ public class AgentKnowledgeSpec {
this.knowledgeId = knowledgeId;
}
/**
* 获取调用方提供的英文运行名。
*
* @return 英文运行名
*/
public String getRuntimeName() {
return runtimeName;
}
/**
* 设置调用方提供的英文运行名。
*
* @param runtimeName 英文运行名
*/
public void setRuntimeName(String runtimeName) {
this.runtimeName = runtimeName;
}
/**
* 获取知识库名称。
*
@@ -70,24 +88,6 @@ public class AgentKnowledgeSpec {
this.description = description;
}
/**
* 获取检索模式。
*
* @return 检索模式
*/
public AgentKnowledgePolicy getRetrievalMode() {
return retrievalMode;
}
/**
* 设置检索模式。
*
* @param retrievalMode 检索模式
*/
public void setRetrievalMode(AgentKnowledgePolicy retrievalMode) {
this.retrievalMode = retrievalMode == null ? AgentKnowledgePolicy.AGENTIC : retrievalMode;
}
/**
* 获取限制数量。
*

View File

@@ -0,0 +1,79 @@
package com.easyagents.agent.runtime.knowledge;
import com.easyagents.agent.runtime.AgentRuntimeException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.regex.Pattern;
/**
* Agent 知识库工具名称规范。
*/
public final class AgentKnowledgeToolNames {
/**
* 知识库工具名称前缀。
*/
public static final String PREFIX = "retrieve_knowledge_";
/**
* OpenAI-compatible Function Call 的通用名称长度上限。
*/
public static final int MAX_TOOL_NAME_LENGTH = 64;
private static final int HASH_LENGTH = 8;
private static final Pattern SAFE_RUNTIME_NAME = Pattern.compile("^[A-Za-z0-9_-]+$");
/**
* 禁止实例化工具类。
*/
private AgentKnowledgeToolNames() {
}
/**
* 根据调用方运行名生成稳定的知识库工具名。
*
* <p>超长名称保留可读前缀并追加稳定短哈希,避免不同模型服务对 Function Call
* 名称长度限制不一致。</p>
*
* @param runtimeName 调用方提供的英文运行名
* @return 合法且稳定的工具名
* @throws AgentRuntimeException 运行名为空或包含非法字符时抛出
*/
public static String build(String runtimeName) {
String normalized = runtimeName == null ? "" : runtimeName.trim();
if (normalized.isEmpty()) {
throw new AgentRuntimeException("Knowledge runtime name is required.");
}
if (!SAFE_RUNTIME_NAME.matcher(normalized).matches()) {
throw new AgentRuntimeException(
"Knowledge runtime name must contain only letters, numbers, underscores, or hyphens: "
+ normalized);
}
String toolName = PREFIX + normalized;
if (toolName.length() <= MAX_TOOL_NAME_LENGTH) {
return toolName;
}
String hash = shortHash(normalized);
int readableLength = MAX_TOOL_NAME_LENGTH - PREFIX.length() - HASH_LENGTH - 1;
return PREFIX + normalized.substring(0, readableLength) + "_" + hash;
}
/**
* 计算用于超长工具名消歧的稳定短哈希。
*
* @param value 原始运行名
* @return 八位十六进制哈希
*/
private static String shortHash(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest).substring(0, HASH_LENGTH);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable.", exception);
}
}
}

View File

@@ -34,9 +34,16 @@ public class HeuristicKnowledgeCitationMatcher implements AgentKnowledgeCitation
if (normalizedAnswer.length() < MIN_NORMALIZED_ANSWER_LENGTH) {
return List.of();
}
List<String> normalizedSegments = normalizeSegments(answerText);
List<ScoredKnowledgeReference> scoredReferences = new ArrayList<>();
for (AgentKnowledgeReference candidate : candidates) {
double supportScore = supportScore(normalizedAnswer, normalize(candidate == null ? null : candidate.getChunkContent()));
String normalizedContent = normalize(candidate == null ? null : candidate.getChunkContent());
double supportScore = supportScore(normalizedAnswer, normalizedContent);
// 长篇汇总回答中,每条证据通常只支撑一个段落或条目。继续只用整篇答案作分母,
// 会随着主题增多把有效引用的重合度稀释到阈值以下。
for (String normalizedSegment : normalizedSegments) {
supportScore = Math.max(supportScore, supportScore(normalizedSegment, normalizedContent));
}
if (supportScore >= MIN_SUPPORT_SCORE) {
scoredReferences.add(new ScoredKnowledgeReference(candidate, supportScore));
}
@@ -48,6 +55,22 @@ public class HeuristicKnowledgeCitationMatcher implements AgentKnowledgeCitation
.toList();
}
/**
* 将答案切分为可独立核验的段落或句子并完成归一化。
*
* @param answerText 最终答案文本
* @return 非空的归一化答案片段
*/
private List<String> normalizeSegments(String answerText) {
if (answerText == null || answerText.isBlank()) {
return List.of();
}
return Arrays.stream(answerText.split("[\\r\\n。!?;]+"))
.map(this::normalize)
.filter(segment -> segment.length() >= MIN_NORMALIZED_ANSWER_LENGTH)
.toList();
}
/**
* 计算答案与候选片段之间的文本支撑分。
*

View File

@@ -0,0 +1,105 @@
package com.easyagents.agent.runtime.prompt;
import com.easyagents.agent.runtime.AgentDefinition;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import java.util.ArrayList;
import java.util.List;
/**
* 组合智能体运行时使用的系统提示词。
*/
public final class SystemPromptComposer {
private static final String KNOWLEDGE_TOOL_PROTOCOL = """
Knowledge tool protocol:
- Knowledge tools provide grounded information for the scopes described in their tool descriptions.
- Before answering a request involving facts, policies, procedures, entitlements, prices, product or service details, or other information within a knowledge tool's scope, call the most relevant knowledge tool first.
- Do not rely only on general model knowledge for claims that fall within an available knowledge tool's scope.
- If it is uncertain whether the request falls within a knowledge tool's scope, prefer making one retrieval call.
- Build a concise, standalone query from the current request and only the necessary conversation context. Resolve pronouns and omitted subjects, and preserve relevant names, time, location, product, membership level, constraints, and user intent. Do not copy the entire conversation.
- If the returned results are empty or do not address the request, reformulate the query and retry once when a materially different query is possible. Do not repeat the same query or retrieve indefinitely.
- Base knowledge-backed claims only on information actually returned by the tools. Never imply that a knowledge base contains information that was not returned.
- If retrieval remains insufficient, follow the Agent's configured system prompt for the response strategy.
- Skip retrieval for greetings, casual conversation, pure writing or translation, and tasks that clearly do not depend on knowledge-base facts.
""".strip();
private static final String ASYNC_TOOL_PROTOCOL = """
Async tool protocol:
- Async tools may expose submit, observe, result, cancel, and list sub-tools. Treat these sub-tools as one user-facing tool.
- Do not ask the user to choose submit, observe, result, cancel, or list. These are internal execution phases.
- For a normal user request to use an async tool, call its submit sub-tool first with the user-provided arguments by default.
- After submit returns task_id, immediately call observe with that task_id to check progress.
- If the task is completed and result is available, use the returned result to answer the user.
- If the task is still running after observation, tell the user that the task is running and keep task_id/next_action for later tool calls.
- Use result, list, or cancel directly only when the user explicitly asks to get a known task result, list tasks, or cancel a task.
""".strip();
/**
* 阻止工具类被实例化。
*/
private SystemPromptComposer() {
}
/**
* 按固定顺序组合用户提示词与运行时工具协议。
*
* @param definition 智能体定义
* @return 最终系统提示词;没有任何提示词时返回原始空值
*/
public static String compose(AgentDefinition definition) {
if (definition == null) {
return null;
}
boolean knowledgeProtocolEnabled = hasEnabledKnowledgeTool(definition);
boolean asyncProtocolEnabled = hasAsyncTool(definition);
String userPrompt = definition.getSystemPrompt();
if (!knowledgeProtocolEnabled && !asyncProtocolEnabled) {
return userPrompt;
}
List<String> promptSections = new ArrayList<>(3);
if (userPrompt != null && !userPrompt.isBlank()) {
promptSections.add(userPrompt.stripTrailing());
}
if (knowledgeProtocolEnabled) {
promptSections.add(KNOWLEDGE_TOOL_PROTOCOL);
}
if (asyncProtocolEnabled) {
promptSections.add(ASYNC_TOOL_PROTOCOL);
}
return String.join("\n\n", promptSections);
}
/**
* 判断当前定义是否会注册可调用的知识库工具。
*
* @param definition 智能体定义
* @return 知识库工具可用时返回 true
*/
private static boolean hasEnabledKnowledgeTool(AgentDefinition definition) {
return definition.getExecutionOptions() != null
&& definition.getExecutionOptions().isToolCallingEnabled()
&& definition.getKnowledgeSpecs() != null
&& !definition.getKnowledgeSpecs().isEmpty();
}
/**
* 判断当前定义是否包含异步工具。
*
* @param definition 智能体定义
* @return 包含异步工具时返回 true
*/
private static boolean hasAsyncTool(AgentDefinition definition) {
if (definition.getToolSpecs() == null) {
return false;
}
for (AgentToolSpec toolSpec : definition.getToolSpecs()) {
// AsyncToolSpecExpander 会为生成的全部子工具写入该运行时元数据。
if (toolSpec != null && Boolean.TRUE.equals(toolSpec.getMetadata().get("asyncTool"))) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,226 @@
package com.easyagents.agent.runtime.agentscope;
import com.easyagents.agent.runtime.AgentDefinition;
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventBridge;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.event.AgentRuntimeTurnContext;
import com.easyagents.agent.runtime.event.AgentRuntimeTurnContextHolder;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeToolNames;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.tool.ToolCallParam;
import io.agentscope.core.tool.Toolkit;
import org.junit.Assert;
import org.junit.Test;
import reactor.core.publisher.Sinks;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
/**
* {@link AgentScopeKnowledgeAdapter} 回归测试。
*/
public class AgentScopeKnowledgeAdapterTest {
/**
* 验证每个知识库生成独立工具,工具名、描述和 Schema 向模型暴露完整信息。
*/
@Test
public void shouldCreateOneToolPerKnowledge() {
AgentKnowledgeSpec first = knowledgeSpec("knowledge-1", "homeinn_faq", 5);
first.setName("如家 FAQ");
first.setDescription("如家酒店入住、退房和会员服务常见问题");
AgentKnowledgeSpec second = knowledgeSpec("knowledge-2", "hotel_policy", 3);
AgentRuntimeExecutionContext context = executionContext(List.of(first, second));
context.setKnowledgeRegistrations(List.of(
registration(first, 1, 0.9D),
registration(second, 1, 0.8D)));
List<AgentToolSpec> toolSpecs = new AgentScopeKnowledgeAdapter().createToolSpecs(context);
Assert.assertEquals(2, toolSpecs.size());
Assert.assertEquals("retrieve_knowledge_homeinn_faq", toolSpecs.get(0).getName());
Assert.assertTrue(toolSpecs.get(0).getDescription().contains("如家 FAQ"));
Assert.assertTrue(toolSpecs.get(0).getDescription().contains("入住、退房"));
Assert.assertEquals(List.of("query"), toolSpecs.get(0).getParametersSchema().get("required"));
Assert.assertFalse(String.valueOf(toolSpecs.get(0).getParametersSchema()).contains("limit"));
}
/**
* 验证选中一个知识库工具时只调用对应 Retriever并使用绑定 limit 和阈值。
*/
@Test
public void shouldDispatchOnlyToSelectedKnowledgeRetriever() {
AgentKnowledgeSpec first = knowledgeSpec("knowledge-1", "homeinn_faq", 2);
first.setScoreThreshold(0.5D);
AgentKnowledgeSpec second = knowledgeSpec("knowledge-2", "hotel_policy", 2);
AgentRuntimeExecutionContext context = executionContext(List.of(first, second));
AtomicInteger firstCalls = new AtomicInteger();
AtomicInteger secondCalls = new AtomicInteger();
context.setKnowledgeRegistrations(List.of(
new AgentKnowledgeRegistration(first, request -> {
firstCalls.incrementAndGet();
Assert.assertEquals("如家几点退房", request.getQuery());
Assert.assertEquals(2, request.getLimit());
Assert.assertEquals(0.5D, request.getScoreThreshold(), 0.0001D);
return AgentKnowledgeRetrievalResult.of(List.of(
document("knowledge-1", 0.9D),
document("knowledge-1-low", 0.2D)));
}),
new AgentKnowledgeRegistration(second, request -> {
secondCalls.incrementAndGet();
return AgentKnowledgeRetrievalResult.of(List.of(document("knowledge-2", 0.8D)));
})));
RegisteredKnowledgeTools registered = register(context);
ToolResultBlock result = registered.toolkit().getTool("retrieve_knowledge_homeinn_faq")
.callAsync(toolCall("retrieve_knowledge_homeinn_faq", "如家几点退房"))
.block();
Assert.assertNotNull(result);
Assert.assertEquals(1, firstCalls.get());
Assert.assertEquals(0, secondCalls.get());
Assert.assertEquals(1, result.getMetadata().get("documentCount"));
}
/**
* 验证最终知识库事件与阈值过滤、排序和截断后的模型可见文档一致。
*/
@Test
public void retrievalEventShouldMatchFinalToolDocuments() {
AgentKnowledgeSpec spec = knowledgeSpec("knowledge-1", "homeinn_faq", 2);
spec.setScoreThreshold(0.5D);
AgentRuntimeExecutionContext context = executionContext(List.of(spec));
context.setKnowledgeRegistrations(List.of(new AgentKnowledgeRegistration(spec, request ->
AgentKnowledgeRetrievalResult.of(List.of(
document("third", 0.7D),
document("first", 0.9D),
document("filtered", 0.2D),
document("second", 0.8D))))));
RegisteredKnowledgeTools registered = register(context);
ToolResultBlock result = registered.toolkit().getTool("retrieve_knowledge_homeinn_faq")
.callAsync(toolCall("retrieve_knowledge_homeinn_faq", "query"))
.block();
List<AgentRuntimeEvent> events = registered.eventSink().asFlux()
.filter(event -> event.getEventType() == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL)
.take(1)
.collectList()
.block(Duration.ofSeconds(1));
Assert.assertNotNull(result);
Assert.assertNotNull(events);
Assert.assertEquals(1, events.size());
AgentRuntimeEvent event = events.get(0);
Assert.assertEquals(2, event.getPayload().get("documentCount"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> eventDocuments =
(List<Map<String, Object>>) event.getPayload().get("documents");
@SuppressWarnings("unchecked")
List<Map<String, Object>> resultDocuments =
(List<Map<String, Object>>) result.getMetadata().get("documents");
Assert.assertEquals(resultDocuments, eventDocuments);
Assert.assertEquals("first", eventDocuments.get(0).get("documentId"));
Assert.assertEquals("second", eventDocuments.get(1).get("documentId"));
}
/**
* 验证超长运行名会稳定压缩到 Function Call 长度上限。
*/
@Test
public void longRuntimeNameShouldProduceStableBoundedToolName() {
String runtimeName = "knowledge_" + "a".repeat(80);
String first = AgentKnowledgeToolNames.build(runtimeName);
String second = AgentKnowledgeToolNames.build(runtimeName);
Assert.assertEquals(first, second);
Assert.assertEquals(AgentKnowledgeToolNames.MAX_TOOL_NAME_LENGTH, first.length());
Assert.assertTrue(first.startsWith(AgentKnowledgeToolNames.PREFIX));
}
private RegisteredKnowledgeTools register(AgentRuntimeExecutionContext context) {
AgentScopeKnowledgeAdapter adapter = new AgentScopeKnowledgeAdapter();
List<AgentToolSpec> toolSpecs = adapter.createToolSpecs(context);
Toolkit toolkit = new Toolkit();
Sinks.Many<AgentRuntimeEvent> eventSink = Sinks.many().replay().all();
AgentRuntimeTurnContextHolder holder = new AgentRuntimeTurnContextHolder();
AgentRuntimeEventBridge eventBridge = new AgentRuntimeEventBridge(context, holder);
holder.set(new AgentRuntimeTurnContext(context, eventSink, eventBridge));
adapter.registerTools(context, toolSpecs, toolkit, new AgentScopeToolAdapter(),
AgentToolApprovalCoordinator.disabled(), holder);
return new RegisteredKnowledgeTools(toolkit, eventSink);
}
private ToolCallParam toolCall(String toolName, String query) {
ToolUseBlock toolUseBlock = ToolUseBlock.builder()
.id("call-1")
.name(toolName)
.input(Map.of("query", query))
.build();
return ToolCallParam.builder()
.toolUseBlock(toolUseBlock)
.input(toolUseBlock.getInput())
.build();
}
private AgentRuntimeExecutionContext executionContext(List<AgentKnowledgeSpec> specs) {
AgentDefinition definition = new AgentDefinition();
definition.setAgentId("agent-1");
definition.setKnowledgeSpecs(specs);
AgentRuntimeExecutionContext context = new AgentRuntimeExecutionContext();
context.setAgentDefinition(definition);
context.setTraceId("trace-1");
context.setSessionId("session-1");
return context;
}
private AgentKnowledgeSpec knowledgeSpec(String knowledgeId, String runtimeName, int limit) {
AgentKnowledgeSpec spec = new AgentKnowledgeSpec();
spec.setKnowledgeId(knowledgeId);
spec.setRuntimeName(runtimeName);
spec.setName(knowledgeId);
spec.setLimit(limit);
return spec;
}
private AgentKnowledgeRegistration registration(AgentKnowledgeSpec spec,
int documentCount,
double firstScore) {
return new AgentKnowledgeRegistration(spec, request ->
AgentKnowledgeRetrievalResult.of(documents(spec.getKnowledgeId(), documentCount, firstScore)));
}
private List<AgentKnowledgeDocument> documents(String knowledgeId, int count, double firstScore) {
List<AgentKnowledgeDocument> documents = new ArrayList<>();
for (int index = 0; index < count; index++) {
documents.add(document(knowledgeId + "-doc-" + index, firstScore - (index * 0.01D)));
}
return documents;
}
private AgentKnowledgeDocument document(String documentId, double score) {
AgentKnowledgeDocument document = new AgentKnowledgeDocument();
document.setDocumentId(documentId);
document.setDocumentName("FAQ");
document.setChunkId(documentId + "-chunk");
document.setContent("content-" + documentId);
document.setScore(score);
return document;
}
private record RegisteredKnowledgeTools(Toolkit toolkit,
Sinks.Many<AgentRuntimeEvent> eventSink) {
}
}

View File

@@ -10,6 +10,7 @@ import com.easyagents.agent.runtime.event.observer.SkillExecutionObserver;
import com.easyagents.agent.runtime.event.observer.ToolExecutionObserver;
import com.easyagents.agent.runtime.hitl.AgentResumeToken;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter;
@@ -23,6 +24,7 @@ import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSess
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext;
import com.easyagents.agent.runtime.skill.AgentSkillSpec;
import com.easyagents.agent.runtime.tool.AgentToolCategory;
import com.easyagents.agent.runtime.tool.AgentToolResult;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolAdapter;
@@ -153,6 +155,27 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertTrue(runtime.getAgent().getSysPrompt().contains("immediately call observe"));
}
@Test
public void shouldAppendKnowledgeToolProtocolPromptWhenKnowledgeToolsExist() {
AgentInitRequest request = initRequest();
AgentKnowledgeSpec knowledgeSpec = new AgentKnowledgeSpec();
knowledgeSpec.setKnowledgeId("knowledge-faq");
knowledgeSpec.setRuntimeName("faq");
request.getAgentDefinition().setKnowledgeSpecs(List.of(knowledgeSpec));
request.setKnowledgeRegistrations(List.of(new AgentKnowledgeRegistration(
knowledgeSpec, retrievalRequest -> AgentKnowledgeRetrievalResult.of(List.of()))));
AgentScopeReActRuntime runtime = fakeRuntime();
runtime.init(request);
Assert.assertTrue(runtime.getAgent().getSysPrompt()
.startsWith("system\n\nKnowledge tool protocol:"));
Assert.assertTrue(runtime.getAgent().getSysPrompt()
.contains("call the most relevant knowledge tool first"));
Assert.assertTrue(runtime.getAgent().getSysPrompt()
.contains("reformulate the query and retry once"));
}
@Test
public void shouldEmitSideEventWithRuntimeIdentityFromBridge() throws Exception {
AgentRuntimeExecutionContext context = new AgentRuntimeExecutionContext();
@@ -235,6 +258,26 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertTrue(interceptors.stream().anyMatch(ToolHitlInterceptor.class::isInstance));
}
/**
* 验证调用方提供的历史快照会在 Agent 首次构建时进入模型记忆。
*/
@Test
public void shouldAttachInitialConversationHistoryToAgentMemory() {
AgentInitRequest request = initRequest();
AgentMemorySnapshot snapshot = new AgentMemorySnapshot();
snapshot.addMessage(AgentMessage.text(AgentMessageRole.USER, "previous user question"));
snapshot.addMessage(AgentMessage.text(AgentMessageRole.ASSISTANT, "previous assistant answer"));
request.setMemorySnapshot(snapshot);
AgentScopeReActRuntime runtime = fakeRuntime();
runtime.init(request);
List<Msg> messages = runtime.getAgent().getMemory().getMessages();
Assert.assertEquals(2, messages.size());
Assert.assertEquals("previous user question", messages.get(0).getTextContent());
Assert.assertEquals("previous assistant answer", messages.get(1).getTextContent());
}
@Test
public void shouldRegisterOperateToolsIntoToolkit() {
AgentInitRequest request = initRequest();
@@ -554,6 +597,82 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertFalse(events.toString().contains("sentinel-secret"));
}
/**
* 验证知识库工具复用统一工具生命周期,并携带可供调用方投影的知识库分类。
*
* @throws Exception 等待旁路事件失败时抛出
*/
@Test
public void shouldEmitKnowledgeToolLifecycleFromToolExecutionObserver() throws Exception {
AgentRuntimeExecutionContext context = executionContext();
Sinks.Many<AgentRuntimeEvent> sink = Sinks.many().replay().all();
AgentRuntimeEventBridge bridge = AgentRuntimeEventBridge.fixed(context, sink);
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName("retrieve_knowledge_homeinn_faq");
toolSpec.setCategory(AgentToolCategory.KNOWLEDGE);
toolSpec.getMetadata().put("toolDisplayName", "如家 FAQ");
toolSpec.getMetadata().put("knowledgeId", "knowledge-1");
toolSpec.getMetadata().put("knowledgeName", "如家 FAQ");
toolSpec.getMetadata().put("knowledgeRuntimeName", "homeinn_faq");
ToolExecutionObserver observer = new ToolExecutionObserver(bridge, null, List.of(toolSpec));
ReActAgent agent = initializedAgent();
Toolkit toolkit = agent.getToolkit();
ToolUseBlock toolUse = ToolUseBlock.builder()
.id("knowledge-call-1")
.name("retrieve_knowledge_homeinn_faq")
.input(Map.of("query", "几点退房"))
.build();
ToolResultBlock toolResult = ToolResultBlock.of(
"knowledge-call-1",
"retrieve_knowledge_homeinn_faq",
TextBlock.builder().text("中午十二点退房").build(),
Map.of("success", true));
observer.observe(new PreActingEvent(agent, toolkit, toolUse)).block();
observer.observe(new PostActingEvent(agent, toolkit, toolUse, toolResult)).block();
List<AgentRuntimeEvent> events = sink.asFlux().take(2).collectList().toFuture()
.get(3, TimeUnit.SECONDS);
Assert.assertEquals(AgentRuntimeEventType.TOOL_CALL, events.get(0).getEventType());
Assert.assertEquals("KNOWLEDGE", events.get(0).getPayload().get("toolCategory"));
Assert.assertEquals("knowledge-1", events.get(0).getPayload().get("knowledgeId"));
Assert.assertEquals("RUNNING", events.get(0).getPayload().get("status"));
Assert.assertEquals(AgentRuntimeEventType.TOOL_RESULT, events.get(1).getEventType());
Assert.assertEquals("KNOWLEDGE", events.get(1).getPayload().get("toolCategory"));
Assert.assertEquals("SUCCESS", events.get(1).getPayload().get("status"));
}
/**
* 验证 AgentScope 的文本错误结果会投影为失败工具状态。
*
* @throws Exception 等待旁路事件失败时抛出
*/
@Test
public void shouldMarkAgentScopeKnowledgeToolErrorAsFailed() throws Exception {
AgentRuntimeExecutionContext context = executionContext();
Sinks.Many<AgentRuntimeEvent> sink = Sinks.many().replay().all();
AgentRuntimeEventBridge bridge = AgentRuntimeEventBridge.fixed(context, sink);
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName("retrieve_knowledge_homeinn_faq");
toolSpec.setCategory(AgentToolCategory.KNOWLEDGE);
ToolExecutionObserver observer = new ToolExecutionObserver(bridge, null, List.of(toolSpec));
ReActAgent agent = initializedAgent();
ToolUseBlock toolUse = ToolUseBlock.builder()
.id("knowledge-call-error")
.name("retrieve_knowledge_homeinn_faq")
.input(Map.of("query", "异常查询"))
.build();
ToolResultBlock toolResult = ToolResultBlock.error("retriever unavailable")
.withIdAndName("knowledge-call-error", "retrieve_knowledge_homeinn_faq");
observer.observe(new PostActingEvent(agent, agent.getToolkit(), toolUse, toolResult)).block();
AgentRuntimeEvent event = sink.asFlux().next().toFuture().get(3, TimeUnit.SECONDS);
Assert.assertEquals(AgentRuntimeEventType.TOOL_RESULT, event.getEventType());
Assert.assertEquals("FAILED", event.getPayload().get("status"));
Assert.assertEquals(Boolean.FALSE, event.getPayload().get("success"));
}
@Test
public void shouldEmitSkillLifecycleEventsFromSkillExecutionObserver() throws Exception {
AgentRuntimeExecutionContext context = executionContext();
@@ -838,6 +957,59 @@ public class AgentScopeStatefulRuntimeTest {
&& "partial answer".equals(message.getTextContent())));
}
@Test
public void shouldPersistUserMessageWhenModelFails() {
InMemoryAgentSessionStore sessionStore = new InMemoryAgentSessionStore();
AgentInitRequest request = initRequest();
request.setSessionStore(sessionStore);
AgentScopeReActRuntime runtime = runtimeWithError(new IllegalStateException("model unavailable"));
runtime.init(request);
List<AgentRuntimeEvent> events = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "remember this request"))
.collectList()
.block();
Assert.assertTrue(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.FAILED));
Assert.assertTrue(sessionStore.exists("session-1"));
AgentScopeReActRuntime restoredRuntime = fakeRuntime();
restoredRuntime.init(request);
Assert.assertTrue(restoredRuntime.getAgent().getMemory().getMessages().stream()
.anyMatch(message -> message.getRole() == MsgRole.USER
&& "remember this request".equals(message.getTextContent())));
}
@Test
public void shouldPersistUserMessageWhenReasoningOnlyStreamIsCancelled() throws Exception {
InMemoryAgentSessionStore sessionStore = new InMemoryAgentSessionStore();
AgentInitRequest request = initRequest();
request.setSessionStore(sessionStore);
AgentScopeReActRuntime runtime = runtimeWithModel(List.of(
ChatResponse.builder()
.id("reasoning-only")
.content(List.of(ThinkingBlock.builder().thinking("still thinking").build()))
.build()), Duration.ofSeconds(5));
runtime.init(request);
CompletableFuture<AgentRuntimeEvent> firstReasoning = new CompletableFuture<>();
reactor.core.Disposable disposable = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "remember reasoning request"))
.subscribe(event -> {
if (event.getEventType() == AgentRuntimeEventType.REASONING_DELTA) {
firstReasoning.complete(event);
}
}, firstReasoning::completeExceptionally);
firstReasoning.get(3, TimeUnit.SECONDS);
disposable.dispose();
awaitCondition(() -> sessionStore.exists("session-1"));
AgentScopeReActRuntime restoredRuntime = fakeRuntime();
restoredRuntime.init(request);
Assert.assertTrue(restoredRuntime.getAgent().getMemory().getMessages().stream()
.anyMatch(message -> message.getRole() == MsgRole.USER
&& "remember reasoning request".equals(message.getTextContent())));
}
@Test
public void shouldNotDuplicateNormalToolEventsFromMainStream() {
AgentInitRequest request = initRequest();
@@ -1577,9 +1749,12 @@ public class AgentScopeStatefulRuntimeTest {
AgentInitRequest request = initRequest();
AgentKnowledgeSpec knowledgeSpec = new AgentKnowledgeSpec();
knowledgeSpec.setKnowledgeId("knowledge-1");
knowledgeSpec.setRuntimeName("faq");
knowledgeSpec.setName("知识库");
request.getAgentDefinition().setKnowledgeSpecs(List.of(knowledgeSpec));
request.setKnowledgeRetrievers(Map.of("knowledge-1", retrievalRequest -> {
AtomicBoolean knowledgeInvoked = new AtomicBoolean(false);
request.setKnowledgeRegistrations(List.of(new AgentKnowledgeRegistration(knowledgeSpec, retrievalRequest -> {
knowledgeInvoked.set(true);
AgentKnowledgeDocument document = new AgentKnowledgeDocument();
document.setDocumentId("doc-1");
document.setDocumentName("说明文档");
@@ -1587,9 +1762,27 @@ public class AgentScopeStatefulRuntimeTest {
document.setContent("fake answer");
document.setScore(0.9D);
return AgentKnowledgeRetrievalResult.of(List.of(document));
}));
AgentScopeReActRuntime runtime = fakeRuntime();
})));
AgentScopeReActRuntime runtime = runtimeWithModel(List.of(
ChatResponse.builder()
.id("knowledge-tool-call")
.content(List.of(ToolUseBlock.builder()
.id("call-search")
.name("retrieve_knowledge_faq")
.input(Map.of("query", "fake answer"))
.content("{\"query\":\"fake answer\"}")
.build()))
.finishReason("tool_calls")
.build(),
ChatResponse.builder()
.id("knowledge-final")
.content(List.of(TextBlock.builder().text("fake answer").build()))
.finishReason("stop")
.build()));
runtime.init(request);
Assert.assertNotNull(runtime.getAgent().getToolkit().getTool("retrieve_knowledge_faq"));
Assert.assertTrue(runtime.getAgent().getToolkit().getToolSchemas().stream()
.anyMatch(schema -> "retrieve_knowledge_faq".equals(schema.getName())));
List<AgentRuntimeEvent> events = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "query knowledge"))
.collectList()
@@ -1600,19 +1793,44 @@ public class AgentScopeStatefulRuntimeTest {
.findFirst()
.orElseThrow();
Assert.assertNotNull(completed.getMessage());
if (completed.getMessage().getKnowledgeReferences().isEmpty()) {
/*
* 当前 runtime 将知识库注册为 AgentScope AGENTIC RAG模型需要主动调用
* retrieve_knowledge 才会产生 KNOWLEDGE_RETRIEVAL 旁路事件。fake model
* 不会调用该工具时,不应强行猜引用。
*/
Assert.assertFalse(events.stream().anyMatch(event ->
event.getEventType() == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL));
return;
}
Assert.assertTrue("knowledgeInvoked=" + knowledgeInvoked.get() + ", events="
+ events.stream().map(AgentRuntimeEvent::getEventType).toList(),
events.stream().anyMatch(event ->
event.getEventType() == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL));
Assert.assertTrue(events.stream().anyMatch(event ->
event.getEventType() == AgentRuntimeEventType.TOOL_CALL
&& "KNOWLEDGE".equals(event.getPayload().get("toolCategory"))));
Assert.assertTrue(events.stream().anyMatch(event ->
event.getEventType() == AgentRuntimeEventType.TOOL_RESULT
&& "KNOWLEDGE".equals(event.getPayload().get("toolCategory"))));
Assert.assertEquals("chunk-1", completed.getMessage().getKnowledgeReferences().get(0).getChunkId());
}
/**
* 验证知识库生成工具与普通工具同名时拒绝初始化,避免 Toolkit 静默覆盖。
*/
@Test(expected = AgentRuntimeException.class)
public void shouldRejectKnowledgeToolNameConflictWithRegularTool() {
AgentInitRequest request = initRequest();
AgentKnowledgeSpec knowledgeSpec = new AgentKnowledgeSpec();
knowledgeSpec.setKnowledgeId("knowledge-1");
knowledgeSpec.setRuntimeName("faq");
knowledgeSpec.setName("知识库");
request.getAgentDefinition().setKnowledgeSpecs(List.of(knowledgeSpec));
request.setKnowledgeRegistrations(List.of(new AgentKnowledgeRegistration(
knowledgeSpec,
retrievalRequest -> AgentKnowledgeRetrievalResult.of(List.of()))));
AgentToolSpec regularTool = new AgentToolSpec();
regularTool.setName("retrieve_knowledge_faq");
regularTool.setDescription("conflicting tool");
request.getAgentDefinition().setToolSpecs(List.of(regularTool));
request.setToolInvokers(Map.of(
regularTool.getName(),
(arguments, context) -> AgentToolResult.success("done")));
fakeRuntime().init(request);
}
private AgentScopeReActRuntime fakeRuntime() {
return new AgentScopeReActRuntime(new FakeAgentScopeModelFactory(), new AgentScopeToolAdapter(),
new AgentScopeKnowledgeAdapter(), new AgentScopeMemoryAdapter(), new AgentScopeSkillAdapter(),
@@ -1636,6 +1854,37 @@ public class AgentScopeStatefulRuntimeTest {
new AgentScopeMessageAdapter());
}
/**
* 创建模型调用直接失败的运行时。
*
* @param error 模型异常
* @return 测试运行时
*/
private AgentScopeReActRuntime runtimeWithError(Throwable error) {
AgentScopeModelFactory modelFactory = new AgentScopeModelFactory() {
@Override
public Model create(AgentModelSpec modelSpec,
com.easyagents.agent.runtime.model.AgentGenerationOptions generationOptions) {
return new Model() {
@Override
public Flux<ChatResponse> stream(List<Msg> messages,
List<ToolSchema> toolSchemas,
GenerateOptions options) {
return Flux.error(error);
}
@Override
public String getModelName() {
return modelSpec == null ? "fake-model" : modelSpec.getModelName();
}
};
}
};
return new AgentScopeReActRuntime(modelFactory, new AgentScopeToolAdapter(),
new AgentScopeKnowledgeAdapter(), new AgentScopeMemoryAdapter(), new AgentScopeSkillAdapter(),
new AgentScopeMessageAdapter());
}
/**
* 创建每次模型调用仅返回下一条预设响应的运行时。
*

View File

@@ -0,0 +1,107 @@
package com.easyagents.agent.runtime.agentscope;
import com.easyagents.agent.runtime.AgentDefinition;
import com.easyagents.agent.runtime.AgentRuntimeExecutionContext;
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
import com.easyagents.agent.runtime.tool.AgentToolResult;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.ToolCallParam;
import org.junit.Assert;
import org.junit.Test;
import java.lang.reflect.Proxy;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
/**
* {@link AgentScopeToolAdapter} 回归测试。
*/
public class AgentScopeToolAdapterTest {
/**
* 验证 Reactor 工作线程执行工具时使用调用器所属类加载器,并在结束后恢复原上下文。
*/
@Test
public void shouldUseInvokerClassLoaderAndRestoreWorkerContext() {
ClassLoader parentClassLoader = AgentToolInvoker.class.getClassLoader();
ClassLoader invocationClassLoader = new ClassLoader(parentClassLoader) {
};
AtomicReference<ClassLoader> observedClassLoader = new AtomicReference<>();
AgentToolInvoker invoker = (AgentToolInvoker) Proxy.newProxyInstance(
invocationClassLoader,
new Class<?>[]{AgentToolInvoker.class},
(proxy, method, arguments) -> {
observedClassLoader.set(Thread.currentThread().getContextClassLoader());
return AgentToolResult.success("ok");
});
AgentTool tool = new AgentScopeToolAdapter().adapt(toolSpec(), invoker, executionContext());
Thread currentThread = Thread.currentThread();
ClassLoader originalClassLoader = currentThread.getContextClassLoader();
ClassLoader workerClassLoader = new ClassLoader(originalClassLoader) {
};
try {
currentThread.setContextClassLoader(workerClassLoader);
ToolResultBlock result = tool.callAsync(toolCall()).block();
Assert.assertNotNull(result);
Assert.assertSame(invocationClassLoader, observedClassLoader.get());
Assert.assertSame(workerClassLoader, currentThread.getContextClassLoader());
} finally {
currentThread.setContextClassLoader(originalClassLoader);
}
}
/**
* 创建测试工具声明。
*
* @return 工具声明
*/
private AgentToolSpec toolSpec() {
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName("test_tool");
toolSpec.setDescription("Test tool");
toolSpec.setParametersSchema(Map.of(
"type", "object",
"properties", Map.of(),
"additionalProperties", false));
return toolSpec;
}
/**
* 创建测试运行上下文。
*
* @return 运行上下文
*/
private AgentRuntimeExecutionContext executionContext() {
AgentDefinition definition = new AgentDefinition();
definition.setAgentId("agent-1");
AgentRuntimeExecutionContext context = new AgentRuntimeExecutionContext();
context.setAgentDefinition(definition);
context.setRequestId("request-1");
context.setTraceId("trace-1");
context.setSessionId("session-1");
return context;
}
/**
* 创建测试工具调用参数。
*
* @return 工具调用参数
*/
private ToolCallParam toolCall() {
ToolUseBlock toolUseBlock = ToolUseBlock.builder()
.id("call-1")
.name("test_tool")
.input(Map.of())
.content("{}")
.build();
return ToolCallParam.builder()
.toolUseBlock(toolUseBlock)
.input(Map.of())
.build();
}
}

View File

@@ -72,6 +72,32 @@ public class HeuristicKnowledgeCitationMatcherTest {
Assert.assertTrue(references.isEmpty());
}
/**
* 长篇多主题汇总回答应按独立条目匹配引用,避免整篇答案稀释局部证据。
*/
@Test
public void shouldMatchReferencesFromLongMultiTopicAnswer() {
AgentKnowledgeReference checkIn = reference("faq-check-in",
"问题:最早入住酒店时间说明 答案最早入住时间为入住日当天下午14:00提前到店按房间状况安排。");
AgentKnowledgeReference luggage = reference("faq-luggage",
"问题:酒店寄存行李服务说明 答案离店客人通常可免费寄存2天第3天起收费。");
AgentKnowledgeReference unrelated = reference("faq-unrelated",
"问题:会员卡如何补办 答案:请携带本人证件到指定服务网点申请补办。");
String answer = "根据知识库整理,主要主题如下:\n"
+ "一、预订与支付官方渠道包括APP、微信小程序和客服电话。\n"
+ "二、入住与退房最早入住时间为下午14:00提前到店按房态安排。\n"
+ "三、酒店服务离店后行李通常可免费寄存2天第3天起收费。\n"
+ "四、会员商城:可使用彩虹如愿豆兑换商品。";
List<AgentKnowledgeReference> references = matcher.match(
answer, List.of(unrelated, checkIn, luggage));
Assert.assertEquals(2, references.size());
Assert.assertTrue(references.stream().anyMatch(reference -> "faq-check-in".equals(reference.getDocumentId())));
Assert.assertTrue(references.stream().anyMatch(reference -> "faq-luggage".equals(reference.getDocumentId())));
}
/**
* 空答案或空候选不应返回引用。
*/

View File

@@ -0,0 +1,149 @@
package com.easyagents.agent.runtime.prompt;
import com.easyagents.agent.runtime.AgentDefinition;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import org.junit.Assert;
import org.junit.Test;
import java.util.List;
/**
* 测试运行时系统提示词组合器。
*/
public class SystemPromptComposerTest {
@Test
public void shouldKeepUserPromptWhenNoRuntimeProtocolIsRequired() {
AgentDefinition definition = definition(" user prompt ");
Assert.assertEquals(" user prompt ", SystemPromptComposer.compose(definition));
}
@Test
public void shouldReturnKnowledgeProtocolWhenUserPromptIsBlank() {
AgentDefinition definition = definition(" ");
definition.setKnowledgeSpecs(List.of(knowledge("faq")));
String prompt = SystemPromptComposer.compose(definition);
Assert.assertTrue(prompt.startsWith("Knowledge tool protocol:"));
Assert.assertFalse(prompt.startsWith("\n"));
}
@Test
public void shouldAppendKnowledgeProtocolAfterUserPrompt() {
AgentDefinition definition = definition("user prompt");
definition.setKnowledgeSpecs(List.of(knowledge("faq")));
String prompt = SystemPromptComposer.compose(definition);
Assert.assertTrue(prompt.startsWith("user prompt\n\nKnowledge tool protocol:"));
Assert.assertTrue(prompt.contains("prefer making one retrieval call"));
Assert.assertTrue(prompt.contains("reformulate the query and retry once"));
}
@Test
public void shouldAppendKnowledgeProtocolOnlyOnceForMultipleKnowledgeTools() {
AgentDefinition definition = definition("user prompt");
definition.setKnowledgeSpecs(List.of(knowledge("faq"), knowledge("policy")));
String prompt = SystemPromptComposer.compose(definition);
Assert.assertEquals(1, occurrences(prompt, "Knowledge tool protocol:"));
}
@Test
public void shouldSkipKnowledgeProtocolWhenToolCallingIsDisabled() {
AgentDefinition definition = definition("user prompt");
definition.setKnowledgeSpecs(List.of(knowledge("faq")));
definition.getExecutionOptions().setToolCallingEnabled(false);
Assert.assertEquals("user prompt", SystemPromptComposer.compose(definition));
}
@Test
public void shouldPreserveAsyncToolProtocolBehavior() {
AgentDefinition definition = definition(null);
definition.setToolSpecs(List.of(asyncTool()));
String prompt = SystemPromptComposer.compose(definition);
Assert.assertTrue(prompt.startsWith("Async tool protocol:"));
Assert.assertTrue(prompt.contains("These are internal execution phases."));
Assert.assertTrue(prompt.contains("call its submit sub-tool first with the user-provided arguments by default"));
Assert.assertTrue(prompt.contains("immediately call observe"));
}
@Test
public void shouldComposeUserKnowledgeAndAsyncProtocolsInStableOrder() {
AgentDefinition definition = definition("user prompt");
definition.setKnowledgeSpecs(List.of(knowledge("faq")));
definition.setToolSpecs(List.of(asyncTool()));
String prompt = SystemPromptComposer.compose(definition);
int userIndex = prompt.indexOf("user prompt");
int knowledgeIndex = prompt.indexOf("Knowledge tool protocol:");
int asyncIndex = prompt.indexOf("Async tool protocol:");
Assert.assertTrue(userIndex >= 0);
Assert.assertTrue(knowledgeIndex > userIndex);
Assert.assertTrue(asyncIndex > knowledgeIndex);
Assert.assertEquals(1, occurrences(prompt, "Knowledge tool protocol:"));
Assert.assertEquals(1, occurrences(prompt, "Async tool protocol:"));
}
/**
* 创建测试智能体定义。
*
* @param systemPrompt 用户系统提示词
* @return 智能体定义
*/
private AgentDefinition definition(String systemPrompt) {
AgentDefinition definition = new AgentDefinition();
definition.setSystemPrompt(systemPrompt);
return definition;
}
/**
* 创建测试知识库定义。
*
* @param runtimeName 知识库运行名
* @return 知识库定义
*/
private AgentKnowledgeSpec knowledge(String runtimeName) {
AgentKnowledgeSpec spec = new AgentKnowledgeSpec();
spec.setKnowledgeId("knowledge-" + runtimeName);
spec.setRuntimeName(runtimeName);
return spec;
}
/**
* 创建测试异步工具定义。
*
* @return 异步工具定义
*/
private AgentToolSpec asyncTool() {
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName("demo_submit");
toolSpec.getMetadata().put("asyncTool", true);
return toolSpec;
}
/**
* 统计文本片段出现次数。
*
* @param value 待检查文本
* @param fragment 目标片段
* @return 出现次数
*/
private int occurrences(String value, String fragment) {
int count = 0;
int offset = 0;
while ((offset = value.indexOf(fragment, offset)) >= 0) {
count++;
offset += fragment.length();
}
return count;
}
}

View File

@@ -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>

View 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 数和总时限设置硬上限。

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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) {
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1 @@
com.easyagents.federation.sql.adapter.jdbc.JdbcFederationSqlAdapterProvider

View File

@@ -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;
}
}

View File

@@ -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()
);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
};
}
}

View File

@@ -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()));
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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 {
}

View File

@@ -0,0 +1,13 @@
package com.easyagents.federation.sql.adapter;
/**
* 数据库 Adapter 兼容性证据状态。
*/
public enum AdapterCompatibilityStatus {
/** 已通过目标数据库真实集成验证。 */
VERIFIED,
/** 代码与契约已支持,缺少目标环境验证。 */
CODE_SUPPORTED_UNVERIFIED,
/** 当前 Adapter 明确不支持。 */
UNSUPPORTED
}

View File

@@ -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
) {
}

View File

@@ -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"));
}
}

View File

@@ -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
) {
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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");
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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
);
}
}
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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");
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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"
);
}
}

View File

@@ -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
);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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");
}
}

View File

@@ -0,0 +1,13 @@
package com.easyagents.federation.sql.compile;
/**
* Explain 深度。
*/
public enum SqlExplainLevel {
/** 只生成 Calcite 逻辑计划和物理分片 SQL不访问数据库 Optimizer。 */
LOGICAL,
/** 在逻辑计划基础上显式请求各物理数据库的非 ANALYZE Explain。 */
PHYSICAL
}

View File

@@ -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());
}
}

View File

@@ -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
);
}
}

View File

@@ -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
) {
}

View File

@@ -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 {
}

View File

@@ -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() {
}
}
}

View File

@@ -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() {
};
}
}

View File

@@ -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()
);
}
}

View File

@@ -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);
}

View File

@@ -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()
);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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 {
}

View File

@@ -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
);
}
}

View File

@@ -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 () -> { };
}
};
}
}

View File

@@ -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()
);
}
}

View File

@@ -0,0 +1,14 @@
package com.easyagents.federation.sql.execute;
/**
* 查询准入许可,关闭时释放并发配额。
*/
@FunctionalInterface
public interface FederationQueryPermit extends AutoCloseable {
/**
* 释放准入许可。
*/
@Override
void close();
}

View File

@@ -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();
}

View File

@@ -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()
);
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
);
}
}

View File

@@ -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)
);
}
}

View File

@@ -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()
);
}
}

View File

@@ -0,0 +1,10 @@
package com.easyagents.federation.sql.federation;
/**
* 联邦本地 Join 的执行算法。
*/
public enum FederationJoinAlgorithm {
/** 在构建侧建立哈希表后探测。 */
HASH_JOIN
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,16 @@
package com.easyagents.federation.sql.federation;
/**
* Join 构建侧的选择原因。
*/
public enum FederationJoinSelectionReason {
/** 可信表级统计表明当前构建侧搬运量更小。 */
SMALLER_BUILD_SIDE,
/** 外连接语义要求保留输入顺序。 */
JOIN_SEMANTICS,
/** 统计不完整,保留稳定默认顺序。 */
INCOMPLETE_STATISTICS
}

View File

@@ -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");
}
}
}

View File

@@ -0,0 +1,11 @@
package com.easyagents.federation.sql.federation;
/**
* 根据 SQL 实际引用物理数据源数量确定的查询模式。
*/
public enum FederationQueryMode {
/** 单一物理数据源完整下推。 */
SINGLE_SOURCE,
/** 多物理数据源分片下推并由 Core 合并。 */
FEDERATED
}

View File

@@ -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());
}
}

View File

@@ -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
);
}
}

View File

@@ -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");
}
}
}

View File

@@ -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);
}
}
}

Some files were not shown because too many files have changed in this diff Show More