perf: 收敛工作流状态与高 IO 节点开销
- 落地 Redis 版本状态、触发租约和定义缓存 - 优化数据批写、插件请求、文件下载与审计日志 - 补齐循环范围校验、轮询兼容和专项测试
This commit is contained in:
@@ -1,182 +1,461 @@
|
|||||||
package tech.easyflow.common.ai.plugin;
|
package tech.easyflow.common.ai.plugin;
|
||||||
|
|
||||||
import cn.hutool.core.util.ObjectUtil;
|
|
||||||
import cn.hutool.http.*;
|
|
||||||
import cn.hutool.json.JSONObject;
|
import cn.hutool.json.JSONObject;
|
||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import okhttp3.Dispatcher;
|
||||||
|
import okhttp3.HttpUrl;
|
||||||
|
import okhttp3.MediaType;
|
||||||
|
import okhttp3.MultipartBody;
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.Request;
|
||||||
|
import okhttp3.RequestBody;
|
||||||
|
import okhttp3.Response;
|
||||||
|
import okhttp3.ResponseBody;
|
||||||
|
import okio.BufferedSink;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.util.*;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.Semaphore;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
public class PluginHttpClient {
|
/**
|
||||||
|
* 使用共享连接池、有界并发和实际响应字节限制调用 HTTP 插件。
|
||||||
|
*/
|
||||||
|
public final class PluginHttpClient {
|
||||||
|
|
||||||
private static final int TIMEOUT = 10_000;
|
private static final int TIMEOUT_MILLIS = 10_000;
|
||||||
|
private static final long DEFAULT_MAX_RESPONSE_BYTES =
|
||||||
|
64L * 1024L * 1024L;
|
||||||
|
private static final int MAX_TRACKED_HOSTS = 512;
|
||||||
|
private static final Semaphore GLOBAL_PERMITS =
|
||||||
|
new Semaphore(32, true);
|
||||||
|
private static final Semaphore OVERFLOW_HOST_PERMITS =
|
||||||
|
new Semaphore(8, true);
|
||||||
|
private static final Map<String, Semaphore> HOST_PERMITS =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
private static final Object HOST_REGISTRY_LOCK = new Object();
|
||||||
|
private static final OkHttpClient CLIENT = buildClient(true);
|
||||||
|
private static final OkHttpClient NO_RETRY_CLIENT = buildClient(false);
|
||||||
|
|
||||||
public static JSONObject sendRequest(String url, String method,
|
private PluginHttpClient() {
|
||||||
Map<String, Object> headers,
|
|
||||||
List<PluginParam> pluginParams) {
|
|
||||||
// 1. 处理路径参数
|
|
||||||
String processedUrl = replacePathVariables(url, pluginParams);
|
|
||||||
|
|
||||||
// 2. 初始化请求
|
|
||||||
Method httpMethod = Method.valueOf(method.toUpperCase());
|
|
||||||
HttpRequest request = HttpRequest.of(processedUrl)
|
|
||||||
.method(httpMethod)
|
|
||||||
.timeout(TIMEOUT);
|
|
||||||
// 3. 处理请求头(合并默认头和参数头)
|
|
||||||
processHeaders(request, headers, pluginParams);
|
|
||||||
|
|
||||||
// 4. 处理查询参数和请求体
|
|
||||||
processQueryAndBodyParams(request, httpMethod, pluginParams);
|
|
||||||
|
|
||||||
// 5. 执行请求
|
|
||||||
HttpResponse response = request.execute();
|
|
||||||
return JSONUtil.parseObj(response.body());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理请求头(合并默认头和参数头)
|
* 发送插件请求。
|
||||||
|
*
|
||||||
|
* @param url 插件 URL
|
||||||
|
* @param method HTTP 方法
|
||||||
|
* @param headers 默认请求头
|
||||||
|
* @param pluginParams 插件参数
|
||||||
|
* @return JSON 响应
|
||||||
*/
|
*/
|
||||||
private static void processHeaders(HttpRequest request,
|
public static JSONObject sendRequest(
|
||||||
Map<String, Object> defaultHeaders,
|
String url,
|
||||||
List<PluginParam> params) {
|
String method,
|
||||||
// 添加默认头
|
Map<String, Object> headers,
|
||||||
if (ObjectUtil.isNotEmpty(defaultHeaders)) {
|
List<PluginParam> pluginParams) {
|
||||||
defaultHeaders.forEach((k, v) -> request.header(k, v.toString()));
|
String normalizedMethod = method == null
|
||||||
}
|
? "GET"
|
||||||
|
: method.trim().toUpperCase();
|
||||||
|
List<PluginParam> safeParams = pluginParams == null
|
||||||
|
? List.of()
|
||||||
|
: pluginParams;
|
||||||
|
HttpUrl processedUrl = buildUrl(url, safeParams);
|
||||||
|
Request.Builder requestBuilder = new Request.Builder().url(processedUrl);
|
||||||
|
applyHeaders(requestBuilder, headers, safeParams);
|
||||||
|
RequestBody requestBody = buildRequestBody(
|
||||||
|
normalizedMethod, safeParams);
|
||||||
|
applyMethod(requestBuilder, normalizedMethod, requestBody);
|
||||||
|
|
||||||
// 添加参数中指定的头
|
Semaphore hostPermits = hostSemaphore(processedUrl.host());
|
||||||
params.stream()
|
boolean hostAcquired = false;
|
||||||
.filter(p -> "header".equalsIgnoreCase(p.getMethod()) && p.isEnabled())
|
boolean globalAcquired = false;
|
||||||
.forEach(p -> request.header(p.getName(), p.getDefaultValue().toString()));
|
try {
|
||||||
}
|
hostAcquired = hostPermits.tryAcquire(
|
||||||
|
1L, TimeUnit.SECONDS);
|
||||||
/**
|
if (!hostAcquired) {
|
||||||
* 处理查询参数和请求体
|
throw new IllegalStateException(
|
||||||
*/
|
"插件目标并发已达上限: " + processedUrl.host());
|
||||||
/**
|
}
|
||||||
* 处理查询参数和请求体(新增文件参数支持)
|
globalAcquired = GLOBAL_PERMITS.tryAcquire(
|
||||||
*/
|
1L, TimeUnit.SECONDS);
|
||||||
private static void processQueryAndBodyParams(HttpRequest request,
|
if (!globalAcquired) {
|
||||||
Method httpMethod,
|
throw new IllegalStateException(
|
||||||
List<PluginParam> params) {
|
"插件 HTTP 并发已达上限");
|
||||||
Map<String, Object> queryParams = new HashMap<>();
|
}
|
||||||
Map<String, Object> bodyParams = new HashMap<>();
|
OkHttpClient client = isIdempotent(normalizedMethod)
|
||||||
// 标记是否包含文件参数
|
? CLIENT
|
||||||
AtomicBoolean hasMultipartFile = new AtomicBoolean(false);
|
: NO_RETRY_CLIENT;
|
||||||
|
try (Response response =
|
||||||
// 分类参数(同时检测是否有文件)
|
client.newCall(requestBuilder.build()).execute()) {
|
||||||
params.stream()
|
ResponseBody body = response.body();
|
||||||
.filter(PluginParam::isEnabled)
|
if (body == null) {
|
||||||
.forEach(p -> {
|
throw new IllegalStateException("插件响应内容为空");
|
||||||
String methodType = p.getMethod().toLowerCase();
|
}
|
||||||
Object paramValue = buildNestedParamValue(p);
|
long maxBytes = Long.getLong(
|
||||||
|
"easyflow.plugin.http.max-response-bytes",
|
||||||
// 检测是否为文件参数(MultipartFile 类型)
|
DEFAULT_MAX_RESPONSE_BYTES);
|
||||||
if (paramValue instanceof org.springframework.web.multipart.MultipartFile) {
|
String responseText = readBounded(body, maxBytes);
|
||||||
hasMultipartFile.set(true);
|
return JSONUtil.parseObj(responseText);
|
||||||
}
|
}
|
||||||
|
} catch (InterruptedException error) {
|
||||||
switch (methodType) {
|
Thread.currentThread().interrupt();
|
||||||
case "query":
|
throw new IllegalStateException(
|
||||||
queryParams.put(p.getName(), paramValue);
|
"插件 HTTP 请求被中断", error);
|
||||||
break;
|
} catch (IOException error) {
|
||||||
case "body":
|
throw new IllegalStateException(
|
||||||
bodyParams.put(p.getName(), paramValue);
|
"插件 HTTP 请求失败", error);
|
||||||
break;
|
} finally {
|
||||||
}
|
if (globalAcquired) {
|
||||||
});
|
GLOBAL_PERMITS.release();
|
||||||
|
}
|
||||||
// 1. 设置查询参数(原有逻辑不变)
|
if (hostAcquired) {
|
||||||
if (!queryParams.isEmpty()) {
|
hostPermits.release();
|
||||||
request.form(queryParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 设置请求体(分两种情况:有文件 vs 无文件)
|
|
||||||
if (!bodyParams.isEmpty() && (httpMethod == Method.POST || httpMethod == Method.PUT)) {
|
|
||||||
if (hasMultipartFile.get()) {
|
|
||||||
// 2.1 包含文件参数 → 用 multipart/form-data 格式
|
|
||||||
processMultipartBody(request, bodyParams);
|
|
||||||
} else {
|
|
||||||
// 2.2 无文件参数 → 保持原有 JSON 格式
|
|
||||||
request.body(JSONUtil.toJsonStr(bodyParams))
|
|
||||||
.header(Header.CONTENT_TYPE, ContentType.JSON.getValue());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 递归构建嵌套参数值
|
* 创建共享 OkHttp 客户端。
|
||||||
* @param param 当前参数
|
*
|
||||||
* @return 如果是 Object 类型,返回 Map;否则返回 defaultValue
|
* @param retryOnConnectionFailure 是否允许连接级自动恢复
|
||||||
|
* @return 共享客户端
|
||||||
*/
|
*/
|
||||||
private static Object buildNestedParamValue(PluginParam param) {
|
private static OkHttpClient buildClient(
|
||||||
// 如果不是 Object 类型,直接返回默认值
|
boolean retryOnConnectionFailure) {
|
||||||
|
Dispatcher dispatcher = new Dispatcher();
|
||||||
|
dispatcher.setMaxRequests(32);
|
||||||
|
dispatcher.setMaxRequestsPerHost(8);
|
||||||
|
return new OkHttpClient.Builder()
|
||||||
|
.dispatcher(dispatcher)
|
||||||
|
.connectTimeout(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
|
||||||
|
.readTimeout(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
|
||||||
|
.writeTimeout(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
|
||||||
|
.callTimeout(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
|
||||||
|
.retryOnConnectionFailure(retryOnConnectionFailure)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造带路径参数和查询参数的 URL。
|
||||||
|
*
|
||||||
|
* @param url 原始 URL
|
||||||
|
* @param params 插件参数
|
||||||
|
* @return 完整 URL
|
||||||
|
*/
|
||||||
|
private static HttpUrl buildUrl(
|
||||||
|
String url, List<PluginParam> params) {
|
||||||
|
String processed = replacePathVariables(url, params);
|
||||||
|
HttpUrl parsed = HttpUrl.parse(processed);
|
||||||
|
if (parsed == null) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"插件 URL 不合法: " + processed);
|
||||||
|
}
|
||||||
|
HttpUrl.Builder builder = parsed.newBuilder();
|
||||||
|
for (PluginParam param : params) {
|
||||||
|
if (param.isEnabled()
|
||||||
|
&& "query".equalsIgnoreCase(param.getMethod())
|
||||||
|
&& param.getDefaultValue() != null) {
|
||||||
|
builder.addQueryParameter(
|
||||||
|
param.getName(),
|
||||||
|
stringify(param.getDefaultValue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并默认请求头和参数请求头。
|
||||||
|
*
|
||||||
|
* @param builder 请求构造器
|
||||||
|
* @param headers 默认请求头
|
||||||
|
* @param params 插件参数
|
||||||
|
*/
|
||||||
|
private static void applyHeaders(
|
||||||
|
Request.Builder builder,
|
||||||
|
Map<String, Object> headers,
|
||||||
|
List<PluginParam> params) {
|
||||||
|
if (headers != null) {
|
||||||
|
headers.forEach((name, value) -> {
|
||||||
|
if (name != null && value != null) {
|
||||||
|
builder.header(name, String.valueOf(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (PluginParam param : params) {
|
||||||
|
if (param.isEnabled()
|
||||||
|
&& "header".equalsIgnoreCase(param.getMethod())
|
||||||
|
&& param.getDefaultValue() != null) {
|
||||||
|
builder.header(
|
||||||
|
param.getName(),
|
||||||
|
String.valueOf(param.getDefaultValue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造 JSON 或 multipart 请求体。
|
||||||
|
*
|
||||||
|
* @param method HTTP 方法
|
||||||
|
* @param params 插件参数
|
||||||
|
* @return 请求体;无请求体时为 {@code null}
|
||||||
|
*/
|
||||||
|
private static RequestBody buildRequestBody(
|
||||||
|
String method, List<PluginParam> params) {
|
||||||
|
if (!supportsRequestBody(method)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, Object> bodyValues = new HashMap<>();
|
||||||
|
boolean multipart = false;
|
||||||
|
for (PluginParam param : params) {
|
||||||
|
if (!param.isEnabled()
|
||||||
|
|| !"body".equalsIgnoreCase(param.getMethod())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Object value = buildNestedParamValue(param);
|
||||||
|
bodyValues.put(param.getName(), value);
|
||||||
|
multipart |= value instanceof MultipartFile;
|
||||||
|
}
|
||||||
|
if (bodyValues.isEmpty()) {
|
||||||
|
return RequestBody.create(
|
||||||
|
new byte[0], null);
|
||||||
|
}
|
||||||
|
if (!multipart) {
|
||||||
|
return RequestBody.create(
|
||||||
|
JSONUtil.toJsonStr(bodyValues),
|
||||||
|
MediaType.parse("application/json; charset=utf-8"));
|
||||||
|
}
|
||||||
|
MultipartBody.Builder builder =
|
||||||
|
new MultipartBody.Builder().setType(MultipartBody.FORM);
|
||||||
|
for (Map.Entry<String, Object> entry : bodyValues.entrySet()) {
|
||||||
|
Object value = entry.getValue();
|
||||||
|
if (value instanceof MultipartFile) {
|
||||||
|
MultipartFile file = (MultipartFile) value;
|
||||||
|
MediaType contentType = MediaType.parse(
|
||||||
|
file.getContentType() == null
|
||||||
|
? "application/octet-stream"
|
||||||
|
: file.getContentType());
|
||||||
|
builder.addFormDataPart(
|
||||||
|
entry.getKey(),
|
||||||
|
Objects.toString(
|
||||||
|
file.getOriginalFilename(),
|
||||||
|
entry.getKey()),
|
||||||
|
streamingFileBody(file, contentType));
|
||||||
|
} else {
|
||||||
|
builder.addFormDataPart(
|
||||||
|
entry.getKey(), stringify(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建从 MultipartFile 流式读取的请求体,避免 getBytes 二次复制。
|
||||||
|
*
|
||||||
|
* @param file 文件参数
|
||||||
|
* @param mediaType 内容类型
|
||||||
|
* @return 流式请求体
|
||||||
|
*/
|
||||||
|
private static RequestBody streamingFileBody(
|
||||||
|
MultipartFile file, MediaType mediaType) {
|
||||||
|
return new RequestBody() {
|
||||||
|
@Override
|
||||||
|
public MediaType contentType() {
|
||||||
|
return mediaType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long contentLength() {
|
||||||
|
return file.getSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(BufferedSink sink) throws IOException {
|
||||||
|
try (InputStream inputStream = file.getInputStream()) {
|
||||||
|
byte[] buffer = new byte[64 * 1024];
|
||||||
|
int read;
|
||||||
|
while ((read = inputStream.read(buffer)) != -1) {
|
||||||
|
sink.write(buffer, 0, read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用 HTTP 方法。
|
||||||
|
*
|
||||||
|
* @param builder 请求构造器
|
||||||
|
* @param method HTTP 方法
|
||||||
|
* @param body 请求体
|
||||||
|
*/
|
||||||
|
private static void applyMethod(
|
||||||
|
Request.Builder builder,
|
||||||
|
String method,
|
||||||
|
RequestBody body) {
|
||||||
|
if ("GET".equals(method)) {
|
||||||
|
builder.get();
|
||||||
|
} else if ("HEAD".equals(method)) {
|
||||||
|
builder.head();
|
||||||
|
} else if ("DELETE".equals(method) && body == null) {
|
||||||
|
builder.delete();
|
||||||
|
} else {
|
||||||
|
builder.method(method, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按实际字节读取响应。
|
||||||
|
*
|
||||||
|
* @param body 响应体
|
||||||
|
* @param maxBytes 最大字节数
|
||||||
|
* @return UTF-8 响应
|
||||||
|
* @throws IOException 读取失败或响应超限
|
||||||
|
*/
|
||||||
|
private static String readBounded(
|
||||||
|
ResponseBody body, long maxBytes) throws IOException {
|
||||||
|
if (maxBytes > 0L
|
||||||
|
&& body.contentLength() > maxBytes) {
|
||||||
|
throw new IOException(
|
||||||
|
"插件响应超过字节上限: " + maxBytes);
|
||||||
|
}
|
||||||
|
try (InputStream inputStream = body.byteStream();
|
||||||
|
ByteArrayOutputStream outputStream =
|
||||||
|
new ByteArrayOutputStream(8 * 1024)) {
|
||||||
|
byte[] buffer = new byte[64 * 1024];
|
||||||
|
long total = 0L;
|
||||||
|
int read;
|
||||||
|
while ((read = inputStream.read(buffer)) != -1) {
|
||||||
|
total += read;
|
||||||
|
if (maxBytes > 0L && total > maxBytes) {
|
||||||
|
throw new IOException(
|
||||||
|
"插件响应超过字节上限: " + maxBytes);
|
||||||
|
}
|
||||||
|
outputStream.write(buffer, 0, read);
|
||||||
|
}
|
||||||
|
return outputStream.toString(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归构造对象参数。
|
||||||
|
*
|
||||||
|
* @param param 参数
|
||||||
|
* @return 参数值
|
||||||
|
*/
|
||||||
|
private static Object buildNestedParamValue(
|
||||||
|
PluginParam param) {
|
||||||
if (!"Object".equalsIgnoreCase(param.getType())) {
|
if (!"Object".equalsIgnoreCase(param.getType())) {
|
||||||
return param.getDefaultValue();
|
return param.getDefaultValue();
|
||||||
}
|
}
|
||||||
|
Map<String, Object> nested = new HashMap<>();
|
||||||
// 如果是 Object 类型,递归处理子参数
|
|
||||||
Map<String, Object> nestedParams = new HashMap<>();
|
|
||||||
if (param.getChildren() != null) {
|
if (param.getChildren() != null) {
|
||||||
param.getChildren().stream()
|
for (PluginParam child : param.getChildren()) {
|
||||||
.filter(PluginParam::isEnabled)
|
if (child.isEnabled()) {
|
||||||
.forEach(child -> {
|
nested.put(
|
||||||
Object childValue = buildNestedParamValue(child); // 递归处理子参数
|
child.getName(),
|
||||||
nestedParams.put(child.getName(), childValue);
|
buildNestedParamValue(child));
|
||||||
});
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nestedParams;
|
return nested;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 替换URL中的路径变量 {xxx}
|
* 替换 URL 路径变量。
|
||||||
|
*
|
||||||
|
* @param url 原始 URL
|
||||||
|
* @param params 插件参数
|
||||||
|
* @return 替换后的 URL
|
||||||
*/
|
*/
|
||||||
private static String replacePathVariables(String url, List<PluginParam> params) {
|
private static String replacePathVariables(
|
||||||
String result = url;
|
String url, List<PluginParam> params) {
|
||||||
|
String result = Objects.requireNonNull(
|
||||||
// 收集路径参数
|
url, "插件 URL 不能为空");
|
||||||
Map<String, Object> pathParams = new HashMap<>();
|
for (PluginParam param : params) {
|
||||||
params.stream()
|
if (param.isEnabled()
|
||||||
.filter(p -> "path".equalsIgnoreCase(p.getMethod()) && p.isEnabled())
|
&& "path".equalsIgnoreCase(param.getMethod())
|
||||||
.forEach(p -> pathParams.put(p.getName(), p.getDefaultValue()));
|
&& param.getDefaultValue() != null) {
|
||||||
|
result = result.replace(
|
||||||
// 替换变量
|
"{" + param.getName() + "}",
|
||||||
for (Map.Entry<String, Object> entry : pathParams.entrySet()) {
|
String.valueOf(param.getDefaultValue()));
|
||||||
result = result.replaceAll("\\{" + entry.getKey() + "\\}",
|
}
|
||||||
entry.getValue().toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void processMultipartBody(HttpRequest request, Map<String, Object> bodyParams) {
|
/**
|
||||||
// 手动设置 Content-Type 为 multipart/form-data
|
* 获取主机隔离许可并严格限制注册表体积。
|
||||||
request.header(Header.CONTENT_TYPE, "multipart/form-data");
|
*
|
||||||
for (Map.Entry<String, Object> entry : bodyParams.entrySet()) {
|
* @param host 主机名
|
||||||
String paramName = entry.getKey();
|
* @return 主机许可
|
||||||
Object paramValue = entry.getValue();
|
*/
|
||||||
|
private static Semaphore hostSemaphore(String host) {
|
||||||
if (paramValue instanceof MultipartFile) {
|
Semaphore existing = HOST_PERMITS.get(host);
|
||||||
MultipartFile file = (MultipartFile) paramValue;
|
if (existing != null) {
|
||||||
try {
|
return existing;
|
||||||
request.form(paramName, file.getBytes(), file.getOriginalFilename());
|
}
|
||||||
} catch (Exception e) {
|
synchronized (HOST_REGISTRY_LOCK) {
|
||||||
throw new RuntimeException(String.format("文件参数处理失败:参数名=%s,文件名=%s",
|
existing = HOST_PERMITS.get(host);
|
||||||
paramName, file.getOriginalFilename()), e);
|
if (existing != null) {
|
||||||
}
|
return existing;
|
||||||
} else {
|
|
||||||
// 处理普通参数
|
|
||||||
String valueStr;
|
|
||||||
if (paramValue instanceof String || paramValue instanceof Number || paramValue instanceof Boolean) {
|
|
||||||
valueStr = paramValue.toString();
|
|
||||||
} else {
|
|
||||||
valueStr = JSONUtil.toJsonStr(paramValue);
|
|
||||||
}
|
|
||||||
request.form(paramName, valueStr);
|
|
||||||
}
|
}
|
||||||
|
if (HOST_PERMITS.size() >= MAX_TRACKED_HOSTS) {
|
||||||
|
return OVERFLOW_HOST_PERMITS;
|
||||||
|
}
|
||||||
|
Semaphore created = new Semaphore(8, true);
|
||||||
|
HOST_PERMITS.put(host, created);
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断方法是否幂等。
|
||||||
|
*
|
||||||
|
* @param method HTTP 方法
|
||||||
|
* @return 是否允许连接级恢复
|
||||||
|
*/
|
||||||
|
private static boolean isIdempotent(String method) {
|
||||||
|
return "GET".equals(method)
|
||||||
|
|| "HEAD".equals(method)
|
||||||
|
|| "OPTIONS".equals(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断方法是否支持请求体。
|
||||||
|
*
|
||||||
|
* @param method HTTP 方法
|
||||||
|
* @return 是否支持请求体
|
||||||
|
*/
|
||||||
|
private static boolean supportsRequestBody(String method) {
|
||||||
|
return "POST".equals(method)
|
||||||
|
|| "PUT".equals(method)
|
||||||
|
|| "PATCH".equals(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将普通或嵌套值转换为表单字符串。
|
||||||
|
*
|
||||||
|
* @param value 参数值
|
||||||
|
* @return 表单值
|
||||||
|
*/
|
||||||
|
private static String stringify(Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (value instanceof String
|
||||||
|
|| value instanceof Number
|
||||||
|
|| value instanceof Boolean) {
|
||||||
|
return String.valueOf(value);
|
||||||
|
}
|
||||||
|
return JSONUtil.toJsonStr(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,19 @@ public interface CacheKey {
|
|||||||
String CHAIN_STATUS_CACHE_KEY = "chain:status:";
|
String CHAIN_STATUS_CACHE_KEY = "chain:status:";
|
||||||
|
|
||||||
String CHAIN_CACHE_KEY = "chainState:";
|
String CHAIN_CACHE_KEY = "chainState:";
|
||||||
|
/**
|
||||||
|
* 工作流实例状态 CAS key 前缀;同实例状态使用 hash tag 保持同槽。
|
||||||
|
*/
|
||||||
|
String CHAIN_STATE_CAS_KEY = "workflowState:";
|
||||||
|
String CHAIN_DEFINITION_SNAPSHOT_CACHE_KEY = "chainDefinitionSnapshot:";
|
||||||
|
String CHAIN_LOCK_KEY = "chainLock:";
|
||||||
String NODE_CACHE_KEY = "nodeState:";
|
String NODE_CACHE_KEY = "nodeState:";
|
||||||
|
String LOOP_RESULT_CACHE_KEY = "loopResult:";
|
||||||
|
String TRIGGER_DATA_KEY = "workflowTrigger:data:";
|
||||||
|
String TRIGGER_CLAIM_KEY = "workflowTrigger:claim:";
|
||||||
|
String TRIGGER_DEAD_LETTER_KEY = "workflowTrigger:dead:";
|
||||||
|
String TRIGGER_PENDING_KEY = "workflowTrigger:pending";
|
||||||
|
String WORKFLOW_DEFINITION_VERSION_KEY = "workflowDefinition:version:";
|
||||||
|
|
||||||
String OAUTH_STATE_KEY = "oauth:state:";
|
String OAUTH_STATE_KEY = "oauth:state:";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package tech.easyflow.common.cache;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于 Redis owner token 的通用幂等执行器。
|
||||||
|
*
|
||||||
|
* <p>该执行器用于降低持久化触发器重复投递造成的副作用重复执行窗口。处理中凭证和完成
|
||||||
|
* 凭证均有界过期,异常时仅允许当前 owner 释放凭证。</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class RedisIdempotencyExecutor {
|
||||||
|
|
||||||
|
private static final Logger log =
|
||||||
|
LoggerFactory.getLogger(RedisIdempotencyExecutor.class);
|
||||||
|
private static final String KEY_PREFIX = "idempotency:";
|
||||||
|
private static final String PROCESSING_PREFIX = "P:";
|
||||||
|
private static final String COMPLETED_PREFIX = "C:";
|
||||||
|
private static final Duration DEFAULT_PROCESSING_TTL =
|
||||||
|
Duration.ofMinutes(5);
|
||||||
|
private static final Duration DEFAULT_COMPLETED_TTL =
|
||||||
|
Duration.ofDays(3);
|
||||||
|
private static final ScheduledExecutorService LEASE_RENEWER =
|
||||||
|
Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||||
|
Thread thread = new Thread(
|
||||||
|
runnable, "redis-idempotency-lease-renewer");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
|
private static final DefaultRedisScript<Long> CLAIM_SCRIPT = script(
|
||||||
|
"local current = redis.call('get', KEYS[1]); "
|
||||||
|
+ "if current == ARGV[3] then return 2 end; "
|
||||||
|
+ "if current and string.sub(current, 3, 66) ~= ARGV[4] then return -1 end; "
|
||||||
|
+ "if current then return 0 end; "
|
||||||
|
+ "redis.call('psetex', KEYS[1], ARGV[2], ARGV[1]); return 1");
|
||||||
|
private static final DefaultRedisScript<Long> COMPLETE_SCRIPT = script(
|
||||||
|
"if redis.call('get', KEYS[1]) == ARGV[1] then "
|
||||||
|
+ "redis.call('psetex', KEYS[1], ARGV[2], ARGV[3]); return 1 "
|
||||||
|
+ "else return 0 end");
|
||||||
|
private static final DefaultRedisScript<Long> RELEASE_SCRIPT = script(
|
||||||
|
"if redis.call('get', KEYS[1]) == ARGV[1] then "
|
||||||
|
+ "return redis.call('del', KEYS[1]) else return 0 end");
|
||||||
|
private static final DefaultRedisScript<Long> RENEW_SCRIPT = script(
|
||||||
|
"if redis.call('get', KEYS[1]) == ARGV[1] then "
|
||||||
|
+ "return redis.call('pexpire', KEYS[1], ARGV[2]) "
|
||||||
|
+ "else return 0 end");
|
||||||
|
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
private final Duration processingTtl;
|
||||||
|
private final Duration completedTtl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Redis 幂等执行器。
|
||||||
|
*
|
||||||
|
* @param redisTemplate Redis 字符串模板
|
||||||
|
*/
|
||||||
|
@Autowired
|
||||||
|
public RedisIdempotencyExecutor(StringRedisTemplate redisTemplate) {
|
||||||
|
this(
|
||||||
|
redisTemplate,
|
||||||
|
DEFAULT_PROCESSING_TTL,
|
||||||
|
DEFAULT_COMPLETED_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建可指定租约周期的 Redis 幂等执行器。
|
||||||
|
*
|
||||||
|
* @param redisTemplate Redis 字符串模板
|
||||||
|
* @param processingTtl 处理中 owner 租约周期
|
||||||
|
* @param completedTtl 完成凭证保留周期
|
||||||
|
*/
|
||||||
|
RedisIdempotencyExecutor(
|
||||||
|
StringRedisTemplate redisTemplate,
|
||||||
|
Duration processingTtl,
|
||||||
|
Duration completedTtl) {
|
||||||
|
this.redisTemplate = Objects.requireNonNull(
|
||||||
|
redisTemplate, "redisTemplate must not be null");
|
||||||
|
this.processingTtl = requirePositive(
|
||||||
|
processingTtl, "processingTtl");
|
||||||
|
this.completedTtl = requirePositive(
|
||||||
|
completedTtl, "completedTtl");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以稳定幂等键至多执行一次当前可观测操作。
|
||||||
|
*
|
||||||
|
* <p>返回 {@code false} 表示该键已有成功记录。另一个 owner 仍在执行时抛出明确异常,
|
||||||
|
* 由上层持久化触发器稍后重试,避免提前返回虚假成功。</p>
|
||||||
|
*
|
||||||
|
* @param idempotencyKey 稳定业务幂等键
|
||||||
|
* @param action 副作用操作
|
||||||
|
* @return 本次实际执行操作时为 {@code true},已有成功记录时为 {@code false}
|
||||||
|
* @throws IdempotentOperationInProgressException 同一操作仍由其他 owner 执行时抛出
|
||||||
|
* @throws IllegalStateException 操作完成后无法确认幂等凭证时抛出
|
||||||
|
*/
|
||||||
|
public boolean executeOnce(String idempotencyKey, Runnable action) {
|
||||||
|
return executeOnce(idempotencyKey, sha256(""), action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以稳定幂等键和负载摘要至多执行一次当前可观测操作。
|
||||||
|
*
|
||||||
|
* @param idempotencyKey 稳定业务幂等键
|
||||||
|
* @param payloadHash 负载摘要,用于拒绝同键不同数据
|
||||||
|
* @param action 副作用操作
|
||||||
|
* @return 本次实际执行操作时为 {@code true},已有成功记录时为 {@code false}
|
||||||
|
* @throws IdempotentOperationInProgressException 同一操作仍在执行
|
||||||
|
* @throws IdempotencyPayloadMismatchException 同一幂等键对应不同负载
|
||||||
|
*/
|
||||||
|
public boolean executeOnce(String idempotencyKey, String payloadHash, Runnable action) {
|
||||||
|
if (idempotencyKey == null || idempotencyKey.isBlank()) {
|
||||||
|
Objects.requireNonNull(action, "action must not be null").run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Objects.requireNonNull(action, "action must not be null");
|
||||||
|
String normalizedPayloadHash = Objects.requireNonNull(
|
||||||
|
payloadHash, "payloadHash must not be null");
|
||||||
|
String key = KEY_PREFIX + sha256(idempotencyKey);
|
||||||
|
String ownerToken = UUID.randomUUID().toString();
|
||||||
|
String processingValue = PROCESSING_PREFIX + normalizedPayloadHash + ":" + ownerToken;
|
||||||
|
String completedValue = COMPLETED_PREFIX + normalizedPayloadHash;
|
||||||
|
Long claim = redisTemplate.execute(
|
||||||
|
CLAIM_SCRIPT,
|
||||||
|
Collections.singletonList(key),
|
||||||
|
processingValue,
|
||||||
|
String.valueOf(processingTtl.toMillis()),
|
||||||
|
completedValue,
|
||||||
|
normalizedPayloadHash);
|
||||||
|
if (Long.valueOf(2L).equals(claim)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Long.valueOf(-1L).equals(claim)) {
|
||||||
|
throw new IdempotencyPayloadMismatchException(idempotencyKey);
|
||||||
|
}
|
||||||
|
if (!Long.valueOf(1L).equals(claim)) {
|
||||||
|
throw new IdempotentOperationInProgressException(idempotencyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
AtomicBoolean ownershipLost = new AtomicBoolean();
|
||||||
|
long renewIntervalMillis = Math.max(
|
||||||
|
1L, processingTtl.toMillis() / 3L);
|
||||||
|
ScheduledFuture<?> renewal = LEASE_RENEWER.scheduleAtFixedRate(
|
||||||
|
() -> renewLease(
|
||||||
|
key, processingValue, ownershipLost),
|
||||||
|
renewIntervalMillis,
|
||||||
|
renewIntervalMillis,
|
||||||
|
TimeUnit.MILLISECONDS);
|
||||||
|
try {
|
||||||
|
action.run();
|
||||||
|
} catch (RuntimeException | Error error) {
|
||||||
|
renewal.cancel(false);
|
||||||
|
redisTemplate.execute(
|
||||||
|
RELEASE_SCRIPT, Collections.singletonList(key), processingValue);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
renewal.cancel(false);
|
||||||
|
if (ownershipLost.get()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Idempotency ownership lost while operation was running");
|
||||||
|
}
|
||||||
|
Long completed = redisTemplate.execute(
|
||||||
|
COMPLETE_SCRIPT,
|
||||||
|
Collections.singletonList(key),
|
||||||
|
processingValue,
|
||||||
|
String.valueOf(completedTtl.toMillis()),
|
||||||
|
completedValue);
|
||||||
|
if (!Long.valueOf(1L).equals(completed)) {
|
||||||
|
// 副作用已经完成时保留处理中凭证,避免确认异常立即打开重复执行窗口。
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Idempotency ownership lost after operation completed");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 续期仍由当前 owner 持有的处理中凭证。
|
||||||
|
*
|
||||||
|
* @param key Redis 幂等键
|
||||||
|
* @param processingValue 当前 owner 完整凭证
|
||||||
|
* @param ownershipLost owner 丢失标记
|
||||||
|
*/
|
||||||
|
private void renewLease(
|
||||||
|
String key,
|
||||||
|
String processingValue,
|
||||||
|
AtomicBoolean ownershipLost) {
|
||||||
|
try {
|
||||||
|
Long renewed = redisTemplate.execute(
|
||||||
|
RENEW_SCRIPT,
|
||||||
|
Collections.singletonList(key),
|
||||||
|
processingValue,
|
||||||
|
String.valueOf(processingTtl.toMillis()));
|
||||||
|
if (!Long.valueOf(1L).equals(renewed)) {
|
||||||
|
ownershipLost.set(true);
|
||||||
|
}
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
// 短暂 Redis 故障由后续周期继续续期;最终完成脚本仍会校验 owner token。
|
||||||
|
log.warn("Redis idempotency lease renewal failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验正数时长配置。
|
||||||
|
*
|
||||||
|
* @param duration 配置值
|
||||||
|
* @param name 配置名
|
||||||
|
* @return 原配置值
|
||||||
|
*/
|
||||||
|
private Duration requirePositive(
|
||||||
|
Duration duration, String name) {
|
||||||
|
Duration value = Objects.requireNonNull(
|
||||||
|
duration, name + " must not be null");
|
||||||
|
if (value.isZero() || value.isNegative()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
name + " must be positive");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算有界 Redis key 摘要。
|
||||||
|
*
|
||||||
|
* @param value 原始幂等键
|
||||||
|
* @return SHA-256 十六进制摘要
|
||||||
|
*/
|
||||||
|
private String sha256(String value) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
return HexFormat.of().formatHex(digest.digest(
|
||||||
|
value.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException error) {
|
||||||
|
throw new IllegalStateException("SHA-256 is unavailable", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建返回 Long 的 Redis Lua 脚本。
|
||||||
|
*
|
||||||
|
* @param text 脚本文本
|
||||||
|
* @return Redis 脚本
|
||||||
|
*/
|
||||||
|
private static DefaultRedisScript<Long> script(String text) {
|
||||||
|
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
|
||||||
|
script.setScriptText(text);
|
||||||
|
script.setResultType(Long.class);
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一幂等操作仍在执行时的冲突异常。
|
||||||
|
*/
|
||||||
|
public static final class IdempotentOperationInProgressException extends IllegalStateException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建幂等执行冲突异常。
|
||||||
|
*
|
||||||
|
* @param idempotencyKey 冲突的业务幂等键
|
||||||
|
*/
|
||||||
|
public IdempotentOperationInProgressException(String idempotencyKey) {
|
||||||
|
super("Idempotent operation is already in progress: " + idempotencyKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同一幂等键被不同负载复用时的冲突异常。
|
||||||
|
*/
|
||||||
|
public static final class IdempotencyPayloadMismatchException extends IllegalStateException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建负载冲突异常。
|
||||||
|
*
|
||||||
|
* @param idempotencyKey 冲突的业务幂等键
|
||||||
|
*/
|
||||||
|
public IdempotencyPayloadMismatchException(String idempotencyKey) {
|
||||||
|
super("Idempotency payload mismatch: " + idempotencyKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ public class RedisLockExecutor {
|
|||||||
|
|
||||||
private static final DefaultRedisScript<Long> RELEASE_LOCK_SCRIPT;
|
private static final DefaultRedisScript<Long> RELEASE_LOCK_SCRIPT;
|
||||||
private static final DefaultRedisScript<Long> RENEW_LOCK_SCRIPT;
|
private static final DefaultRedisScript<Long> RENEW_LOCK_SCRIPT;
|
||||||
|
private static final DefaultRedisScript<Long> NEXT_FENCING_TOKEN_SCRIPT;
|
||||||
|
private static final DefaultRedisScript<Long> ACQUIRE_FENCED_LOCK_SCRIPT;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>();
|
RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>();
|
||||||
@@ -40,6 +42,19 @@ public class RedisLockExecutor {
|
|||||||
"else return 0 end"
|
"else return 0 end"
|
||||||
);
|
);
|
||||||
RENEW_LOCK_SCRIPT.setResultType(Long.class);
|
RENEW_LOCK_SCRIPT.setResultType(Long.class);
|
||||||
|
NEXT_FENCING_TOKEN_SCRIPT = new DefaultRedisScript<>();
|
||||||
|
NEXT_FENCING_TOKEN_SCRIPT.setScriptText(
|
||||||
|
"local token = redis.call('hincrby', KEYS[1], 'version', 1); " +
|
||||||
|
"redis.call('pexpire', KEYS[1], ARGV[1]); return token"
|
||||||
|
);
|
||||||
|
NEXT_FENCING_TOKEN_SCRIPT.setResultType(Long.class);
|
||||||
|
ACQUIRE_FENCED_LOCK_SCRIPT = new DefaultRedisScript<>();
|
||||||
|
ACQUIRE_FENCED_LOCK_SCRIPT.setScriptText(
|
||||||
|
"if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
|
||||||
|
+ "redis.call('psetex', KEYS[1], ARGV[2], ARGV[1]); "
|
||||||
|
+ "local token = redis.call('hincrby', KEYS[2], 'version', 1); "
|
||||||
|
+ "redis.call('pexpire', KEYS[2], ARGV[3]); return token");
|
||||||
|
ACQUIRE_FENCED_LOCK_SCRIPT.setResultType(Long.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@@ -132,7 +147,53 @@ public class RedisLockExecutor {
|
|||||||
if (!acquired) {
|
if (!acquired) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return new LockHandle(lockKey, lockValue, leaseTimeout);
|
return new LockHandle(lockKey, lockValue, leaseTimeout, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子获取互斥锁并分配 fencing token。
|
||||||
|
*
|
||||||
|
* <p>锁键和 fencing 键必须位于同一 Redis Cluster slot。成功的 SET 与 token
|
||||||
|
* 递增在同一 Lua 脚本内完成,消除新 owner 已取得锁但 token 尚未推进的窗口。</p>
|
||||||
|
*
|
||||||
|
* @param lockKey 互斥锁键
|
||||||
|
* @param fenceKey fencing token 哈希键
|
||||||
|
* @param waitTimeout 等待时间
|
||||||
|
* @param leaseTimeout 锁租约时间
|
||||||
|
* @param fenceTtl fencing token 有效期
|
||||||
|
* @return 获取成功时返回带 token 的锁句柄,否则返回 {@code null}
|
||||||
|
*/
|
||||||
|
public LockHandle tryAcquireFenced(
|
||||||
|
String lockKey,
|
||||||
|
String fenceKey,
|
||||||
|
Duration waitTimeout,
|
||||||
|
Duration leaseTimeout,
|
||||||
|
Duration fenceTtl) {
|
||||||
|
String lockValue = UUID.randomUUID().toString();
|
||||||
|
long deadline = System.nanoTime() + waitTimeout.toNanos();
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
Long token = stringRedisTemplate.execute(
|
||||||
|
ACQUIRE_FENCED_LOCK_SCRIPT,
|
||||||
|
java.util.List.of(lockKey, fenceKey),
|
||||||
|
lockValue,
|
||||||
|
String.valueOf(Math.max(1L, leaseTimeout.toMillis())),
|
||||||
|
String.valueOf(Math.max(1L, fenceTtl.toMillis())));
|
||||||
|
if (token != null && token > 0L) {
|
||||||
|
return new LockHandle(
|
||||||
|
lockKey, lockValue, leaseTimeout, token);
|
||||||
|
}
|
||||||
|
if (System.nanoTime() >= deadline) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Thread.sleep(RETRY_INTERVAL_MILLIS);
|
||||||
|
} while (System.nanoTime() <= deadline);
|
||||||
|
} catch (InterruptedException error) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"等待 fenced 分布式锁被中断,lockKey=" + lockKey, error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,6 +229,25 @@ public class RedisLockExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为已经取得互斥锁的实例分配单调递增 fencing token。
|
||||||
|
*
|
||||||
|
* @param fenceKey fencing token 哈希键
|
||||||
|
* @param ttl token 有效期
|
||||||
|
* @return 大于零的 fencing token
|
||||||
|
* @throws IllegalStateException Redis 未返回有效 token 时抛出
|
||||||
|
*/
|
||||||
|
public long nextFencingToken(String fenceKey, Duration ttl) {
|
||||||
|
Long token = stringRedisTemplate.execute(
|
||||||
|
NEXT_FENCING_TOKEN_SCRIPT,
|
||||||
|
Collections.singletonList(fenceKey),
|
||||||
|
String.valueOf(Math.max(1L, ttl.toMillis())));
|
||||||
|
if (token == null || token <= 0L) {
|
||||||
|
throw new IllegalStateException("分配 fencing token 失败,fenceKey=" + fenceKey);
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 显式分布式锁句柄。
|
* 显式分布式锁句柄。
|
||||||
*/
|
*/
|
||||||
@@ -176,12 +256,27 @@ public class RedisLockExecutor {
|
|||||||
private final String lockKey;
|
private final String lockKey;
|
||||||
private final String lockValue;
|
private final String lockValue;
|
||||||
private final Duration leaseTimeout;
|
private final Duration leaseTimeout;
|
||||||
|
private final long fencingToken;
|
||||||
private volatile boolean released;
|
private volatile boolean released;
|
||||||
|
|
||||||
private LockHandle(String lockKey, String lockValue, Duration leaseTimeout) {
|
private LockHandle(
|
||||||
|
String lockKey,
|
||||||
|
String lockValue,
|
||||||
|
Duration leaseTimeout,
|
||||||
|
long fencingToken) {
|
||||||
this.lockKey = lockKey;
|
this.lockKey = lockKey;
|
||||||
this.lockValue = lockValue;
|
this.lockValue = lockValue;
|
||||||
this.leaseTimeout = leaseTimeout;
|
this.leaseTimeout = leaseTimeout;
|
||||||
|
this.fencingToken = fencingToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取原子分配的 fencing token。
|
||||||
|
*
|
||||||
|
* @return 普通锁为 {@code 0},fenced 锁为正数
|
||||||
|
*/
|
||||||
|
public long getFencingToken() {
|
||||||
|
return fencingToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,870 @@
|
|||||||
|
package tech.easyflow.common.cache;
|
||||||
|
|
||||||
|
import com.alicp.jetcache.support.JavaValueEncoder;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnection;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.connection.ReturnType;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于 Redis Hash 与 Lua 的原子版本对象存储。
|
||||||
|
*
|
||||||
|
* <p>对象继续使用项目既有的 Java 序列化协议,避免状态内多态值在迁移后改变类型。
|
||||||
|
* payload 与 version 保存在同一 Redis Hash 中,创建、版本比较、写入和 TTL 刷新均由
|
||||||
|
* 单次 Lua 脚本原子完成。</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class RedisVersionedObjectStore implements VersionedObjectStore {
|
||||||
|
|
||||||
|
private static final byte[] PAYLOAD_FIELD = bytes("payload");
|
||||||
|
private static final byte[] CREATE_SCRIPT = bytes(
|
||||||
|
"if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[1], 'payload', ARGV[2]); "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[3]); return 1");
|
||||||
|
private static final byte[] GUARDED_CREATE_SCRIPT = bytes(
|
||||||
|
"local guard = redis.call('hget', KEYS[2], 'version'); "
|
||||||
|
+ "if not guard or guard ~= ARGV[2] then return -1 end; "
|
||||||
|
+ "if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[1], 'payload', ARGV[3]); "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[4]); return 1");
|
||||||
|
private static final byte[] DOUBLE_GUARDED_CREATE_SCRIPT = bytes(
|
||||||
|
"local guard = redis.call('hget', KEYS[2], 'version'); "
|
||||||
|
+ "local secondary = redis.call('hget', KEYS[3], 'version'); "
|
||||||
|
+ "if not guard or guard ~= ARGV[2] "
|
||||||
|
+ "or not secondary or secondary ~= ARGV[3] then return -1 end; "
|
||||||
|
+ "if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[1], 'payload', ARGV[4]); "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[5]); return 1");
|
||||||
|
private static final byte[] CAS_SCRIPT = bytes(
|
||||||
|
"local current = redis.call('hget', KEYS[1], 'version'); "
|
||||||
|
+ "if not current then return -1 end; "
|
||||||
|
+ "if current ~= ARGV[1] then return 0 end; "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[2], 'payload', ARGV[3]); "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[4]); return 1");
|
||||||
|
private static final byte[] GUARDED_CAS_SCRIPT = bytes(
|
||||||
|
"local guard = redis.call('hget', KEYS[2], 'version'); "
|
||||||
|
+ "if not guard or guard ~= ARGV[3] then return -2 end; "
|
||||||
|
+ "local current = redis.call('hget', KEYS[1], 'version'); "
|
||||||
|
+ "if not current then return -1 end; "
|
||||||
|
+ "if current ~= ARGV[1] then return 0 end; "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[2], 'payload', ARGV[4]); "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[5]); return 1");
|
||||||
|
private static final byte[] DOUBLE_GUARDED_CAS_SCRIPT = bytes(
|
||||||
|
"local guard = redis.call('hget', KEYS[2], 'version'); "
|
||||||
|
+ "local secondary = redis.call('hget', KEYS[3], 'version'); "
|
||||||
|
+ "if not guard or guard ~= ARGV[3] "
|
||||||
|
+ "or not secondary or secondary ~= ARGV[4] then return -2 end; "
|
||||||
|
+ "local current = redis.call('hget', KEYS[1], 'version'); "
|
||||||
|
+ "if not current then return -1 end; "
|
||||||
|
+ "if current ~= ARGV[1] then return 0 end; "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[2], 'payload', ARGV[5]); "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[6]); return 1");
|
||||||
|
private static final byte[] DOUBLE_GUARDED_DELETE_ALL_SCRIPT = bytes(
|
||||||
|
"local guard = redis.call('hget', KEYS[#KEYS - 1], 'version'); "
|
||||||
|
+ "local secondary = redis.call('hget', KEYS[#KEYS], 'version'); "
|
||||||
|
+ "if not guard or guard ~= ARGV[1] "
|
||||||
|
+ "or not secondary or secondary ~= ARGV[2] then return -1 end; "
|
||||||
|
+ "for i = 1, #KEYS - 2 do redis.call('del', KEYS[i]); end; "
|
||||||
|
+ "return 1");
|
||||||
|
private static final byte[] CREATE_FIELDS_SCRIPT = bytes(
|
||||||
|
"if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[1]); "
|
||||||
|
+ "for i = 2, #ARGV - 1, 2 do redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
|
||||||
|
private static final byte[] GUARDED_CREATE_FIELDS_SCRIPT = bytes(
|
||||||
|
"local guard = redis.call('hget', KEYS[2], 'version'); "
|
||||||
|
+ "if not guard or guard ~= ARGV[2] then return -1 end; "
|
||||||
|
+ "if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[1]); "
|
||||||
|
+ "for i = 3, #ARGV - 1, 2 do redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
|
||||||
|
private static final byte[] DOUBLE_GUARDED_CREATE_FIELDS_SCRIPT = bytes(
|
||||||
|
"local guard = redis.call('hget', KEYS[2], 'version'); "
|
||||||
|
+ "local secondary = redis.call('hget', KEYS[3], 'version'); "
|
||||||
|
+ "if not guard or guard ~= ARGV[2] "
|
||||||
|
+ "or not secondary or secondary ~= ARGV[3] then return -1 end; "
|
||||||
|
+ "if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[1]); "
|
||||||
|
+ "for i = 4, #ARGV - 1, 2 do "
|
||||||
|
+ "redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
|
||||||
|
private static final byte[] TRIPLE_GUARDED_CREATE_FIELDS_SCRIPT = bytes(
|
||||||
|
"local guard = redis.call('hget', KEYS[2], 'version'); "
|
||||||
|
+ "local secondary = redis.call('hget', KEYS[3], 'version'); "
|
||||||
|
+ "local tertiary = redis.call('hget', KEYS[4], 'version'); "
|
||||||
|
+ "if not guard or guard ~= ARGV[2] "
|
||||||
|
+ "or not secondary or secondary ~= ARGV[3] "
|
||||||
|
+ "or not tertiary or tertiary ~= ARGV[4] then return -1 end; "
|
||||||
|
+ "if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[1]); "
|
||||||
|
+ "for i = 5, #ARGV - 1, 2 do "
|
||||||
|
+ "redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
|
||||||
|
private static final byte[] CAS_FIELDS_SCRIPT = bytes(
|
||||||
|
"local current = redis.call('hget', KEYS[1], 'version'); "
|
||||||
|
+ "if not current then return -1 end; "
|
||||||
|
+ "if current ~= ARGV[1] then return 0 end; "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[2]); "
|
||||||
|
+ "for i = 3, #ARGV - 1, 2 do redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
|
||||||
|
private static final byte[] GUARDED_CAS_FIELDS_SCRIPT = bytes(
|
||||||
|
"local guard = redis.call('hget', KEYS[2], 'version'); "
|
||||||
|
+ "if not guard or guard ~= ARGV[3] then return -2 end; "
|
||||||
|
+ "local current = redis.call('hget', KEYS[1], 'version'); "
|
||||||
|
+ "if not current then return -1 end; "
|
||||||
|
+ "if current ~= ARGV[1] then return 0 end; "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[2]); "
|
||||||
|
+ "for i = 4, #ARGV - 1, 2 do redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
|
||||||
|
private static final byte[] DOUBLE_GUARDED_CAS_FIELDS_SCRIPT = bytes(
|
||||||
|
"local guard = redis.call('hget', KEYS[2], 'version'); "
|
||||||
|
+ "local secondary = redis.call('hget', KEYS[3], 'version'); "
|
||||||
|
+ "if not guard or guard ~= ARGV[3] "
|
||||||
|
+ "or not secondary or secondary ~= ARGV[4] then return -2 end; "
|
||||||
|
+ "local current = redis.call('hget', KEYS[1], 'version'); "
|
||||||
|
+ "if not current then return -1 end; "
|
||||||
|
+ "if current ~= ARGV[1] then return 0 end; "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[2]); "
|
||||||
|
+ "for i = 5, #ARGV - 1, 2 do "
|
||||||
|
+ "redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
|
||||||
|
private static final byte[] TRIPLE_GUARDED_CAS_FIELDS_SCRIPT = bytes(
|
||||||
|
"local guard = redis.call('hget', KEYS[2], 'version'); "
|
||||||
|
+ "local secondary = redis.call('hget', KEYS[3], 'version'); "
|
||||||
|
+ "local tertiary = redis.call('hget', KEYS[4], 'version'); "
|
||||||
|
+ "if not guard or guard ~= ARGV[3] "
|
||||||
|
+ "or not secondary or secondary ~= ARGV[4] "
|
||||||
|
+ "or not tertiary or tertiary ~= ARGV[5] then return -2 end; "
|
||||||
|
+ "local current = redis.call('hget', KEYS[1], 'version'); "
|
||||||
|
+ "if not current then return -1 end; "
|
||||||
|
+ "if current ~= ARGV[1] then return 0 end; "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[2]); "
|
||||||
|
+ "for i = 6, #ARGV - 1, 2 do "
|
||||||
|
+ "redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
|
||||||
|
private static final byte[] DOUBLE_GUARDED_CAS_FIELDS_REFRESH_SCRIPT = bytes(
|
||||||
|
"local guard = redis.call('hget', KEYS[2], 'version'); "
|
||||||
|
+ "local secondary = redis.call('hget', KEYS[3], 'version'); "
|
||||||
|
+ "if not guard or guard ~= ARGV[3] "
|
||||||
|
+ "or not secondary or secondary ~= ARGV[4] then return -2 end; "
|
||||||
|
+ "local current = redis.call('hget', KEYS[1], 'version'); "
|
||||||
|
+ "if not current then return -1 end; "
|
||||||
|
+ "if current ~= ARGV[1] then return 0 end; "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[2]); "
|
||||||
|
+ "for i = 5, #ARGV - 2, 2 do "
|
||||||
|
+ "redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV - 1]); "
|
||||||
|
+ "if redis.call('exists', KEYS[4]) == 0 then "
|
||||||
|
+ "redis.call('hset', KEYS[4], 'version', '0'); end; "
|
||||||
|
+ "redis.call('pexpire', KEYS[4], ARGV[#ARGV]); return 1");
|
||||||
|
private static final byte[] REWRITE_FIELDS_SCRIPT = bytes(
|
||||||
|
"local current = redis.call('hget', KEYS[1], 'version'); "
|
||||||
|
+ "if not current or current ~= ARGV[1] then return 0 end; "
|
||||||
|
+ "redis.call('del', KEYS[1]); "
|
||||||
|
+ "redis.call('hset', KEYS[1], 'version', ARGV[1]); "
|
||||||
|
+ "for i = 2, #ARGV - 1, 2 do redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
|
||||||
|
|
||||||
|
private final RedisConnectionFactory connectionFactory;
|
||||||
|
private final ApplicationClassLoaderJavaValueDecoder valueDecoder =
|
||||||
|
new ApplicationClassLoaderJavaValueDecoder();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Redis 版本对象存储。
|
||||||
|
*
|
||||||
|
* @param connectionFactory Redis 连接工厂
|
||||||
|
*/
|
||||||
|
public RedisVersionedObjectStore(RedisConnectionFactory connectionFactory) {
|
||||||
|
this.connectionFactory = Objects.requireNonNull(
|
||||||
|
connectionFactory, "connectionFactory must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public <T> T load(String key, Class<T> type) {
|
||||||
|
requireKey(key);
|
||||||
|
Objects.requireNonNull(type, "type must not be null");
|
||||||
|
try (RedisConnection connection = connectionFactory.getConnection()) {
|
||||||
|
byte[] payload = connection.hashCommands().hGet(bytes(key), PAYLOAD_FIELD);
|
||||||
|
if (payload == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return type.cast(valueDecoder.apply(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public <T> List<T> loadAll(List<String> keys, Class<T> type) {
|
||||||
|
Objects.requireNonNull(keys, "keys must not be null");
|
||||||
|
Objects.requireNonNull(type, "type must not be null");
|
||||||
|
if (keys.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try (RedisConnection connection = connectionFactory.getConnection()) {
|
||||||
|
connection.openPipeline();
|
||||||
|
for (String key : keys) {
|
||||||
|
connection.hashCommands().hGet(bytes(requireKey(key)), PAYLOAD_FIELD);
|
||||||
|
}
|
||||||
|
List<Object> encodedValues = connection.closePipeline();
|
||||||
|
List<T> values = new ArrayList<>(keys.size());
|
||||||
|
for (int index = 0; index < keys.size(); index++) {
|
||||||
|
Object encoded = encodedValues != null && index < encodedValues.size()
|
||||||
|
? encodedValues.get(index)
|
||||||
|
: null;
|
||||||
|
values.add(encoded instanceof byte[]
|
||||||
|
? type.cast(valueDecoder.apply((byte[]) encoded))
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void deleteAll(List<String> keys) {
|
||||||
|
Objects.requireNonNull(keys, "keys must not be null");
|
||||||
|
if (keys.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
byte[][] encodedKeys = new byte[keys.size()][];
|
||||||
|
for (int index = 0; index < keys.size(); index++) {
|
||||||
|
encodedKeys[index] = bytes(requireKey(keys.get(index)));
|
||||||
|
}
|
||||||
|
try (RedisConnection connection = connectionFactory.getConnection()) {
|
||||||
|
connection.keyCommands().del(encodedKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean deleteAll(
|
||||||
|
List<String> keys,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion) {
|
||||||
|
Objects.requireNonNull(keys, "keys must not be null");
|
||||||
|
if (keys.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
List<byte[]> arguments = new ArrayList<>(keys.size() + 4);
|
||||||
|
for (String key : keys) {
|
||||||
|
arguments.add(bytes(requireKey(key)));
|
||||||
|
}
|
||||||
|
arguments.add(bytes(requireKey(guardKey)));
|
||||||
|
arguments.add(bytes(requireKey(secondaryGuardKey)));
|
||||||
|
arguments.add(bytes(guardVersion));
|
||||||
|
arguments.add(bytes(secondaryGuardVersion));
|
||||||
|
Long result = eval(
|
||||||
|
DOUBLE_GUARDED_DELETE_ALL_SCRIPT,
|
||||||
|
keys.size() + 2,
|
||||||
|
arguments.toArray(new byte[0][]));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void refreshExpirations(List<String> keys, Duration ttl) {
|
||||||
|
Objects.requireNonNull(keys, "keys must not be null");
|
||||||
|
if (keys.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long ttlMillis = ttlMillis(ttl);
|
||||||
|
try (RedisConnection connection = connectionFactory.getConnection()) {
|
||||||
|
connection.openPipeline();
|
||||||
|
for (String key : keys) {
|
||||||
|
connection.keyCommands().pExpire(
|
||||||
|
bytes(requireKey(key)), ttlMillis);
|
||||||
|
}
|
||||||
|
connection.closePipeline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean createIfAbsent(String key,
|
||||||
|
Serializable value,
|
||||||
|
long version,
|
||||||
|
Duration ttl) {
|
||||||
|
Long result = eval(
|
||||||
|
CREATE_SCRIPT,
|
||||||
|
1,
|
||||||
|
bytes(requireKey(key)),
|
||||||
|
bytes(version),
|
||||||
|
encode(value),
|
||||||
|
bytes(ttlMillis(ttl)));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean createIfAbsent(
|
||||||
|
String key,
|
||||||
|
Serializable value,
|
||||||
|
long version,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long result = eval(
|
||||||
|
DOUBLE_GUARDED_CREATE_SCRIPT,
|
||||||
|
3,
|
||||||
|
bytes(requireKey(key)),
|
||||||
|
bytes(requireKey(guardKey)),
|
||||||
|
bytes(requireKey(secondaryGuardKey)),
|
||||||
|
bytes(version),
|
||||||
|
bytes(guardVersion),
|
||||||
|
bytes(secondaryGuardVersion),
|
||||||
|
encode(value),
|
||||||
|
bytes(ttlMillis(ttl)));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean createIfAbsent(String key,
|
||||||
|
Serializable value,
|
||||||
|
long version,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long result = eval(
|
||||||
|
GUARDED_CREATE_SCRIPT,
|
||||||
|
2,
|
||||||
|
bytes(requireKey(key)),
|
||||||
|
bytes(requireKey(guardKey)),
|
||||||
|
bytes(version),
|
||||||
|
bytes(guardVersion),
|
||||||
|
encode(value),
|
||||||
|
bytes(ttlMillis(ttl)));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean compareAndSet(String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Serializable value,
|
||||||
|
long newVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long result = eval(
|
||||||
|
CAS_SCRIPT,
|
||||||
|
1,
|
||||||
|
bytes(requireKey(key)),
|
||||||
|
bytes(expectedVersion),
|
||||||
|
bytes(newVersion),
|
||||||
|
encode(value),
|
||||||
|
bytes(ttlMillis(ttl)));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public VersionedFields loadFields(String key) {
|
||||||
|
requireKey(key);
|
||||||
|
try (RedisConnection connection = connectionFactory.getConnection()) {
|
||||||
|
Map<byte[], byte[]> encoded = connection.hashCommands().hGetAll(bytes(key));
|
||||||
|
if (encoded == null || encoded.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Long version = null;
|
||||||
|
Map<String, Object> fields = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<byte[], byte[]> entry : encoded.entrySet()) {
|
||||||
|
String fieldName = new String(entry.getKey(), StandardCharsets.UTF_8);
|
||||||
|
if ("version".equals(fieldName)) {
|
||||||
|
version = Long.parseLong(new String(entry.getValue(), StandardCharsets.UTF_8));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Object value = valueDecoder.apply(entry.getValue());
|
||||||
|
fields.put(fieldName, value == NullFieldValue.INSTANCE ? null : value);
|
||||||
|
}
|
||||||
|
return version == null ? null : new VersionedFields(version, fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Long loadVersion(String key) {
|
||||||
|
requireKey(key);
|
||||||
|
try (RedisConnection connection = connectionFactory.getConnection()) {
|
||||||
|
byte[] encoded = connection.hashCommands().hGet(bytes(key), bytes("version"));
|
||||||
|
return encoded == null
|
||||||
|
? null
|
||||||
|
: Long.parseLong(new String(encoded, StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean createFieldsIfAbsent(String key,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long version,
|
||||||
|
Duration ttl) {
|
||||||
|
Long result = eval(
|
||||||
|
CREATE_FIELDS_SCRIPT,
|
||||||
|
1,
|
||||||
|
fieldArguments(
|
||||||
|
List.of(bytes(requireKey(key)), bytes(version)),
|
||||||
|
fields,
|
||||||
|
ttl));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean createFieldsIfAbsent(
|
||||||
|
String key,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long version,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long result = eval(
|
||||||
|
DOUBLE_GUARDED_CREATE_FIELDS_SCRIPT,
|
||||||
|
3,
|
||||||
|
fieldArguments(
|
||||||
|
List.of(
|
||||||
|
bytes(requireKey(key)),
|
||||||
|
bytes(requireKey(guardKey)),
|
||||||
|
bytes(requireKey(secondaryGuardKey)),
|
||||||
|
bytes(version),
|
||||||
|
bytes(guardVersion),
|
||||||
|
bytes(secondaryGuardVersion)),
|
||||||
|
fields,
|
||||||
|
ttl));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean createFieldsIfAbsent(
|
||||||
|
String key,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long version,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion,
|
||||||
|
String tertiaryGuardKey,
|
||||||
|
long tertiaryGuardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long result = eval(
|
||||||
|
TRIPLE_GUARDED_CREATE_FIELDS_SCRIPT,
|
||||||
|
4,
|
||||||
|
fieldArguments(
|
||||||
|
List.of(
|
||||||
|
bytes(requireKey(key)),
|
||||||
|
bytes(requireKey(guardKey)),
|
||||||
|
bytes(requireKey(secondaryGuardKey)),
|
||||||
|
bytes(requireKey(tertiaryGuardKey)),
|
||||||
|
bytes(version),
|
||||||
|
bytes(guardVersion),
|
||||||
|
bytes(secondaryGuardVersion),
|
||||||
|
bytes(tertiaryGuardVersion)),
|
||||||
|
fields,
|
||||||
|
ttl));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean createFieldsIfAbsent(String key,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long version,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long result = eval(
|
||||||
|
GUARDED_CREATE_FIELDS_SCRIPT,
|
||||||
|
2,
|
||||||
|
fieldArguments(
|
||||||
|
List.of(
|
||||||
|
bytes(requireKey(key)),
|
||||||
|
bytes(requireKey(guardKey)),
|
||||||
|
bytes(version),
|
||||||
|
bytes(guardVersion)),
|
||||||
|
fields,
|
||||||
|
ttl));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean compareAndSetFields(String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long newVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long result = eval(
|
||||||
|
CAS_FIELDS_SCRIPT,
|
||||||
|
1,
|
||||||
|
fieldArguments(
|
||||||
|
List.of(
|
||||||
|
bytes(requireKey(key)),
|
||||||
|
bytes(expectedVersion),
|
||||||
|
bytes(newVersion)),
|
||||||
|
fields,
|
||||||
|
ttl));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean compareAndSetFields(
|
||||||
|
String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long newVersion,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long result = eval(
|
||||||
|
DOUBLE_GUARDED_CAS_FIELDS_SCRIPT,
|
||||||
|
3,
|
||||||
|
fieldArguments(
|
||||||
|
List.of(
|
||||||
|
bytes(requireKey(key)),
|
||||||
|
bytes(requireKey(guardKey)),
|
||||||
|
bytes(requireKey(secondaryGuardKey)),
|
||||||
|
bytes(expectedVersion),
|
||||||
|
bytes(newVersion),
|
||||||
|
bytes(guardVersion),
|
||||||
|
bytes(secondaryGuardVersion)),
|
||||||
|
fields,
|
||||||
|
ttl));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean compareAndSetFields(
|
||||||
|
String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long newVersion,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion,
|
||||||
|
String tertiaryGuardKey,
|
||||||
|
long tertiaryGuardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long result = eval(
|
||||||
|
TRIPLE_GUARDED_CAS_FIELDS_SCRIPT,
|
||||||
|
4,
|
||||||
|
fieldArguments(
|
||||||
|
List.of(
|
||||||
|
bytes(requireKey(key)),
|
||||||
|
bytes(requireKey(guardKey)),
|
||||||
|
bytes(requireKey(secondaryGuardKey)),
|
||||||
|
bytes(requireKey(tertiaryGuardKey)),
|
||||||
|
bytes(expectedVersion),
|
||||||
|
bytes(newVersion),
|
||||||
|
bytes(guardVersion),
|
||||||
|
bytes(secondaryGuardVersion),
|
||||||
|
bytes(tertiaryGuardVersion)),
|
||||||
|
fields,
|
||||||
|
ttl));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean compareAndSetFieldsAndRefresh(
|
||||||
|
String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long newVersion,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion,
|
||||||
|
Duration ttl,
|
||||||
|
String refreshKey,
|
||||||
|
Duration refreshTtl) {
|
||||||
|
Long result = eval(
|
||||||
|
DOUBLE_GUARDED_CAS_FIELDS_REFRESH_SCRIPT,
|
||||||
|
4,
|
||||||
|
fieldArgumentsWithRefreshTtl(
|
||||||
|
List.of(
|
||||||
|
bytes(requireKey(key)),
|
||||||
|
bytes(requireKey(guardKey)),
|
||||||
|
bytes(requireKey(secondaryGuardKey)),
|
||||||
|
bytes(requireKey(refreshKey)),
|
||||||
|
bytes(expectedVersion),
|
||||||
|
bytes(newVersion),
|
||||||
|
bytes(guardVersion),
|
||||||
|
bytes(secondaryGuardVersion)),
|
||||||
|
fields,
|
||||||
|
ttl,
|
||||||
|
refreshTtl));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean compareAndSetFields(String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long newVersion,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long result = eval(
|
||||||
|
GUARDED_CAS_FIELDS_SCRIPT,
|
||||||
|
2,
|
||||||
|
fieldArguments(
|
||||||
|
List.of(
|
||||||
|
bytes(requireKey(key)),
|
||||||
|
bytes(requireKey(guardKey)),
|
||||||
|
bytes(expectedVersion),
|
||||||
|
bytes(newVersion),
|
||||||
|
bytes(guardVersion)),
|
||||||
|
fields,
|
||||||
|
ttl));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean rewriteAsFields(String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
Duration ttl) {
|
||||||
|
Long result = eval(
|
||||||
|
REWRITE_FIELDS_SCRIPT,
|
||||||
|
1,
|
||||||
|
fieldArguments(
|
||||||
|
List.of(bytes(requireKey(key)), bytes(expectedVersion)),
|
||||||
|
fields,
|
||||||
|
ttl));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean compareAndSet(String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Serializable value,
|
||||||
|
long newVersion,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long result = eval(
|
||||||
|
GUARDED_CAS_SCRIPT,
|
||||||
|
2,
|
||||||
|
bytes(requireKey(key)),
|
||||||
|
bytes(requireKey(guardKey)),
|
||||||
|
bytes(expectedVersion),
|
||||||
|
bytes(newVersion),
|
||||||
|
bytes(guardVersion),
|
||||||
|
encode(value),
|
||||||
|
bytes(ttlMillis(ttl)));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean compareAndSet(
|
||||||
|
String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Serializable value,
|
||||||
|
long newVersion,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long result = eval(
|
||||||
|
DOUBLE_GUARDED_CAS_SCRIPT,
|
||||||
|
3,
|
||||||
|
bytes(requireKey(key)),
|
||||||
|
bytes(requireKey(guardKey)),
|
||||||
|
bytes(requireKey(secondaryGuardKey)),
|
||||||
|
bytes(expectedVersion),
|
||||||
|
bytes(newVersion),
|
||||||
|
bytes(guardVersion),
|
||||||
|
bytes(secondaryGuardVersion),
|
||||||
|
encode(value),
|
||||||
|
bytes(ttlMillis(ttl)));
|
||||||
|
return Long.valueOf(1L).equals(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行返回整数的 Redis Lua 脚本。
|
||||||
|
*
|
||||||
|
* @param script Lua 脚本
|
||||||
|
* @param keyCount 参数中的 Redis key 数量
|
||||||
|
* @param keysAndArgs key 与脚本参数
|
||||||
|
* @return Redis 整数结果
|
||||||
|
*/
|
||||||
|
private Long eval(byte[] script, int keyCount, byte[]... keysAndArgs) {
|
||||||
|
try (RedisConnection connection = connectionFactory.getConnection()) {
|
||||||
|
return connection.scriptingCommands().eval(
|
||||||
|
script, ReturnType.INTEGER, keyCount, keysAndArgs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用项目既有 Java 协议编码对象。
|
||||||
|
*
|
||||||
|
* @param value 待编码对象
|
||||||
|
* @return 编码后的二进制 payload
|
||||||
|
*/
|
||||||
|
private byte[] encode(Serializable value) {
|
||||||
|
return JavaValueEncoder.INSTANCE.apply(
|
||||||
|
Objects.requireNonNull(value, "value must not be null"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装动态字段脚本参数,每个字段只编码一次。
|
||||||
|
*
|
||||||
|
* @param prefix Redis key 与固定参数
|
||||||
|
* @param fields 待写字段
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return EVAL 的完整二进制参数
|
||||||
|
*/
|
||||||
|
private byte[][] fieldArguments(List<byte[]> prefix,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
Duration ttl) {
|
||||||
|
Objects.requireNonNull(fields, "fields must not be null");
|
||||||
|
List<byte[]> arguments = new ArrayList<>(prefix.size() + fields.size() * 2 + 1);
|
||||||
|
arguments.addAll(prefix);
|
||||||
|
for (Map.Entry<String, ? extends Serializable> entry : fields.entrySet()) {
|
||||||
|
String fieldName = requireKey(entry.getKey());
|
||||||
|
if ("version".equals(fieldName)) {
|
||||||
|
throw new IllegalArgumentException("version is a reserved field");
|
||||||
|
}
|
||||||
|
Serializable value = entry.getValue() == null ? NullFieldValue.INSTANCE : entry.getValue();
|
||||||
|
arguments.add(bytes(fieldName));
|
||||||
|
arguments.add(encode(value));
|
||||||
|
}
|
||||||
|
arguments.add(bytes(ttlMillis(ttl)));
|
||||||
|
return arguments.toArray(new byte[0][]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装需要同时刷新关联键 TTL 的字段脚本参数。
|
||||||
|
*
|
||||||
|
* @param prefix Redis key 与固定参数
|
||||||
|
* @param fields 待写字段
|
||||||
|
* @param ttl 主对象有效期
|
||||||
|
* @param refreshTtl 关联键有效期
|
||||||
|
* @return EVAL 的完整二进制参数
|
||||||
|
*/
|
||||||
|
private byte[][] fieldArgumentsWithRefreshTtl(
|
||||||
|
List<byte[]> prefix,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
Duration ttl,
|
||||||
|
Duration refreshTtl) {
|
||||||
|
byte[][] base = fieldArguments(prefix, fields, ttl);
|
||||||
|
byte[][] arguments = java.util.Arrays.copyOf(base, base.length + 1);
|
||||||
|
arguments[base.length] = bytes(ttlMillis(refreshTtl));
|
||||||
|
return arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验并换算有效期。
|
||||||
|
*
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 至少一毫秒的有效期
|
||||||
|
*/
|
||||||
|
private long ttlMillis(Duration ttl) {
|
||||||
|
Objects.requireNonNull(ttl, "ttl must not be null");
|
||||||
|
if (ttl.isNegative() || ttl.isZero()) {
|
||||||
|
throw new IllegalArgumentException("ttl must be positive");
|
||||||
|
}
|
||||||
|
return Math.max(1L, ttl.toMillis());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 Redis key。
|
||||||
|
*
|
||||||
|
* @param key Redis key
|
||||||
|
* @return 原 key
|
||||||
|
*/
|
||||||
|
private String requireKey(String key) {
|
||||||
|
if (key == null || key.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("key must not be blank");
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文本转为 Redis 二进制参数。
|
||||||
|
*
|
||||||
|
* @param value 文本
|
||||||
|
* @return UTF-8 字节
|
||||||
|
*/
|
||||||
|
private static byte[] bytes(Object value) {
|
||||||
|
return String.valueOf(value).getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis Hash 中显式保存的空字段占位。
|
||||||
|
*/
|
||||||
|
private enum NullFieldValue {
|
||||||
|
INSTANCE
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package tech.easyflow.common.cache;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 字段化版本状态快照。
|
||||||
|
*/
|
||||||
|
public final class VersionedFields {
|
||||||
|
|
||||||
|
private final long version;
|
||||||
|
private final Map<String, Object> fields;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建字段化状态快照。
|
||||||
|
*
|
||||||
|
* @param version 当前版本
|
||||||
|
* @param fields 字段值
|
||||||
|
*/
|
||||||
|
public VersionedFields(long version, Map<String, Object> fields) {
|
||||||
|
this.version = version;
|
||||||
|
this.fields = fields == null
|
||||||
|
? Collections.emptyMap()
|
||||||
|
: Collections.unmodifiableMap(new LinkedHashMap<>(fields));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取状态版本。
|
||||||
|
*
|
||||||
|
* @return 当前版本
|
||||||
|
*/
|
||||||
|
public long getVersion() {
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取字段快照。
|
||||||
|
*
|
||||||
|
* @return 不可变字段映射
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getFields() {
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
package tech.easyflow.common.cache;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支持原子版本比较的对象存储。
|
||||||
|
*/
|
||||||
|
public interface VersionedObjectStore {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载对象。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param type 对象类型
|
||||||
|
* @param <T> 对象类型
|
||||||
|
* @return 已存在的对象;不存在时返回 {@code null}
|
||||||
|
*/
|
||||||
|
<T> T load(String key, Class<T> type);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量加载对象并保持输入键顺序。
|
||||||
|
*
|
||||||
|
* @param keys 存储键
|
||||||
|
* @param type 对象类型
|
||||||
|
* @param <T> 对象类型
|
||||||
|
* @return 与输入键等长的对象列表;缺失位置为 {@code null}
|
||||||
|
*/
|
||||||
|
default <T> List<T> loadAll(List<String> keys, Class<T> type) {
|
||||||
|
List<T> values = new ArrayList<>(keys.size());
|
||||||
|
for (String key : keys) {
|
||||||
|
values.add(load(key, type));
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除对象。
|
||||||
|
*
|
||||||
|
* @param keys 存储键
|
||||||
|
*/
|
||||||
|
default void deleteAll(List<String> keys) {
|
||||||
|
throw new UnsupportedOperationException("Batch delete is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两个守卫版本均匹配时原子批量删除对象。
|
||||||
|
*
|
||||||
|
* @param keys 待删除对象键
|
||||||
|
* @param guardKey 第一守卫键
|
||||||
|
* @param guardVersion 第一守卫版本
|
||||||
|
* @param secondaryGuardKey 第二守卫键
|
||||||
|
* @param secondaryGuardVersion 第二守卫版本
|
||||||
|
* @return 守卫匹配并执行删除时为 {@code true}
|
||||||
|
*/
|
||||||
|
default boolean deleteAll(
|
||||||
|
List<String> keys,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion) {
|
||||||
|
throw new UnsupportedOperationException(
|
||||||
|
"Guarded batch delete is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量刷新对象有效期。
|
||||||
|
*
|
||||||
|
* @param keys 存储键
|
||||||
|
* @param ttl 新有效期
|
||||||
|
*/
|
||||||
|
default void refreshExpirations(List<String> keys, Duration ttl) {
|
||||||
|
throw new UnsupportedOperationException("Expiration refresh is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子创建对象。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param value 初始对象
|
||||||
|
* @param version 初始版本
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 创建成功时为 {@code true},对象已存在时为 {@code false}
|
||||||
|
*/
|
||||||
|
boolean createIfAbsent(String key, Serializable value, long version, Duration ttl);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在守卫对象版本匹配时原子创建对象。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param value 初始对象
|
||||||
|
* @param version 初始版本
|
||||||
|
* @param guardKey 守卫对象键
|
||||||
|
* @param guardVersion 期望的守卫版本
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 创建成功时为 {@code true},对象已存在或守卫版本冲突时为 {@code false}
|
||||||
|
*/
|
||||||
|
boolean createIfAbsent(String key,
|
||||||
|
Serializable value,
|
||||||
|
long version,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
Duration ttl);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两个守卫版本均匹配时原子创建对象。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param value 初始对象
|
||||||
|
* @param version 初始版本
|
||||||
|
* @param guardKey 第一守卫键
|
||||||
|
* @param guardVersion 第一守卫版本
|
||||||
|
* @param secondaryGuardKey 第二守卫键
|
||||||
|
* @param secondaryGuardVersion 第二守卫版本
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 创建成功时为 {@code true}
|
||||||
|
*/
|
||||||
|
default boolean createIfAbsent(
|
||||||
|
String key,
|
||||||
|
Serializable value,
|
||||||
|
long version,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
return createIfAbsent(key, value, version, guardKey, guardVersion, ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子比较并更新对象。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param expectedVersion 期望的当前版本
|
||||||
|
* @param value 新对象
|
||||||
|
* @param newVersion 新版本
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 更新成功时为 {@code true},对象缺失或版本冲突时为 {@code false}
|
||||||
|
*/
|
||||||
|
boolean compareAndSet(String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Serializable value,
|
||||||
|
long newVersion,
|
||||||
|
Duration ttl);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在守卫对象版本匹配时原子比较并更新对象。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param expectedVersion 期望的当前版本
|
||||||
|
* @param value 新对象
|
||||||
|
* @param newVersion 新版本
|
||||||
|
* @param guardKey 守卫对象键
|
||||||
|
* @param guardVersion 期望的守卫版本
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 更新成功时为 {@code true},对象缺失或任一版本冲突时为 {@code false}
|
||||||
|
*/
|
||||||
|
boolean compareAndSet(String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Serializable value,
|
||||||
|
long newVersion,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
Duration ttl);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两个守卫版本均匹配时原子比较并更新对象。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param expectedVersion 期望的当前版本
|
||||||
|
* @param value 新对象
|
||||||
|
* @param newVersion 新版本
|
||||||
|
* @param guardKey 第一守卫键
|
||||||
|
* @param guardVersion 第一守卫版本
|
||||||
|
* @param secondaryGuardKey 第二守卫键
|
||||||
|
* @param secondaryGuardVersion 第二守卫版本
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 更新成功时为 {@code true}
|
||||||
|
*/
|
||||||
|
default boolean compareAndSet(
|
||||||
|
String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Serializable value,
|
||||||
|
long newVersion,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
return compareAndSet(
|
||||||
|
key, expectedVersion, value, newVersion, guardKey, guardVersion, ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次读取字段化状态及其版本。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @return 字段化状态;不存在时返回 {@code null}
|
||||||
|
*/
|
||||||
|
default VersionedFields loadFields(String key) {
|
||||||
|
throw new UnsupportedOperationException("Field storage is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只读取对象版本。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @return 当前版本;对象不存在或缺少版本时返回 {@code null}
|
||||||
|
*/
|
||||||
|
default Long loadVersion(String key) {
|
||||||
|
VersionedFields fields = loadFields(key);
|
||||||
|
return fields == null ? null : fields.getVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子创建字段化状态。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param fields 初始字段
|
||||||
|
* @param version 初始版本
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 创建成功时为 {@code true}
|
||||||
|
*/
|
||||||
|
default boolean createFieldsIfAbsent(String key,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long version,
|
||||||
|
Duration ttl) {
|
||||||
|
throw new UnsupportedOperationException("Field storage is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 守卫版本匹配时原子创建字段化状态。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param fields 初始字段
|
||||||
|
* @param version 初始版本
|
||||||
|
* @param guardKey 守卫状态键
|
||||||
|
* @param guardVersion 期望守卫版本
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 创建成功时为 {@code true}
|
||||||
|
*/
|
||||||
|
default boolean createFieldsIfAbsent(String key,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long version,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
throw new UnsupportedOperationException("Field storage is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两个守卫版本均匹配时原子创建字段化状态。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param fields 初始字段
|
||||||
|
* @param version 初始版本
|
||||||
|
* @param guardKey 第一守卫键
|
||||||
|
* @param guardVersion 第一守卫版本
|
||||||
|
* @param secondaryGuardKey 第二守卫键
|
||||||
|
* @param secondaryGuardVersion 第二守卫版本
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 创建成功时为 {@code true}
|
||||||
|
*/
|
||||||
|
default boolean createFieldsIfAbsent(
|
||||||
|
String key,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long version,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
return createFieldsIfAbsent(key, fields, version, guardKey, guardVersion, ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三个守卫版本均匹配时原子创建字段化状态。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param fields 初始字段
|
||||||
|
* @param version 初始版本
|
||||||
|
* @param guardKey 第一守卫键
|
||||||
|
* @param guardVersion 第一守卫版本
|
||||||
|
* @param secondaryGuardKey 第二守卫键
|
||||||
|
* @param secondaryGuardVersion 第二守卫版本
|
||||||
|
* @param tertiaryGuardKey 第三守卫键
|
||||||
|
* @param tertiaryGuardVersion 第三守卫版本
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 创建成功时为 {@code true}
|
||||||
|
*/
|
||||||
|
default boolean createFieldsIfAbsent(
|
||||||
|
String key,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long version,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion,
|
||||||
|
String tertiaryGuardKey,
|
||||||
|
long tertiaryGuardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
return createFieldsIfAbsent(
|
||||||
|
key,
|
||||||
|
fields,
|
||||||
|
version,
|
||||||
|
guardKey,
|
||||||
|
guardVersion,
|
||||||
|
secondaryGuardKey,
|
||||||
|
secondaryGuardVersion,
|
||||||
|
ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子比较版本并仅提交变化字段。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param expectedVersion 期望版本
|
||||||
|
* @param fields 变化字段
|
||||||
|
* @param newVersion 新版本
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 更新成功时为 {@code true}
|
||||||
|
*/
|
||||||
|
default boolean compareAndSetFields(String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long newVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
throw new UnsupportedOperationException("Field storage is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两个守卫版本均匹配时原子提交变化字段。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param expectedVersion 当前对象期望版本
|
||||||
|
* @param fields 变化字段
|
||||||
|
* @param newVersion 新版本
|
||||||
|
* @param guardKey 第一守卫键
|
||||||
|
* @param guardVersion 第一守卫版本
|
||||||
|
* @param secondaryGuardKey 第二守卫键
|
||||||
|
* @param secondaryGuardVersion 第二守卫版本
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 提交成功时为 {@code true}
|
||||||
|
*/
|
||||||
|
default boolean compareAndSetFields(
|
||||||
|
String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long newVersion,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
return compareAndSetFields(
|
||||||
|
key, expectedVersion, fields, newVersion, guardKey, guardVersion, ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三个守卫版本均匹配时原子提交变化字段。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param expectedVersion 当前对象期望版本
|
||||||
|
* @param fields 变化字段
|
||||||
|
* @param newVersion 新版本
|
||||||
|
* @param guardKey 第一守卫键
|
||||||
|
* @param guardVersion 第一守卫版本
|
||||||
|
* @param secondaryGuardKey 第二守卫键
|
||||||
|
* @param secondaryGuardVersion 第二守卫版本
|
||||||
|
* @param tertiaryGuardKey 第三守卫键
|
||||||
|
* @param tertiaryGuardVersion 第三守卫版本
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 提交成功时为 {@code true}
|
||||||
|
*/
|
||||||
|
default boolean compareAndSetFields(
|
||||||
|
String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long newVersion,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion,
|
||||||
|
String tertiaryGuardKey,
|
||||||
|
long tertiaryGuardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
return compareAndSetFields(
|
||||||
|
key,
|
||||||
|
expectedVersion,
|
||||||
|
fields,
|
||||||
|
newVersion,
|
||||||
|
guardKey,
|
||||||
|
guardVersion,
|
||||||
|
secondaryGuardKey,
|
||||||
|
secondaryGuardVersion,
|
||||||
|
ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两个守卫版本均匹配时提交字段,并在同一原子操作中刷新关联键有效期。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param expectedVersion 当前对象期望版本
|
||||||
|
* @param fields 变化字段
|
||||||
|
* @param newVersion 新版本
|
||||||
|
* @param guardKey 第一守卫键
|
||||||
|
* @param guardVersion 第一守卫版本
|
||||||
|
* @param secondaryGuardKey 第二守卫键
|
||||||
|
* @param secondaryGuardVersion 第二守卫版本
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @param refreshKey 需要刷新有效期的关联键
|
||||||
|
* @param refreshTtl 关联键有效期
|
||||||
|
* @return 提交成功时为 {@code true}
|
||||||
|
*/
|
||||||
|
default boolean compareAndSetFieldsAndRefresh(
|
||||||
|
String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long newVersion,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
String secondaryGuardKey,
|
||||||
|
long secondaryGuardVersion,
|
||||||
|
Duration ttl,
|
||||||
|
String refreshKey,
|
||||||
|
Duration refreshTtl) {
|
||||||
|
return compareAndSetFields(
|
||||||
|
key,
|
||||||
|
expectedVersion,
|
||||||
|
fields,
|
||||||
|
newVersion,
|
||||||
|
guardKey,
|
||||||
|
guardVersion,
|
||||||
|
secondaryGuardKey,
|
||||||
|
secondaryGuardVersion,
|
||||||
|
ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 守卫版本匹配时原子比较版本并仅提交变化字段。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param expectedVersion 期望版本
|
||||||
|
* @param fields 变化字段
|
||||||
|
* @param newVersion 新版本
|
||||||
|
* @param guardKey 守卫状态键
|
||||||
|
* @param guardVersion 期望守卫版本
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 更新成功时为 {@code true}
|
||||||
|
*/
|
||||||
|
default boolean compareAndSetFields(String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long newVersion,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
throw new UnsupportedOperationException("Field storage is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将同版本完整对象原子改写为字段化状态。
|
||||||
|
*
|
||||||
|
* @param key 存储键
|
||||||
|
* @param expectedVersion 期望版本
|
||||||
|
* @param fields 完整字段
|
||||||
|
* @param ttl 有效期
|
||||||
|
* @return 改写成功时为 {@code true}
|
||||||
|
*/
|
||||||
|
default boolean rewriteAsFields(String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
Duration ttl) {
|
||||||
|
throw new UnsupportedOperationException("Field storage is not supported");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package tech.easyflow.common.cache;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentMatchers;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.script.RedisScript;
|
||||||
|
|
||||||
|
import java.lang.reflect.Constructor;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link RedisIdempotencyExecutor} 状态转换回归测试。
|
||||||
|
*/
|
||||||
|
public class RedisIdempotencyExecutorTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证存在测试构造器时生产构造器仍能被 Spring 明确选择。
|
||||||
|
*
|
||||||
|
* @throws Exception 生产构造器不存在时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void productionConstructorShouldBeAutowired()
|
||||||
|
throws Exception {
|
||||||
|
Constructor<RedisIdempotencyExecutor> constructor =
|
||||||
|
RedisIdempotencyExecutor.class.getConstructor(
|
||||||
|
StringRedisTemplate.class);
|
||||||
|
|
||||||
|
Assert.assertNotNull(
|
||||||
|
constructor.getAnnotation(Autowired.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证首次认领会执行操作并写入完成凭证。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void executeOnceShouldRunAndCompleteForNewKey() {
|
||||||
|
StringRedisTemplate redisTemplate = redisTemplateReturning(1L, 1L);
|
||||||
|
RedisIdempotencyExecutor executor = new RedisIdempotencyExecutor(redisTemplate);
|
||||||
|
AtomicInteger executions = new AtomicInteger();
|
||||||
|
|
||||||
|
boolean executed = executor.executeOnce(
|
||||||
|
"workflow:instance:node:trigger", executions::incrementAndGet);
|
||||||
|
|
||||||
|
Assert.assertTrue(executed);
|
||||||
|
Assert.assertEquals(1, executions.get());
|
||||||
|
Mockito.verify(redisTemplate).execute(
|
||||||
|
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||||
|
ArgumentMatchers.<List<String>>any(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString());
|
||||||
|
Mockito.verify(redisTemplate).execute(
|
||||||
|
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||||
|
ArgumentMatchers.<List<String>>any(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证已有完成凭证时直接跳过副作用操作。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void executeOnceShouldSkipCompletedKey() {
|
||||||
|
StringRedisTemplate redisTemplate = redisTemplateReturning(2L);
|
||||||
|
RedisIdempotencyExecutor executor = new RedisIdempotencyExecutor(redisTemplate);
|
||||||
|
AtomicInteger executions = new AtomicInteger();
|
||||||
|
|
||||||
|
boolean executed = executor.executeOnce(
|
||||||
|
"workflow:instance:node:trigger", executions::incrementAndGet);
|
||||||
|
|
||||||
|
Assert.assertFalse(executed);
|
||||||
|
Assert.assertEquals(0, executions.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证其他 owner 仍在处理时返回明确冲突。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void executeOnceShouldRejectInProgressKey() {
|
||||||
|
StringRedisTemplate redisTemplate = redisTemplateReturning(0L);
|
||||||
|
RedisIdempotencyExecutor executor = new RedisIdempotencyExecutor(redisTemplate);
|
||||||
|
|
||||||
|
try {
|
||||||
|
executor.executeOnce("workflow:instance:node:trigger", () -> {
|
||||||
|
});
|
||||||
|
Assert.fail("in-progress operation must be rejected");
|
||||||
|
} catch (RedisIdempotencyExecutor.IdempotentOperationInProgressException expected) {
|
||||||
|
Assert.assertTrue(expected.getMessage().contains("workflow:instance"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证同一幂等键绑定不同负载时明确拒绝,避免把不同业务写入误判为已完成。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void executeOnceShouldRejectPayloadMismatch() {
|
||||||
|
StringRedisTemplate redisTemplate = redisTemplateReturning(-1L);
|
||||||
|
RedisIdempotencyExecutor executor = new RedisIdempotencyExecutor(redisTemplate);
|
||||||
|
|
||||||
|
try {
|
||||||
|
executor.executeOnce(
|
||||||
|
"workflow:instance:node:trigger",
|
||||||
|
"different-payload-hash",
|
||||||
|
() -> {
|
||||||
|
});
|
||||||
|
Assert.fail("payload mismatch must be rejected");
|
||||||
|
} catch (RedisIdempotencyExecutor.IdempotencyPayloadMismatchException expected) {
|
||||||
|
Assert.assertTrue(expected.getMessage().contains("workflow:instance"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证长操作会在处理中凭证到期前持续续期。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试等待被中断
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void executeOnceShouldRenewLeaseForLongOperation()
|
||||||
|
throws Exception {
|
||||||
|
StringRedisTemplate redisTemplate =
|
||||||
|
redisTemplateReturning(1L, 1L);
|
||||||
|
Mockito.when(redisTemplate.execute(
|
||||||
|
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||||
|
ArgumentMatchers.<List<String>>any(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString()))
|
||||||
|
.thenReturn(1L);
|
||||||
|
RedisIdempotencyExecutor executor =
|
||||||
|
new RedisIdempotencyExecutor(
|
||||||
|
redisTemplate,
|
||||||
|
Duration.ofMillis(60L),
|
||||||
|
Duration.ofMinutes(1L));
|
||||||
|
|
||||||
|
Assert.assertTrue(executor.executeOnce(
|
||||||
|
"workflow:long-operation",
|
||||||
|
() -> {
|
||||||
|
try {
|
||||||
|
Thread.sleep(90L);
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"test interrupted", exception);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Mockito.verify(redisTemplate, Mockito.atLeastOnce()).execute(
|
||||||
|
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||||
|
ArgumentMatchers.<List<String>>any(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建按顺序返回脚本结果的 Redis 模板。
|
||||||
|
*
|
||||||
|
* @param results 脚本返回值
|
||||||
|
* @return Redis 模板
|
||||||
|
*/
|
||||||
|
private StringRedisTemplate redisTemplateReturning(Long... results) {
|
||||||
|
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
|
||||||
|
Mockito.when(redisTemplate.execute(
|
||||||
|
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||||
|
ArgumentMatchers.<List<String>>any(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString()
|
||||||
|
)).thenReturn(results[0]);
|
||||||
|
Mockito.when(redisTemplate.execute(
|
||||||
|
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||||
|
ArgumentMatchers.<List<String>>any(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString()
|
||||||
|
)).thenReturn(results[Math.min(1, results.length - 1)]);
|
||||||
|
return redisTemplate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,74 @@ public class RedisLockExecutorTest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 fencing token 通过 Redis 原子脚本分配并返回。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入异常
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void nextFencingTokenShouldReturnAtomicSequenceValue() throws Exception {
|
||||||
|
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
|
||||||
|
Mockito.when(redisTemplate.execute(
|
||||||
|
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||||
|
ArgumentMatchers.<List<String>>any(),
|
||||||
|
ArgumentMatchers.<Object[]>any()
|
||||||
|
)).thenReturn(9L);
|
||||||
|
|
||||||
|
RedisLockExecutor executor = new RedisLockExecutor();
|
||||||
|
setRedisTemplate(executor, redisTemplate);
|
||||||
|
|
||||||
|
long token = executor.nextFencingToken(
|
||||||
|
"workflowState:{instance}:fence",
|
||||||
|
Duration.ofDays(4));
|
||||||
|
|
||||||
|
Assert.assertEquals(9L, token);
|
||||||
|
Mockito.verify(redisTemplate).execute(
|
||||||
|
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||||
|
ArgumentMatchers.eq(List.of("workflowState:{instance}:fence")),
|
||||||
|
ArgumentMatchers.eq(String.valueOf(Duration.ofDays(4).toMillis()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证互斥锁与 fencing token 通过同一个 Redis 脚本原子获取。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入异常
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void tryAcquireFencedShouldSetLockAndAdvanceTokenAtomically() throws Exception {
|
||||||
|
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
|
||||||
|
Mockito.when(redisTemplate.execute(
|
||||||
|
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||||
|
ArgumentMatchers.<List<String>>any(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString()
|
||||||
|
)).thenReturn(17L);
|
||||||
|
|
||||||
|
RedisLockExecutor executor = new RedisLockExecutor();
|
||||||
|
setRedisTemplate(executor, redisTemplate);
|
||||||
|
|
||||||
|
RedisLockExecutor.LockHandle handle = executor.tryAcquireFenced(
|
||||||
|
"chainLock:{instance}",
|
||||||
|
"workflowState:{instance}:fence",
|
||||||
|
Duration.ZERO,
|
||||||
|
Duration.ofSeconds(30),
|
||||||
|
Duration.ofDays(4));
|
||||||
|
|
||||||
|
Assert.assertNotNull(handle);
|
||||||
|
Assert.assertEquals(17L, handle.getFencingToken());
|
||||||
|
Mockito.verify(redisTemplate).execute(
|
||||||
|
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||||
|
ArgumentMatchers.eq(List.of(
|
||||||
|
"chainLock:{instance}",
|
||||||
|
"workflowState:{instance}:fence")),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.eq("30000"),
|
||||||
|
ArgumentMatchers.eq(
|
||||||
|
String.valueOf(Duration.ofDays(4).toMillis())));
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private ValueOperations<String, String> mockValueOperations(boolean acquired) {
|
private ValueOperations<String, String> mockValueOperations(boolean acquired) {
|
||||||
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
|
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package tech.easyflow.common.cache;
|
||||||
|
|
||||||
|
import com.alicp.jetcache.support.JavaValueEncoder;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentMatchers;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnection;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.connection.RedisHashCommands;
|
||||||
|
import org.springframework.data.redis.connection.RedisScriptingCommands;
|
||||||
|
import org.springframework.data.redis.connection.ReturnType;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link RedisVersionedObjectStore} 二进制协议与脚本调用回归测试。
|
||||||
|
*/
|
||||||
|
public class RedisVersionedObjectStoreTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证状态对象继续按项目 Java 序列化协议读取。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void loadShouldDecodeExistingJavaPayload() {
|
||||||
|
RedisConnectionFactory connectionFactory = Mockito.mock(RedisConnectionFactory.class);
|
||||||
|
RedisConnection connection = Mockito.mock(RedisConnection.class);
|
||||||
|
RedisHashCommands hashCommands = Mockito.mock(RedisHashCommands.class);
|
||||||
|
Mockito.when(connectionFactory.getConnection()).thenReturn(connection);
|
||||||
|
Mockito.when(connection.hashCommands()).thenReturn(hashCommands);
|
||||||
|
Mockito.when(hashCommands.hGet(
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class)
|
||||||
|
)).thenReturn(JavaValueEncoder.INSTANCE.apply("state-value"));
|
||||||
|
RedisVersionedObjectStore store = new RedisVersionedObjectStore(connectionFactory);
|
||||||
|
|
||||||
|
String loaded = store.load("workflowState:{instance}:chain", String.class);
|
||||||
|
|
||||||
|
Assert.assertEquals("state-value", loaded);
|
||||||
|
Mockito.verify(connection).close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 CAS 更新通过单次 Redis Lua 调用完成。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void compareAndSetShouldUseSingleAtomicScript() {
|
||||||
|
RedisConnectionFactory connectionFactory = Mockito.mock(RedisConnectionFactory.class);
|
||||||
|
RedisConnection connection = Mockito.mock(RedisConnection.class);
|
||||||
|
RedisScriptingCommands scriptingCommands = Mockito.mock(RedisScriptingCommands.class);
|
||||||
|
Mockito.when(connectionFactory.getConnection()).thenReturn(connection);
|
||||||
|
Mockito.when(connection.scriptingCommands()).thenReturn(scriptingCommands);
|
||||||
|
Mockito.when(scriptingCommands.eval(
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.eq(ReturnType.INTEGER),
|
||||||
|
ArgumentMatchers.eq(1),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class)
|
||||||
|
)).thenReturn(1L);
|
||||||
|
RedisVersionedObjectStore store = new RedisVersionedObjectStore(connectionFactory);
|
||||||
|
|
||||||
|
boolean updated = store.compareAndSet(
|
||||||
|
"workflowState:{instance}:chain",
|
||||||
|
3L,
|
||||||
|
"state-value",
|
||||||
|
4L,
|
||||||
|
Duration.ofDays(3));
|
||||||
|
|
||||||
|
Assert.assertTrue(updated);
|
||||||
|
Mockito.verify(scriptingCommands, Mockito.times(1)).eval(
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.eq(ReturnType.INTEGER),
|
||||||
|
ArgumentMatchers.eq(1),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class));
|
||||||
|
Mockito.verify(connection).close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证节点状态更新在同一脚本内校验链版本和 fencing token。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void compareAndSetFieldsShouldUseDoubleGuardedAtomicScript() {
|
||||||
|
RedisConnectionFactory connectionFactory = Mockito.mock(RedisConnectionFactory.class);
|
||||||
|
RedisConnection connection = Mockito.mock(RedisConnection.class);
|
||||||
|
RedisScriptingCommands scriptingCommands = Mockito.mock(RedisScriptingCommands.class);
|
||||||
|
Mockito.when(connectionFactory.getConnection()).thenReturn(connection);
|
||||||
|
Mockito.when(connection.scriptingCommands()).thenReturn(scriptingCommands);
|
||||||
|
Mockito.when(scriptingCommands.eval(
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.eq(ReturnType.INTEGER),
|
||||||
|
ArgumentMatchers.eq(3),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class)
|
||||||
|
)).thenReturn(1L);
|
||||||
|
RedisVersionedObjectStore store = new RedisVersionedObjectStore(connectionFactory);
|
||||||
|
|
||||||
|
boolean updated = store.compareAndSetFields(
|
||||||
|
"workflowState:{instance}:node:node-1",
|
||||||
|
2L,
|
||||||
|
Map.of("status", "RUNNING"),
|
||||||
|
3L,
|
||||||
|
"workflowState:{instance}:chain",
|
||||||
|
8L,
|
||||||
|
"workflowState:{instance}:fence",
|
||||||
|
12L,
|
||||||
|
Duration.ofDays(3));
|
||||||
|
|
||||||
|
Assert.assertTrue(updated);
|
||||||
|
Mockito.verify(scriptingCommands).eval(
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.eq(ReturnType.INTEGER),
|
||||||
|
ArgumentMatchers.eq(3),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class),
|
||||||
|
ArgumentMatchers.any(byte[].class));
|
||||||
|
Mockito.verify(connection).close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ public class RedisMQProducer implements MQProducer {
|
|||||||
int shardCount = Math.max(properties.getRedis().getChatPersistShardCount(), 1);
|
int shardCount = Math.max(properties.getRedis().getChatPersistShardCount(), 1);
|
||||||
int shard = keySupport.resolveShard(message.getKey(), shardCount);
|
int shard = keySupport.resolveShard(message.getKey(), shardCount);
|
||||||
String streamKey = keySupport.streamKey(message.getTopic(), shard);
|
String streamKey = keySupport.streamKey(message.getTopic(), shard);
|
||||||
LOG.info("MQ 开始投递消息: topic={}, messageId={}, key={}, shard={}, streamKey={}",
|
LOG.debug("MQ 开始投递消息: topic={}, messageId={}, key={}, shard={}, streamKey={}",
|
||||||
message.getTopic(), message.getMessageId(), message.getKey(), shard, streamKey);
|
message.getTopic(), message.getMessageId(), message.getKey(), shard, streamKey);
|
||||||
RecordId recordId = stringRedisTemplate.opsForStream().add(
|
RecordId recordId = stringRedisTemplate.opsForStream().add(
|
||||||
StreamRecords.string(Map.of("payload", messageConverter.serialize(message))).withStreamKey(streamKey)
|
StreamRecords.string(Map.of("payload", messageConverter.serialize(message))).withStreamKey(streamKey)
|
||||||
@@ -59,7 +59,7 @@ public class RedisMQProducer implements MQProducer {
|
|||||||
if (recordId == null) {
|
if (recordId == null) {
|
||||||
throw new MQException("MQ 消息投递失败");
|
throw new MQException("MQ 消息投递失败");
|
||||||
}
|
}
|
||||||
LOG.info("MQ 消息投递完成: topic={}, messageId={}, key={}, shard={}, streamKey={}, recordId={}",
|
LOG.debug("MQ 消息投递完成: topic={}, messageId={}, key={}, shard={}, streamKey={}, recordId={}",
|
||||||
message.getTopic(), message.getMessageId(), message.getKey(), shard, streamKey, recordId.getValue());
|
message.getTopic(), message.getMessageId(), message.getKey(), shard, streamKey, recordId.getValue());
|
||||||
return recordId.getValue();
|
return recordId.getValue();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,11 @@
|
|||||||
<artifactId>spring-boot-actuator</artifactId>
|
<artifactId>spring-boot-actuator</artifactId>
|
||||||
<version>${spring-boot.version}</version>
|
<version>${spring-boot.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.micrometer</groupId>
|
||||||
|
<artifactId>micrometer-core</artifactId>
|
||||||
|
<version>1.15.7</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.easyagents</groupId>
|
<groupId>com.easyagents</groupId>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
import tech.easyflow.ai.easyagents.CustomMultipartFile;
|
import tech.easyflow.ai.easyagents.CustomMultipartFile;
|
||||||
import tech.easyflow.ai.entity.Plugin;
|
import tech.easyflow.ai.entity.Plugin;
|
||||||
import tech.easyflow.ai.entity.PluginItem;
|
import tech.easyflow.ai.entity.PluginItem;
|
||||||
|
import tech.easyflow.ai.node.TemporaryFileMultipartFile;
|
||||||
import tech.easyflow.ai.mapper.PluginMapper;
|
import tech.easyflow.ai.mapper.PluginMapper;
|
||||||
import tech.easyflow.ai.service.PluginItemService;
|
import tech.easyflow.ai.service.PluginItemService;
|
||||||
import tech.easyflow.common.ai.plugin.NestedParamConverter;
|
import tech.easyflow.common.ai.plugin.NestedParamConverter;
|
||||||
@@ -23,10 +24,13 @@ import tech.easyflow.common.ai.plugin.PluginParamConverter;
|
|||||||
import tech.easyflow.common.filestorage.FileStorageManager;
|
import tech.easyflow.common.filestorage.FileStorageManager;
|
||||||
import tech.easyflow.common.filestorage.FileStorageService;
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
import tech.easyflow.common.util.SpringContextUtil;
|
import tech.easyflow.common.util.SpringContextUtil;
|
||||||
|
import com.easyagents.flow.core.util.IoBulkhead;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.lang.reflect.Array;
|
import java.lang.reflect.Array;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
public class PluginTool extends BaseTool {
|
public class PluginTool extends BaseTool {
|
||||||
@@ -36,6 +40,8 @@ public class PluginTool extends BaseTool {
|
|||||||
private String name;
|
private String name;
|
||||||
private String description;
|
private String description;
|
||||||
private Parameter[] parameters;
|
private Parameter[] parameters;
|
||||||
|
private transient PluginItem pluginItemSnapshot;
|
||||||
|
private transient Plugin pluginSnapshot;
|
||||||
private static final Logger logger = LoggerFactory.getLogger(PluginTool.class);
|
private static final Logger logger = LoggerFactory.getLogger(PluginTool.class);
|
||||||
|
|
||||||
public PluginTool() {
|
public PluginTool() {
|
||||||
@@ -43,9 +49,21 @@ public class PluginTool extends BaseTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public PluginTool(PluginItem pluginItem) {
|
public PluginTool(PluginItem pluginItem) {
|
||||||
|
this(pluginItem, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用已加载实体快照创建插件工具。
|
||||||
|
*
|
||||||
|
* @param pluginItem 插件项快照
|
||||||
|
* @param plugin 插件快照,可为空
|
||||||
|
*/
|
||||||
|
public PluginTool(PluginItem pluginItem, Plugin plugin) {
|
||||||
this.name = pluginItem.getEnglishName();
|
this.name = pluginItem.getEnglishName();
|
||||||
this.description = pluginItem.getDescription();
|
this.description = pluginItem.getDescription();
|
||||||
this.pluginToolId = pluginItem.getId();
|
this.pluginToolId = pluginItem.getId();
|
||||||
|
this.pluginItemSnapshot = pluginItem;
|
||||||
|
this.pluginSnapshot = plugin;
|
||||||
this.parameters = getDefaultParameters(pluginItem.getInputData());
|
this.parameters = getDefaultParameters(pluginItem.getInputData());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,18 +98,7 @@ public class PluginTool extends BaseTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Parameter[] getDefaultParameters(String inputData) {
|
private Parameter[] getDefaultParameters(String inputData) {
|
||||||
PluginItemService pluginToolService = SpringContextUtil.getBean(PluginItemService.class);
|
List<Map<String, Object>> dataList = getDataList(inputData);
|
||||||
QueryWrapper queryAiPluginToolWrapper = QueryWrapper.create()
|
|
||||||
.select("*")
|
|
||||||
.from("tb_plugin_item")
|
|
||||||
.where("id = ? ", this.pluginToolId);
|
|
||||||
PluginItem pluginItem = pluginToolService.getMapper().selectOneByQuery(queryAiPluginToolWrapper);
|
|
||||||
List<Map<String, Object>> dataList = null;
|
|
||||||
if (pluginItem == null || pluginItem.getInputData() == null){
|
|
||||||
dataList = getDataList(inputData);
|
|
||||||
} else {
|
|
||||||
dataList = getDataList(pluginItem.getInputData());
|
|
||||||
}
|
|
||||||
Parameter[] params = new Parameter[dataList.size()];
|
Parameter[] params = new Parameter[dataList.size()];
|
||||||
for (int i = 0; i < dataList.size(); i++) {
|
for (int i = 0; i < dataList.size(); i++) {
|
||||||
Map<String, Object> item = dataList.get(i);
|
Map<String, Object> item = dataList.get(i);
|
||||||
@@ -147,14 +154,16 @@ public class PluginTool extends BaseTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Object runPluginTool(Map<String, Object> argsMap, String inputData, BigInteger pluginId){
|
public Object runPluginTool(Map<String, Object> argsMap, String inputData, BigInteger pluginId){
|
||||||
PluginItemService pluginToolService = SpringContextUtil.getBean(PluginItemService.class);
|
PluginItem pluginItem = pluginItemSnapshot != null
|
||||||
QueryWrapper queryAiPluginToolWrapper = QueryWrapper.create()
|
&& Objects.equals(pluginItemSnapshot.getId(), pluginId)
|
||||||
.select("*")
|
? pluginItemSnapshot
|
||||||
.from("tb_plugin_item")
|
: loadPluginItem(pluginId);
|
||||||
.where("id = ? ", pluginId);
|
|
||||||
PluginItem pluginItem = pluginToolService.getMapper().selectOneByQuery(queryAiPluginToolWrapper);
|
|
||||||
String method = pluginItem.getRequestMethod().toUpperCase();
|
String method = pluginItem.getRequestMethod().toUpperCase();
|
||||||
Plugin plugin = getAiPlugin(pluginItem.getPluginId());
|
Plugin plugin = pluginSnapshot != null
|
||||||
|
&& Objects.equals(
|
||||||
|
pluginSnapshot.getId(), pluginItem.getPluginId())
|
||||||
|
? pluginSnapshot
|
||||||
|
: getAiPlugin(pluginItem.getPluginId());
|
||||||
|
|
||||||
String url;
|
String url;
|
||||||
if (!StrUtil.isEmpty(pluginItem.getBasePath())) {
|
if (!StrUtil.isEmpty(pluginItem.getBasePath())) {
|
||||||
@@ -200,6 +209,8 @@ public class PluginTool extends BaseTool {
|
|||||||
List<PluginParam> pathParams = new ArrayList<>();
|
List<PluginParam> pathParams = new ArrayList<>();
|
||||||
Map<String, Object> nestedParams = NestedParamConverter.convertToNestedParamMap(pluginParams);
|
Map<String, Object> nestedParams = NestedParamConverter.convertToNestedParamMap(pluginParams);
|
||||||
|
|
||||||
|
List<Path> temporaryFiles = new ArrayList<>();
|
||||||
|
try {
|
||||||
// 遍历嵌套参数
|
// 遍历嵌套参数
|
||||||
for (Map.Entry<String, Object> entry : nestedParams.entrySet()) {
|
for (Map.Entry<String, Object> entry : nestedParams.entrySet()) {
|
||||||
String paramName = entry.getKey();
|
String paramName = entry.getKey();
|
||||||
@@ -234,15 +245,37 @@ public class PluginTool extends BaseTool {
|
|||||||
// 如果是文件类型
|
// 如果是文件类型
|
||||||
if (originalParam.getType().equals("File")){
|
if (originalParam.getType().equals("File")){
|
||||||
try {
|
try {
|
||||||
FileStorageService fileStorageService = SpringContextUtil.getBean(FileStorageManager.class);
|
FileStorageService fileStorageService =
|
||||||
InputStream inputStream = fileStorageService.readStream((String)originalParam.getDefaultValue());
|
SpringContextUtil.getBean(
|
||||||
requestParam.setType("MultipartFile");
|
FileStorageManager.class);
|
||||||
byte[] bytes = inputStreamToBytes(inputStream);
|
String fileUrl =
|
||||||
String contentType = FileTypeUtil.getType(new ByteArrayInputStream(bytes));
|
(String) originalParam.getDefaultValue();
|
||||||
String fileUrl = (String) originalParam.getDefaultValue();
|
|
||||||
int lastSlashIndex = fileUrl.lastIndexOf("/");
|
int lastSlashIndex = fileUrl.lastIndexOf("/");
|
||||||
String fileName = fileUrl.substring(lastSlashIndex + 1);
|
String fileName =
|
||||||
requestParam.setDefaultValue(new CustomMultipartFile(bytes, originalParam.getName(), fileName, contentType));
|
fileUrl.substring(lastSlashIndex + 1);
|
||||||
|
Path temporaryFile = Files.createTempFile(
|
||||||
|
"easyflow-plugin-", ".upload");
|
||||||
|
try (IoBulkhead.Permit ignored =
|
||||||
|
IoBulkhead.storage().acquire(
|
||||||
|
"storage:plugin-read");
|
||||||
|
InputStream inputStream =
|
||||||
|
fileStorageService.readStream(fileUrl);
|
||||||
|
OutputStream outputStream =
|
||||||
|
Files.newOutputStream(temporaryFile)) {
|
||||||
|
copyBounded(
|
||||||
|
inputStream,
|
||||||
|
outputStream,
|
||||||
|
Long.getLong(
|
||||||
|
"easyflow.plugin.file.max-bytes",
|
||||||
|
256L * 1024L * 1024L));
|
||||||
|
}
|
||||||
|
temporaryFiles.add(temporaryFile);
|
||||||
|
requestParam.setType("MultipartFile");
|
||||||
|
requestParam.setDefaultValue(
|
||||||
|
new TemporaryFileMultipartFile(
|
||||||
|
fileName,
|
||||||
|
temporaryFile,
|
||||||
|
null));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
@@ -283,6 +316,59 @@ public class PluginTool extends BaseTool {
|
|||||||
logger.error(result.get("error").toString());
|
logger.error(result.get("error").toString());
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
} finally {
|
||||||
|
for (Path temporaryFile : temporaryFiles) {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(temporaryFile);
|
||||||
|
} catch (IOException cleanupError) {
|
||||||
|
logger.warn(
|
||||||
|
"清理插件上传临时文件失败,path={}",
|
||||||
|
temporaryFile,
|
||||||
|
cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 ID 加载插件项。
|
||||||
|
*
|
||||||
|
* @param pluginId 插件项 ID
|
||||||
|
* @return 插件项
|
||||||
|
*/
|
||||||
|
private PluginItem loadPluginItem(BigInteger pluginId) {
|
||||||
|
PluginItemService pluginToolService =
|
||||||
|
SpringContextUtil.getBean(PluginItemService.class);
|
||||||
|
QueryWrapper query = QueryWrapper.create()
|
||||||
|
.select("*")
|
||||||
|
.from("tb_plugin_item")
|
||||||
|
.where("id = ? ", pluginId);
|
||||||
|
return pluginToolService.getMapper().selectOneByQuery(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用固定缓冲区复制文件并校验实际字节数。
|
||||||
|
*
|
||||||
|
* @param inputStream 输入流
|
||||||
|
* @param outputStream 输出流
|
||||||
|
* @param maxBytes 最大字节数
|
||||||
|
* @throws IOException 读取失败或超限
|
||||||
|
*/
|
||||||
|
private void copyBounded(
|
||||||
|
InputStream inputStream,
|
||||||
|
OutputStream outputStream,
|
||||||
|
long maxBytes) throws IOException {
|
||||||
|
byte[] buffer = new byte[64 * 1024];
|
||||||
|
long total = 0L;
|
||||||
|
int read;
|
||||||
|
while ((read = inputStream.read(buffer)) != -1) {
|
||||||
|
total += read;
|
||||||
|
if (maxBytes > 0L && total > maxBytes) {
|
||||||
|
throw new IOException(
|
||||||
|
"插件文件超过字节上限: " + maxBytes);
|
||||||
|
}
|
||||||
|
outputStream.write(buffer, 0, read);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 辅助方法:根据参数名查找原始参数定义
|
// 辅助方法:根据参数名查找原始参数定义
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package tech.easyflow.ai.easyagentsflow.code;
|
|||||||
import com.alibaba.fastjson2.JSON;
|
import com.alibaba.fastjson2.JSON;
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.easyagents.flow.core.chain.Chain;
|
import com.easyagents.flow.core.chain.Chain;
|
||||||
|
import com.easyagents.flow.core.chain.ChainState;
|
||||||
import com.easyagents.flow.core.code.CodeRuntimeEngine;
|
import com.easyagents.flow.core.code.CodeRuntimeEngine;
|
||||||
import com.easyagents.flow.core.node.CodeNode;
|
import com.easyagents.flow.core.node.CodeNode;
|
||||||
import com.easyagents.flow.core.util.StringUtil;
|
import com.easyagents.flow.core.util.StringUtil;
|
||||||
@@ -218,20 +219,24 @@ public class PythonRuntimeEngine implements CodeRuntimeEngine {
|
|||||||
|
|
||||||
private Map<String, Object> buildContext(Chain chain, CodeNode node) {
|
private Map<String, Object> buildContext(Chain chain, CodeNode node) {
|
||||||
Map<String, Object> context = new HashMap<>();
|
Map<String, Object> context = new HashMap<>();
|
||||||
|
ChainState chainState =
|
||||||
|
chain.getExecutionState();
|
||||||
|
|
||||||
Map<String, Object> all = chain.getState().getMemory();
|
Map<String, Object> all =
|
||||||
|
chainState.getMemory();
|
||||||
all.forEach((key, value) -> {
|
all.forEach((key, value) -> {
|
||||||
if (!key.contains(".")) {
|
if (!key.contains(".")) {
|
||||||
context.put(key, value);
|
context.put(key, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Map<String, Object> parameterValues = chain.getState().resolveParameters(node);
|
Map<String, Object> parameterValues =
|
||||||
|
chainState.resolveParameters(node);
|
||||||
if (parameterValues != null && !parameterValues.isEmpty()) {
|
if (parameterValues != null && !parameterValues.isEmpty()) {
|
||||||
context.putAll(parameterValues);
|
context.putAll(parameterValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.put("_env", chain.getState().getEnvMap());
|
context.put("_env", chainState.getEnvMap());
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package tech.easyflow.ai.easyagentsflow.config;
|
package tech.easyflow.ai.easyagentsflow.config;
|
||||||
|
|
||||||
import com.easyagents.flow.core.chain.repository.ChainDefinitionRepository;
|
import com.easyagents.flow.core.chain.repository.ChainDefinitionRepository;
|
||||||
|
import com.easyagents.flow.core.chain.repository.ChainDefinitionSnapshotRepository;
|
||||||
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
||||||
|
import com.easyagents.flow.core.chain.repository.LoopResultRepository;
|
||||||
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
|
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
|
import com.easyagents.flow.core.chain.runtime.TriggerScheduler;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import tech.easyflow.ai.easyagentsflow.listener.ChainErrorListenerForSave;
|
import tech.easyflow.ai.easyagentsflow.listener.ChainErrorListenerForSave;
|
||||||
@@ -11,8 +15,10 @@ import tech.easyflow.ai.easyagentsflow.listener.ChainEventListenerForSave;
|
|||||||
import tech.easyflow.ai.easyagentsflow.listener.NodeErrorListenerForSave;
|
import tech.easyflow.ai.easyagentsflow.listener.NodeErrorListenerForSave;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(WorkflowExecutionBudgetProperties.class)
|
||||||
public class ChainExecutorConfig {
|
public class ChainExecutorConfig {
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
@@ -22,14 +28,51 @@ public class ChainExecutorConfig {
|
|||||||
@Resource
|
@Resource
|
||||||
private NodeStateRepository nodeStateRepository;
|
private NodeStateRepository nodeStateRepository;
|
||||||
@Resource
|
@Resource
|
||||||
|
private LoopResultRepository loopResultRepository;
|
||||||
|
@Resource
|
||||||
|
private ChainDefinitionSnapshotRepository chainDefinitionSnapshotRepository;
|
||||||
|
@Resource
|
||||||
|
private TriggerScheduler triggerScheduler;
|
||||||
|
@Resource
|
||||||
private ChainEventListenerForSave chainEventListenerForSave;
|
private ChainEventListenerForSave chainEventListenerForSave;
|
||||||
|
@Resource
|
||||||
|
private WorkflowExecutionBudgetProperties workflowExecutionBudgetProperties;
|
||||||
|
@Resource
|
||||||
|
private WorkflowRuntimeProperties workflowRuntimeProperties;
|
||||||
|
|
||||||
@Bean(name = "chainExecutor")
|
@Bean(name = "chainExecutor")
|
||||||
public ChainExecutor chainExecutor() {
|
public ChainExecutor chainExecutor() {
|
||||||
|
|
||||||
ChainExecutor chainExecutor = new ChainExecutor(chainDefinitionRepository,
|
ChainExecutor chainExecutor = new ChainExecutor(chainDefinitionRepository,
|
||||||
chainStateRepository,
|
chainStateRepository,
|
||||||
nodeStateRepository);
|
nodeStateRepository,
|
||||||
|
loopResultRepository,
|
||||||
|
chainDefinitionSnapshotRepository,
|
||||||
|
triggerScheduler,
|
||||||
|
workflowExecutionBudgetProperties.toExecutionBudget());
|
||||||
|
int laneMaxDepth = workflowRuntimeProperties
|
||||||
|
.getChildWorkflowLaneMaxDepth();
|
||||||
|
int budgetMaxDepth = workflowExecutionBudgetProperties
|
||||||
|
.getMaxNestedDepth();
|
||||||
|
if (laneMaxDepth <= 0
|
||||||
|
|| budgetMaxDepth <= 0
|
||||||
|
|| budgetMaxDepth > laneMaxDepth) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"easyflow.workflow.execution-budget.max-nested-depth "
|
||||||
|
+ "must be positive and not exceed "
|
||||||
|
+ "easyflow.workflow.runtime."
|
||||||
|
+ "child-workflow-lane-max-depth");
|
||||||
|
}
|
||||||
|
Duration pollInterval = workflowRuntimeProperties
|
||||||
|
.getChildWorkflowPollInterval();
|
||||||
|
long pollMillis = pollInterval == null
|
||||||
|
? 500L
|
||||||
|
: Math.max(100L, pollInterval.toMillis());
|
||||||
|
chainExecutor.configureChildWorkflowRuntime(
|
||||||
|
Math.max(1, workflowRuntimeProperties
|
||||||
|
.getChildWorkflowRootPermits()),
|
||||||
|
pollMillis,
|
||||||
|
laneMaxDepth);
|
||||||
|
|
||||||
saveStepsListeners(chainExecutor);
|
saveStepsListeners(chainExecutor);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.config;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.runtime.ExecutionBudget;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流执行资源保护预算配置。
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "easyflow.workflow.execution-budget")
|
||||||
|
public class WorkflowExecutionBudgetProperties {
|
||||||
|
|
||||||
|
private long maxIterations = ExecutionBudget.DEFAULT_MAX_ITERATIONS;
|
||||||
|
private Duration maxDuration = Duration.ofMillis(ExecutionBudget.DEFAULT_MAX_DURATION_MILLIS);
|
||||||
|
private long maxChildExecutions = ExecutionBudget.DEFAULT_MAX_CHILD_EXECUTIONS;
|
||||||
|
private long maxAccumulatedBytes = ExecutionBudget.DEFAULT_MAX_ACCUMULATED_BYTES;
|
||||||
|
private int maxNestedDepth = ExecutionBudget.DEFAULT_MAX_NESTED_DEPTH;
|
||||||
|
private long maxHotStateBytes = ExecutionBudget.DEFAULT_MAX_HOT_STATE_BYTES;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为工作流引擎使用的不可变执行预算。
|
||||||
|
*
|
||||||
|
* @return 执行预算
|
||||||
|
*/
|
||||||
|
public ExecutionBudget toExecutionBudget() {
|
||||||
|
long maxDurationMillis = maxDuration == null ? 0L : maxDuration.toMillis();
|
||||||
|
return new ExecutionBudget(
|
||||||
|
maxIterations,
|
||||||
|
maxDurationMillis,
|
||||||
|
maxChildExecutions,
|
||||||
|
maxAccumulatedBytes,
|
||||||
|
maxNestedDepth,
|
||||||
|
maxHotStateBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个执行实例允许的最大循环次数。
|
||||||
|
*
|
||||||
|
* @return 最大循环次数
|
||||||
|
*/
|
||||||
|
public long getMaxIterations() {
|
||||||
|
return maxIterations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置单个执行实例允许的最大循环次数。
|
||||||
|
*
|
||||||
|
* @param maxIterations 最大循环次数;小于等于零表示不限制
|
||||||
|
*/
|
||||||
|
public void setMaxIterations(long maxIterations) {
|
||||||
|
this.maxIterations = maxIterations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个执行实例允许的最大运行时间。
|
||||||
|
*
|
||||||
|
* @return 最大运行时间
|
||||||
|
*/
|
||||||
|
public Duration getMaxDuration() {
|
||||||
|
return maxDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置单个执行实例允许的最大运行时间。
|
||||||
|
*
|
||||||
|
* @param maxDuration 最大运行时间
|
||||||
|
*/
|
||||||
|
public void setMaxDuration(Duration maxDuration) {
|
||||||
|
this.maxDuration = maxDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取允许调度的最大子节点执行次数。
|
||||||
|
*
|
||||||
|
* @return 最大子节点执行次数
|
||||||
|
*/
|
||||||
|
public long getMaxChildExecutions() {
|
||||||
|
return maxChildExecutions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置允许调度的最大子节点执行次数。
|
||||||
|
*
|
||||||
|
* @param maxChildExecutions 最大子节点执行次数;小于等于零表示不限制
|
||||||
|
*/
|
||||||
|
public void setMaxChildExecutions(long maxChildExecutions) {
|
||||||
|
this.maxChildExecutions = maxChildExecutions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取允许累计的结果估算字节数。
|
||||||
|
*
|
||||||
|
* @return 最大累计结果字节数
|
||||||
|
*/
|
||||||
|
public long getMaxAccumulatedBytes() {
|
||||||
|
return maxAccumulatedBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置允许累计的结果估算字节数。
|
||||||
|
*
|
||||||
|
* @param maxAccumulatedBytes 最大累计结果字节数;小于等于零表示不限制
|
||||||
|
*/
|
||||||
|
public void setMaxAccumulatedBytes(long maxAccumulatedBytes) {
|
||||||
|
this.maxAccumulatedBytes = maxAccumulatedBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取循环允许的最大嵌套深度。
|
||||||
|
*
|
||||||
|
* @return 最大嵌套深度
|
||||||
|
*/
|
||||||
|
public int getMaxNestedDepth() {
|
||||||
|
return maxNestedDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置循环允许的最大嵌套深度。
|
||||||
|
*
|
||||||
|
* @param maxNestedDepth 最大嵌套深度;小于等于零表示不限制
|
||||||
|
*/
|
||||||
|
public void setMaxNestedDepth(int maxNestedDepth) {
|
||||||
|
this.maxNestedDepth = maxNestedDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个热状态允许的最大估算字节数。
|
||||||
|
*
|
||||||
|
* @return 最大热状态字节数
|
||||||
|
*/
|
||||||
|
public long getMaxHotStateBytes() {
|
||||||
|
return maxHotStateBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置单个热状态允许的最大估算字节数。
|
||||||
|
*
|
||||||
|
* @param maxHotStateBytes 最大热状态字节数;小于等于零表示不限制
|
||||||
|
*/
|
||||||
|
public void setMaxHotStateBytes(long maxHotStateBytes) {
|
||||||
|
this.maxHotStateBytes = maxHotStateBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.config;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.util.IoBulkhead;
|
||||||
|
import io.micrometer.core.instrument.Gauge;
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.ToDoubleFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将工作流各类 I/O 隔离器运行状态接入 Micrometer。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WorkflowIoBulkheadMetrics {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册工作流 I/O 隔离器指标。
|
||||||
|
*
|
||||||
|
* @param meterRegistry Micrometer 注册表
|
||||||
|
* @param properties 工作流 I/O 配置
|
||||||
|
*/
|
||||||
|
public WorkflowIoBulkheadMetrics(
|
||||||
|
MeterRegistry meterRegistry,
|
||||||
|
WorkflowIoProperties properties) {
|
||||||
|
IoBulkhead.configure(
|
||||||
|
properties.getHttp().toSettings(),
|
||||||
|
properties.getDataset().toSettings(),
|
||||||
|
properties.getStorage().toSettings(),
|
||||||
|
properties.getDocumentParse().toSettings(),
|
||||||
|
properties.getResponseAggregation().toSettings());
|
||||||
|
Map<String, IoBulkhead> lanes = new LinkedHashMap<>();
|
||||||
|
lanes.put("http", IoBulkhead.shared());
|
||||||
|
lanes.put("dataset", IoBulkhead.dataset());
|
||||||
|
lanes.put("storage", IoBulkhead.storage());
|
||||||
|
lanes.put("document_parse", IoBulkhead.documentParse());
|
||||||
|
lanes.put(
|
||||||
|
"response_aggregation",
|
||||||
|
IoBulkhead.responseAggregation());
|
||||||
|
lanes.forEach((lane, bulkhead) -> registerLane(
|
||||||
|
meterRegistry, lane, bulkhead));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册一个资源 lane 的核心容量和等待指标。
|
||||||
|
*
|
||||||
|
* @param registry 指标注册表
|
||||||
|
* @param lane lane 名
|
||||||
|
* @param bulkhead 隔离器
|
||||||
|
*/
|
||||||
|
private void registerLane(
|
||||||
|
MeterRegistry registry,
|
||||||
|
String lane,
|
||||||
|
IoBulkhead bulkhead) {
|
||||||
|
gauge(registry, lane, bulkhead, "in_flight",
|
||||||
|
snapshot -> snapshot.inFlightCount());
|
||||||
|
gauge(registry, lane, bulkhead, "acquired_total",
|
||||||
|
snapshot -> snapshot.acquiredCount());
|
||||||
|
gauge(registry, lane, bulkhead, "rejected_total",
|
||||||
|
snapshot -> snapshot.rejectedCount());
|
||||||
|
gauge(registry, lane, bulkhead, "wait_nanos_total",
|
||||||
|
snapshot -> snapshot.totalWaitNanos());
|
||||||
|
gauge(registry, lane, bulkhead, "available_permits",
|
||||||
|
snapshot -> snapshot.availableGlobalPermits());
|
||||||
|
gauge(registry, lane, bulkhead, "tracked_targets",
|
||||||
|
snapshot -> snapshot.trackedTargetCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册从快照读取的 Gauge。
|
||||||
|
*
|
||||||
|
* @param registry 指标注册表
|
||||||
|
* @param lane lane 名
|
||||||
|
* @param bulkhead 隔离器
|
||||||
|
* @param metric 指标后缀
|
||||||
|
* @param valueFunction 快照取值函数
|
||||||
|
*/
|
||||||
|
private void gauge(
|
||||||
|
MeterRegistry registry,
|
||||||
|
String lane,
|
||||||
|
IoBulkhead bulkhead,
|
||||||
|
String metric,
|
||||||
|
ToDoubleFunction<IoBulkhead.Snapshot> valueFunction) {
|
||||||
|
Gauge.builder(
|
||||||
|
"easyflow.workflow.io." + metric,
|
||||||
|
bulkhead,
|
||||||
|
value -> valueFunction.applyAsDouble(
|
||||||
|
value.snapshot()))
|
||||||
|
.tag("lane", lane)
|
||||||
|
.register(registry);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.config;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.util.IoBulkhead;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流阻塞 I/O 隔离配置。
|
||||||
|
*
|
||||||
|
* <p>全部参数均提供宽松默认值,应用无需新增配置即可保持现有业务行为。</p>
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "easyflow.workflow.io")
|
||||||
|
public class WorkflowIoProperties {
|
||||||
|
|
||||||
|
private Lane http = new Lane(64, 16, Duration.ofSeconds(1), 1_024);
|
||||||
|
private Lane dataset = new Lane(32, 8, Duration.ofSeconds(1), 512);
|
||||||
|
private Lane storage = new Lane(24, 12, Duration.ofSeconds(2), 256);
|
||||||
|
private Lane documentParse = new Lane(8, 4, Duration.ofSeconds(2), 128);
|
||||||
|
private Lane responseAggregation =
|
||||||
|
new Lane(8, 4, Duration.ofSeconds(2), 1_024);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 HTTP 隔离配置。
|
||||||
|
*
|
||||||
|
* @return HTTP 配置
|
||||||
|
*/
|
||||||
|
public Lane getHttp() {
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 HTTP 隔离配置。
|
||||||
|
*
|
||||||
|
* @param http HTTP 配置
|
||||||
|
*/
|
||||||
|
public void setHttp(Lane http) {
|
||||||
|
this.http = http;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据集隔离配置。
|
||||||
|
*
|
||||||
|
* @return 数据集配置
|
||||||
|
*/
|
||||||
|
public Lane getDataset() {
|
||||||
|
return dataset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置数据集隔离配置。
|
||||||
|
*
|
||||||
|
* @param dataset 数据集配置
|
||||||
|
*/
|
||||||
|
public void setDataset(Lane dataset) {
|
||||||
|
this.dataset = dataset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取对象存储隔离配置。
|
||||||
|
*
|
||||||
|
* @return 对象存储配置
|
||||||
|
*/
|
||||||
|
public Lane getStorage() {
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置对象存储隔离配置。
|
||||||
|
*
|
||||||
|
* @param storage 对象存储配置
|
||||||
|
*/
|
||||||
|
public void setStorage(Lane storage) {
|
||||||
|
this.storage = storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文档解析隔离配置。
|
||||||
|
*
|
||||||
|
* @return 文档解析配置
|
||||||
|
*/
|
||||||
|
public Lane getDocumentParse() {
|
||||||
|
return documentParse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置文档解析隔离配置。
|
||||||
|
*
|
||||||
|
* @param documentParse 文档解析配置
|
||||||
|
*/
|
||||||
|
public void setDocumentParse(Lane documentParse) {
|
||||||
|
this.documentParse = documentParse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取响应聚合隔离配置。
|
||||||
|
*
|
||||||
|
* @return 响应聚合配置
|
||||||
|
*/
|
||||||
|
public Lane getResponseAggregation() {
|
||||||
|
return responseAggregation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置响应聚合隔离配置。
|
||||||
|
*
|
||||||
|
* @param responseAggregation 响应聚合配置
|
||||||
|
*/
|
||||||
|
public void setResponseAggregation(Lane responseAggregation) {
|
||||||
|
this.responseAggregation = responseAggregation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单类阻塞 I/O 的容量配置。
|
||||||
|
*/
|
||||||
|
public static class Lane {
|
||||||
|
|
||||||
|
private int maxConcurrency;
|
||||||
|
private int perTargetMaxConcurrency;
|
||||||
|
private Duration acquireTimeout;
|
||||||
|
private int maxTrackedTargets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建供 Spring 绑定使用的空配置对象。
|
||||||
|
*/
|
||||||
|
public Lane() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带默认值的隔离配置。
|
||||||
|
*
|
||||||
|
* @param maxConcurrency 总并发
|
||||||
|
* @param perTargetMaxConcurrency 单目标并发
|
||||||
|
* @param acquireTimeout 许可等待时间
|
||||||
|
* @param maxTrackedTargets 最大目标数
|
||||||
|
*/
|
||||||
|
public Lane(
|
||||||
|
int maxConcurrency,
|
||||||
|
int perTargetMaxConcurrency,
|
||||||
|
Duration acquireTimeout,
|
||||||
|
int maxTrackedTargets) {
|
||||||
|
this.maxConcurrency = maxConcurrency;
|
||||||
|
this.perTargetMaxConcurrency = perTargetMaxConcurrency;
|
||||||
|
this.acquireTimeout = acquireTimeout;
|
||||||
|
this.maxTrackedTargets = maxTrackedTargets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换为引擎配置并完成启动期校验。
|
||||||
|
*
|
||||||
|
* @return 引擎隔离配置
|
||||||
|
* @throws IllegalArgumentException 配置值无效时抛出
|
||||||
|
*/
|
||||||
|
public IoBulkhead.Settings toSettings() {
|
||||||
|
return new IoBulkhead.Settings(
|
||||||
|
maxConcurrency,
|
||||||
|
perTargetMaxConcurrency,
|
||||||
|
acquireTimeout,
|
||||||
|
maxTrackedTargets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取总并发。
|
||||||
|
*
|
||||||
|
* @return 总并发
|
||||||
|
*/
|
||||||
|
public int getMaxConcurrency() {
|
||||||
|
return maxConcurrency;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置总并发。
|
||||||
|
*
|
||||||
|
* @param maxConcurrency 总并发
|
||||||
|
*/
|
||||||
|
public void setMaxConcurrency(int maxConcurrency) {
|
||||||
|
this.maxConcurrency = maxConcurrency;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单目标并发。
|
||||||
|
*
|
||||||
|
* @return 单目标并发
|
||||||
|
*/
|
||||||
|
public int getPerTargetMaxConcurrency() {
|
||||||
|
return perTargetMaxConcurrency;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置单目标并发。
|
||||||
|
*
|
||||||
|
* @param perTargetMaxConcurrency 单目标并发
|
||||||
|
*/
|
||||||
|
public void setPerTargetMaxConcurrency(
|
||||||
|
int perTargetMaxConcurrency) {
|
||||||
|
this.perTargetMaxConcurrency =
|
||||||
|
perTargetMaxConcurrency;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取许可等待时间。
|
||||||
|
*
|
||||||
|
* @return 等待时间
|
||||||
|
*/
|
||||||
|
public Duration getAcquireTimeout() {
|
||||||
|
return acquireTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置许可等待时间。
|
||||||
|
*
|
||||||
|
* @param acquireTimeout 等待时间
|
||||||
|
*/
|
||||||
|
public void setAcquireTimeout(Duration acquireTimeout) {
|
||||||
|
this.acquireTimeout = acquireTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最大目标数。
|
||||||
|
*
|
||||||
|
* @return 最大目标数
|
||||||
|
*/
|
||||||
|
public int getMaxTrackedTargets() {
|
||||||
|
return maxTrackedTargets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置最大目标数。
|
||||||
|
*
|
||||||
|
* @param maxTrackedTargets 最大目标数
|
||||||
|
*/
|
||||||
|
public void setMaxTrackedTargets(int maxTrackedTargets) {
|
||||||
|
this.maxTrackedTargets = maxTrackedTargets;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流调度运行时配置。
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "easyflow.workflow.runtime")
|
||||||
|
public class WorkflowRuntimeProperties {
|
||||||
|
|
||||||
|
private Duration triggerScanInterval = Duration.ofSeconds(5);
|
||||||
|
private int schedulerThreads = 2;
|
||||||
|
private int workerCoreSize = 32;
|
||||||
|
private int workerMaxSize = 256;
|
||||||
|
private int workerQueueCapacity = 256;
|
||||||
|
private int childWorkflowLaneMaxDepth = 32;
|
||||||
|
private int childWorkflowLaneMaxThreads = 16;
|
||||||
|
private int childWorkflowRootPermits = 32;
|
||||||
|
private Duration childWorkflowPollInterval =
|
||||||
|
Duration.ofMillis(500);
|
||||||
|
private int dataWriteBatchSize = 200;
|
||||||
|
private long downloadMaxBytes = 2L * 1024L * 1024L * 1024L;
|
||||||
|
private int definitionCacheMaxEntries = 512;
|
||||||
|
private long definitionCacheMaxBytes =
|
||||||
|
256L * 1024L * 1024L;
|
||||||
|
private Duration definitionCacheExpireAfterAccess = Duration.ofMinutes(30);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取触发器补偿扫描间隔。
|
||||||
|
*
|
||||||
|
* @return 扫描间隔
|
||||||
|
*/
|
||||||
|
public Duration getTriggerScanInterval() {
|
||||||
|
return triggerScanInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置触发器补偿扫描间隔。
|
||||||
|
*
|
||||||
|
* @param triggerScanInterval 扫描间隔
|
||||||
|
*/
|
||||||
|
public void setTriggerScanInterval(Duration triggerScanInterval) {
|
||||||
|
this.triggerScanInterval = triggerScanInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取调度线程数。
|
||||||
|
*
|
||||||
|
* @return 调度线程数
|
||||||
|
*/
|
||||||
|
public int getSchedulerThreads() {
|
||||||
|
return schedulerThreads;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置调度线程数。
|
||||||
|
*
|
||||||
|
* @param schedulerThreads 调度线程数
|
||||||
|
*/
|
||||||
|
public void setSchedulerThreads(int schedulerThreads) {
|
||||||
|
this.schedulerThreads = schedulerThreads;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工作线程核心数。
|
||||||
|
*
|
||||||
|
* @return 核心线程数
|
||||||
|
*/
|
||||||
|
public int getWorkerCoreSize() {
|
||||||
|
return workerCoreSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置工作线程核心数。
|
||||||
|
*
|
||||||
|
* @param workerCoreSize 核心线程数
|
||||||
|
*/
|
||||||
|
public void setWorkerCoreSize(int workerCoreSize) {
|
||||||
|
this.workerCoreSize = workerCoreSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工作线程最大数。
|
||||||
|
*
|
||||||
|
* @return 最大线程数
|
||||||
|
*/
|
||||||
|
public int getWorkerMaxSize() {
|
||||||
|
return workerMaxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置工作线程最大数。
|
||||||
|
*
|
||||||
|
* @param workerMaxSize 最大线程数
|
||||||
|
*/
|
||||||
|
public void setWorkerMaxSize(int workerMaxSize) {
|
||||||
|
this.workerMaxSize = workerMaxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工作队列容量。
|
||||||
|
*
|
||||||
|
* @return 队列容量
|
||||||
|
*/
|
||||||
|
public int getWorkerQueueCapacity() {
|
||||||
|
return workerQueueCapacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置工作队列容量。
|
||||||
|
*
|
||||||
|
* @param workerQueueCapacity 队列容量
|
||||||
|
*/
|
||||||
|
public void setWorkerQueueCapacity(int workerQueueCapacity) {
|
||||||
|
this.workerQueueCapacity = workerQueueCapacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取子工作流独立通道覆盖的最大嵌套深度。
|
||||||
|
*
|
||||||
|
* @return 最大深度
|
||||||
|
*/
|
||||||
|
public int getChildWorkflowLaneMaxDepth() {
|
||||||
|
return childWorkflowLaneMaxDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置子工作流独立通道覆盖的最大嵌套深度。
|
||||||
|
*
|
||||||
|
* @param childWorkflowLaneMaxDepth 最大深度
|
||||||
|
*/
|
||||||
|
public void setChildWorkflowLaneMaxDepth(
|
||||||
|
int childWorkflowLaneMaxDepth) {
|
||||||
|
this.childWorkflowLaneMaxDepth =
|
||||||
|
childWorkflowLaneMaxDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取每个子工作流深度通道的最大线程数。
|
||||||
|
*
|
||||||
|
* @return 最大线程数
|
||||||
|
*/
|
||||||
|
public int getChildWorkflowLaneMaxThreads() {
|
||||||
|
return childWorkflowLaneMaxThreads;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置每个子工作流深度通道的最大线程数。
|
||||||
|
*
|
||||||
|
* @param childWorkflowLaneMaxThreads 最大线程数
|
||||||
|
*/
|
||||||
|
public void setChildWorkflowLaneMaxThreads(
|
||||||
|
int childWorkflowLaneMaxThreads) {
|
||||||
|
this.childWorkflowLaneMaxThreads =
|
||||||
|
childWorkflowLaneMaxThreads;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取根级同步子工作流并发许可数。
|
||||||
|
*
|
||||||
|
* @return 并发许可数
|
||||||
|
*/
|
||||||
|
public int getChildWorkflowRootPermits() {
|
||||||
|
return childWorkflowRootPermits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置根级同步子工作流并发许可数。
|
||||||
|
*
|
||||||
|
* @param childWorkflowRootPermits 并发许可数
|
||||||
|
*/
|
||||||
|
public void setChildWorkflowRootPermits(
|
||||||
|
int childWorkflowRootPermits) {
|
||||||
|
this.childWorkflowRootPermits =
|
||||||
|
childWorkflowRootPermits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取同步等待持久终态的轮询间隔。
|
||||||
|
*
|
||||||
|
* @return 轮询间隔
|
||||||
|
*/
|
||||||
|
public Duration getChildWorkflowPollInterval() {
|
||||||
|
return childWorkflowPollInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置同步等待持久终态的轮询间隔。
|
||||||
|
*
|
||||||
|
* @param childWorkflowPollInterval 轮询间隔
|
||||||
|
*/
|
||||||
|
public void setChildWorkflowPollInterval(
|
||||||
|
Duration childWorkflowPollInterval) {
|
||||||
|
this.childWorkflowPollInterval =
|
||||||
|
childWorkflowPollInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据写入节点单批最大行数。
|
||||||
|
*
|
||||||
|
* @return 单批最大行数
|
||||||
|
*/
|
||||||
|
public int getDataWriteBatchSize() {
|
||||||
|
return dataWriteBatchSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置数据写入节点单批最大行数。
|
||||||
|
*
|
||||||
|
* @param dataWriteBatchSize 单批最大行数
|
||||||
|
*/
|
||||||
|
public void setDataWriteBatchSize(int dataWriteBatchSize) {
|
||||||
|
this.dataWriteBatchSize = dataWriteBatchSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取下载节点允许的最大文件字节数。
|
||||||
|
*
|
||||||
|
* @return 最大文件字节数,小于等于 0 表示不限制
|
||||||
|
*/
|
||||||
|
public long getDownloadMaxBytes() {
|
||||||
|
return downloadMaxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置下载节点允许的最大文件字节数。
|
||||||
|
*
|
||||||
|
* @param downloadMaxBytes 最大文件字节数,小于等于 0 表示不限制
|
||||||
|
*/
|
||||||
|
public void setDownloadMaxBytes(long downloadMaxBytes) {
|
||||||
|
this.downloadMaxBytes = downloadMaxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取本地编译定义缓存最大条目数。
|
||||||
|
*
|
||||||
|
* @return 最大条目数
|
||||||
|
*/
|
||||||
|
public int getDefinitionCacheMaxEntries() {
|
||||||
|
return definitionCacheMaxEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置本地编译定义缓存最大条目数。
|
||||||
|
*
|
||||||
|
* @param definitionCacheMaxEntries 最大条目数
|
||||||
|
*/
|
||||||
|
public void setDefinitionCacheMaxEntries(int definitionCacheMaxEntries) {
|
||||||
|
this.definitionCacheMaxEntries = definitionCacheMaxEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取本地编译定义缓存的最大估算字节数。
|
||||||
|
*
|
||||||
|
* @return 最大字节数
|
||||||
|
*/
|
||||||
|
public long getDefinitionCacheMaxBytes() {
|
||||||
|
return definitionCacheMaxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置本地编译定义缓存的最大估算字节数。
|
||||||
|
*
|
||||||
|
* @param definitionCacheMaxBytes 最大字节数
|
||||||
|
*/
|
||||||
|
public void setDefinitionCacheMaxBytes(
|
||||||
|
long definitionCacheMaxBytes) {
|
||||||
|
this.definitionCacheMaxBytes =
|
||||||
|
definitionCacheMaxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取编译定义缓存访问过期时间。
|
||||||
|
*
|
||||||
|
* @return 访问过期时间
|
||||||
|
*/
|
||||||
|
public Duration getDefinitionCacheExpireAfterAccess() {
|
||||||
|
return definitionCacheExpireAfterAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置编译定义缓存访问过期时间。
|
||||||
|
*
|
||||||
|
* @param definitionCacheExpireAfterAccess 访问过期时间
|
||||||
|
*/
|
||||||
|
public void setDefinitionCacheExpireAfterAccess(Duration definitionCacheExpireAfterAccess) {
|
||||||
|
this.definitionCacheExpireAfterAccess = definitionCacheExpireAfterAccess;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.config;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
|
import com.easyagents.flow.core.chain.runtime.TriggerScheduler;
|
||||||
|
import com.easyagents.flow.core.chain.runtime.TriggerStore;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
|
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||||
|
import java.util.concurrent.SynchronousQueue;
|
||||||
|
import java.util.concurrent.ThreadFactory;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流持久化触发调度器配置。
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties({
|
||||||
|
WorkflowRuntimeProperties.class,
|
||||||
|
WorkflowIoProperties.class
|
||||||
|
})
|
||||||
|
public class WorkflowTriggerSchedulerConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建使用 Redis 触发器仓储的独立调度器。
|
||||||
|
*
|
||||||
|
* @param triggerStore 持久化触发器仓储
|
||||||
|
* @param properties 调度运行时配置
|
||||||
|
* @return 工作流触发调度器
|
||||||
|
*/
|
||||||
|
@Bean(destroyMethod = "shutdown")
|
||||||
|
public TriggerScheduler workflowTriggerScheduler(
|
||||||
|
TriggerStore triggerStore, WorkflowRuntimeProperties properties) {
|
||||||
|
int schedulerThreads = Math.max(1, properties.getSchedulerThreads());
|
||||||
|
int workerCoreSize = Math.max(1, properties.getWorkerCoreSize());
|
||||||
|
int workerMaxSize = Math.max(workerCoreSize, properties.getWorkerMaxSize());
|
||||||
|
int queueCapacity = Math.max(1, properties.getWorkerQueueCapacity());
|
||||||
|
Duration scanInterval = properties.getTriggerScanInterval();
|
||||||
|
long scanIntervalMillis = scanInterval == null ? 5000L : Math.max(1000L, scanInterval.toMillis());
|
||||||
|
|
||||||
|
ScheduledThreadPoolExecutor scheduler = new ScheduledThreadPoolExecutor(
|
||||||
|
schedulerThreads, namedThreadFactory("workflow-trigger-scheduler"));
|
||||||
|
scheduler.setRemoveOnCancelPolicy(true);
|
||||||
|
ThreadPoolExecutor worker = new ThreadPoolExecutor(
|
||||||
|
workerCoreSize,
|
||||||
|
workerMaxSize,
|
||||||
|
60L,
|
||||||
|
TimeUnit.SECONDS,
|
||||||
|
new LinkedBlockingQueue<>(queueCapacity),
|
||||||
|
namedThreadFactory("workflow-node-worker"),
|
||||||
|
new ThreadPoolExecutor.AbortPolicy());
|
||||||
|
TriggerScheduler triggerScheduler = new TriggerScheduler(
|
||||||
|
triggerStore, scheduler, worker, scanIntervalMillis);
|
||||||
|
int childLaneMaxDepth = Math.max(
|
||||||
|
1, properties.getChildWorkflowLaneMaxDepth());
|
||||||
|
int childLaneMaxThreads = Math.max(
|
||||||
|
1, properties.getChildWorkflowLaneMaxThreads());
|
||||||
|
/*
|
||||||
|
* 不同嵌套深度使用独立小通道。depth N 的 WorkflowNode 即使全部同步等待,
|
||||||
|
* depth N+1 的触发器仍有独立容量,不会形成同池递归饥饿。
|
||||||
|
*/
|
||||||
|
for (int depth = 1; depth <= childLaneMaxDepth; depth++) {
|
||||||
|
ThreadPoolExecutor childWorkflowWorker =
|
||||||
|
new ThreadPoolExecutor(
|
||||||
|
0,
|
||||||
|
childLaneMaxThreads,
|
||||||
|
60L,
|
||||||
|
TimeUnit.SECONDS,
|
||||||
|
new SynchronousQueue<>(),
|
||||||
|
namedThreadFactory(
|
||||||
|
"workflow-child-" + depth),
|
||||||
|
new ThreadPoolExecutor.AbortPolicy());
|
||||||
|
childWorkflowWorker.allowCoreThreadTimeOut(true);
|
||||||
|
triggerScheduler.registerWorker(
|
||||||
|
ChainExecutor.childExecutionLane(depth),
|
||||||
|
childWorkflowWorker);
|
||||||
|
}
|
||||||
|
return triggerScheduler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带稳定前缀的守护线程工厂。
|
||||||
|
*
|
||||||
|
* @param prefix 线程名前缀
|
||||||
|
* @return 线程工厂
|
||||||
|
*/
|
||||||
|
private ThreadFactory namedThreadFactory(String prefix) {
|
||||||
|
AtomicInteger sequence = new AtomicInteger();
|
||||||
|
return task -> {
|
||||||
|
Thread thread = new Thread(task, prefix + "-" + sequence.incrementAndGet());
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流定义内容或发布快照发生变化的本地事件。
|
||||||
|
*
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
*/
|
||||||
|
public record WorkflowDefinitionChangedEvent(String workflowId) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.event;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.easyagents.flow.core.chain.repository.LoopInputReference;
|
||||||
|
import com.easyagents.flow.core.chain.repository.LoopResultReference;
|
||||||
|
import com.easyagents.flow.core.chain.repository.LoopResultRepository;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowExecStep;
|
||||||
|
import tech.easyflow.ai.service.WorkflowExecResultService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowExecStepService;
|
||||||
|
import tech.easyflow.common.mq.core.MQConsumerHandler;
|
||||||
|
import tech.easyflow.common.mq.core.MQMessage;
|
||||||
|
import tech.easyflow.common.mq.core.MQSubscription;
|
||||||
|
import tech.easyflow.common.mq.config.MQProperties;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流执行审计事件消费者。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WorkflowExecutionAuditConsumer implements MQConsumerHandler {
|
||||||
|
|
||||||
|
private final WorkflowExecResultService workflowExecResultService;
|
||||||
|
private final WorkflowExecStepService workflowExecStepService;
|
||||||
|
private final MQProperties mqProperties;
|
||||||
|
private final LoopResultRepository loopResultRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建工作流执行审计事件消费者。
|
||||||
|
*
|
||||||
|
* @param workflowExecResultService 工作流执行记录服务
|
||||||
|
* @param workflowExecStepService 节点执行步骤服务
|
||||||
|
* @param mqProperties MQ 配置
|
||||||
|
* @param loopResultRepository 循环与大型查询结果仓储
|
||||||
|
*/
|
||||||
|
public WorkflowExecutionAuditConsumer(WorkflowExecResultService workflowExecResultService,
|
||||||
|
WorkflowExecStepService workflowExecStepService,
|
||||||
|
MQProperties mqProperties,
|
||||||
|
LoopResultRepository loopResultRepository) {
|
||||||
|
this.workflowExecResultService = workflowExecResultService;
|
||||||
|
this.workflowExecStepService = workflowExecStepService;
|
||||||
|
this.mqProperties = mqProperties;
|
||||||
|
this.loopResultRepository =
|
||||||
|
loopResultRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public MQSubscription subscription() {
|
||||||
|
MQSubscription subscription = new MQSubscription();
|
||||||
|
subscription.setTopic(WorkflowExecutionAuditMqConstants.TOPIC);
|
||||||
|
subscription.setConsumerGroup(WorkflowExecutionAuditMqConstants.CONSUMER_GROUP);
|
||||||
|
subscription.setShardCount(Math.max(
|
||||||
|
1,
|
||||||
|
mqProperties.getRedis().getChatPersistShardCount()));
|
||||||
|
subscription.setBatchEnabled(true);
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void handle(List<MQMessage> messages) {
|
||||||
|
if (messages == null || messages.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (MQMessage message : messages) {
|
||||||
|
WorkflowExecutionAuditEvent event = JSON.parseObject(
|
||||||
|
message.getBody(), WorkflowExecutionAuditEvent.class);
|
||||||
|
if (event == null || event.getType() == null) {
|
||||||
|
throw new IllegalArgumentException("Invalid workflow execution audit event");
|
||||||
|
}
|
||||||
|
apply(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按事件顺序幂等写入执行记录。
|
||||||
|
*
|
||||||
|
* @param event 审计事件
|
||||||
|
*/
|
||||||
|
private void apply(WorkflowExecutionAuditEvent event) {
|
||||||
|
switch (event.getType()) {
|
||||||
|
case CHAIN_STARTED -> createExecution(event);
|
||||||
|
case CHAIN_ENDED -> finishExecution(event);
|
||||||
|
case NODE_STARTED -> createStep(event);
|
||||||
|
case NODE_ENDED -> finishStep(event);
|
||||||
|
default -> throw new IllegalArgumentException(
|
||||||
|
"Unsupported workflow execution audit event: " + event.getType());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建工作流执行记录。
|
||||||
|
*
|
||||||
|
* @param event 启动事件
|
||||||
|
*/
|
||||||
|
private void createExecution(WorkflowExecutionAuditEvent event) {
|
||||||
|
WorkflowExecResult incoming = requireResult(event);
|
||||||
|
try {
|
||||||
|
workflowExecResultService.save(incoming);
|
||||||
|
} catch (DuplicateKeyException ignored) {
|
||||||
|
// MQ 至少一次投递下的重复启动事件按 exec_key 幂等处理。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成工作流执行记录。
|
||||||
|
*
|
||||||
|
* @param event 结束事件
|
||||||
|
*/
|
||||||
|
private void finishExecution(WorkflowExecutionAuditEvent event) {
|
||||||
|
WorkflowExecResult incoming = requireResult(event);
|
||||||
|
incoming.setOutput(resolveAuditOutput(
|
||||||
|
incoming.getOutput()));
|
||||||
|
if (workflowExecResultService.updateByExecKey(incoming) != 1) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Unable to update workflow execution record: " + incoming.getExecKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建节点执行步骤。
|
||||||
|
*
|
||||||
|
* @param event 节点启动事件
|
||||||
|
*/
|
||||||
|
private void createStep(WorkflowExecutionAuditEvent event) {
|
||||||
|
WorkflowExecStep incoming = requireStep(event);
|
||||||
|
WorkflowExecResult record = workflowExecResultService.getByExecKey(event.getInstanceId());
|
||||||
|
if (record == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Workflow execution record not found: " + event.getInstanceId());
|
||||||
|
}
|
||||||
|
incoming.setRecordId(record.getId());
|
||||||
|
incoming.setInput(resolveAuditOutput(
|
||||||
|
incoming.getInput()));
|
||||||
|
try {
|
||||||
|
workflowExecStepService.save(incoming);
|
||||||
|
} catch (DuplicateKeyException ignored) {
|
||||||
|
// 同一基础设施触发器恢复后重复投递时按稳定 exec_key 幂等处理。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成节点执行步骤。
|
||||||
|
*
|
||||||
|
* @param event 节点结束事件
|
||||||
|
*/
|
||||||
|
private void finishStep(WorkflowExecutionAuditEvent event) {
|
||||||
|
WorkflowExecStep incoming = requireStep(event);
|
||||||
|
incoming.setOutput(resolveAuditOutput(
|
||||||
|
incoming.getOutput()));
|
||||||
|
if (workflowExecStepService.updateByExecKey(incoming) != 1) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Unable to update workflow execution step: " + incoming.getExecKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取事件中的工作流执行记录。
|
||||||
|
*
|
||||||
|
* @param event 审计事件
|
||||||
|
* @return 工作流执行记录
|
||||||
|
*/
|
||||||
|
private WorkflowExecResult requireResult(WorkflowExecutionAuditEvent event) {
|
||||||
|
if (event.getResult() == null || event.getResult().getExecKey() == null) {
|
||||||
|
throw new IllegalArgumentException("Workflow execution audit result is required");
|
||||||
|
}
|
||||||
|
return event.getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取事件中的节点执行步骤。
|
||||||
|
*
|
||||||
|
* @param event 审计事件
|
||||||
|
* @return 节点执行步骤
|
||||||
|
*/
|
||||||
|
private WorkflowExecStep requireStep(WorkflowExecutionAuditEvent event) {
|
||||||
|
if (event.getStep() == null || event.getStep().getExecKey() == null) {
|
||||||
|
throw new IllegalArgumentException("Workflow execution audit step is required");
|
||||||
|
}
|
||||||
|
return event.getStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在审计消费线程还原轻量引用,保持执行记录既有完整 JSON 语义。
|
||||||
|
*
|
||||||
|
* @param output 可能包含内部引用的 JSON
|
||||||
|
* @return 已还原的完整 JSON
|
||||||
|
*/
|
||||||
|
private String resolveAuditOutput(
|
||||||
|
String output) {
|
||||||
|
if (output == null || output.isBlank()) {
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
Object parsed = JSON.parse(output);
|
||||||
|
Object references = restoreReferences(parsed);
|
||||||
|
Object resolved =
|
||||||
|
loopResultRepository.resolveReferences(
|
||||||
|
references);
|
||||||
|
return JSON.toJSONString(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将异步消息中的稳定引用标记恢复为引擎引用对象。
|
||||||
|
*
|
||||||
|
* @param value JSON 值
|
||||||
|
* @return 引擎可解析值
|
||||||
|
*/
|
||||||
|
private Object restoreReferences(Object value) {
|
||||||
|
if (value instanceof Map<?, ?> map) {
|
||||||
|
if ("easyflow.loop-input.v1".equals(
|
||||||
|
map.get("referenceType"))
|
||||||
|
&& map.get("resultId") != null
|
||||||
|
&& map.get("itemCount")
|
||||||
|
instanceof Number) {
|
||||||
|
return new LoopInputReference(
|
||||||
|
String.valueOf(
|
||||||
|
map.get("resultId")),
|
||||||
|
((Number) map.get(
|
||||||
|
"itemCount")).intValue());
|
||||||
|
}
|
||||||
|
if ("easyflow.loop-result.v1".equals(
|
||||||
|
map.get("referenceType"))
|
||||||
|
&& map.get("resultId") != null
|
||||||
|
&& map.get("iterationCount")
|
||||||
|
instanceof Number
|
||||||
|
&& map.get("outputName") != null) {
|
||||||
|
return new LoopResultReference(
|
||||||
|
String.valueOf(
|
||||||
|
map.get("resultId")),
|
||||||
|
((Number) map.get(
|
||||||
|
"iterationCount"))
|
||||||
|
.intValue(),
|
||||||
|
String.valueOf(
|
||||||
|
map.get("outputName")));
|
||||||
|
}
|
||||||
|
Map<Object, Object> restored =
|
||||||
|
new LinkedHashMap<>();
|
||||||
|
map.forEach((key, item) ->
|
||||||
|
restored.put(
|
||||||
|
key,
|
||||||
|
restoreReferences(item)));
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
|
if (value instanceof List<?> list) {
|
||||||
|
return list.stream()
|
||||||
|
.map(this::restoreReferences)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.event;
|
||||||
|
|
||||||
|
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowExecStep;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流执行记录异步持久化事件。
|
||||||
|
*/
|
||||||
|
public class WorkflowExecutionAuditEvent implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计事件类型。
|
||||||
|
*/
|
||||||
|
public enum Type {
|
||||||
|
CHAIN_STARTED,
|
||||||
|
CHAIN_ENDED,
|
||||||
|
NODE_STARTED,
|
||||||
|
NODE_ENDED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事件幂等 ID。
|
||||||
|
*/
|
||||||
|
private String eventId;
|
||||||
|
/**
|
||||||
|
* 工作流实例 ID,同时作为同实例事件顺序键。
|
||||||
|
*/
|
||||||
|
private String instanceId;
|
||||||
|
/**
|
||||||
|
* 事件类型。
|
||||||
|
*/
|
||||||
|
private Type type;
|
||||||
|
/**
|
||||||
|
* 事件发生时间。
|
||||||
|
*/
|
||||||
|
private Date occurredAt;
|
||||||
|
/**
|
||||||
|
* 工作流执行记录快照。
|
||||||
|
*/
|
||||||
|
private WorkflowExecResult result;
|
||||||
|
/**
|
||||||
|
* 节点执行步骤快照。
|
||||||
|
*/
|
||||||
|
private WorkflowExecStep step;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取事件幂等 ID。
|
||||||
|
*
|
||||||
|
* @return 事件幂等 ID
|
||||||
|
*/
|
||||||
|
public String getEventId() {
|
||||||
|
return eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置事件幂等 ID。
|
||||||
|
*
|
||||||
|
* @param eventId 事件幂等 ID
|
||||||
|
*/
|
||||||
|
public void setEventId(String eventId) {
|
||||||
|
this.eventId = eventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工作流实例 ID。
|
||||||
|
*
|
||||||
|
* @return 工作流实例 ID
|
||||||
|
*/
|
||||||
|
public String getInstanceId() {
|
||||||
|
return instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置工作流实例 ID。
|
||||||
|
*
|
||||||
|
* @param instanceId 工作流实例 ID
|
||||||
|
*/
|
||||||
|
public void setInstanceId(String instanceId) {
|
||||||
|
this.instanceId = instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取事件类型。
|
||||||
|
*
|
||||||
|
* @return 事件类型
|
||||||
|
*/
|
||||||
|
public Type getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置事件类型。
|
||||||
|
*
|
||||||
|
* @param type 事件类型
|
||||||
|
*/
|
||||||
|
public void setType(Type type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取事件发生时间。
|
||||||
|
*
|
||||||
|
* @return 事件发生时间
|
||||||
|
*/
|
||||||
|
public Date getOccurredAt() {
|
||||||
|
return occurredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置事件发生时间。
|
||||||
|
*
|
||||||
|
* @param occurredAt 事件发生时间
|
||||||
|
*/
|
||||||
|
public void setOccurredAt(Date occurredAt) {
|
||||||
|
this.occurredAt = occurredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工作流执行记录快照。
|
||||||
|
*
|
||||||
|
* @return 工作流执行记录快照
|
||||||
|
*/
|
||||||
|
public WorkflowExecResult getResult() {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置工作流执行记录快照。
|
||||||
|
*
|
||||||
|
* @param result 工作流执行记录快照
|
||||||
|
*/
|
||||||
|
public void setResult(WorkflowExecResult result) {
|
||||||
|
this.result = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取节点执行步骤快照。
|
||||||
|
*
|
||||||
|
* @return 节点执行步骤快照
|
||||||
|
*/
|
||||||
|
public WorkflowExecStep getStep() {
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置节点执行步骤快照。
|
||||||
|
*
|
||||||
|
* @param step 节点执行步骤快照
|
||||||
|
*/
|
||||||
|
public void setStep(WorkflowExecStep step) {
|
||||||
|
this.step = step;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流执行审计 MQ 常量。
|
||||||
|
*/
|
||||||
|
public final class WorkflowExecutionAuditMqConstants {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流执行审计主题。
|
||||||
|
*/
|
||||||
|
public static final String TOPIC = "workflow-execution-audit";
|
||||||
|
/**
|
||||||
|
* 工作流执行审计消费组。
|
||||||
|
*/
|
||||||
|
public static final String CONSUMER_GROUP = "workflow-execution-audit-writer";
|
||||||
|
|
||||||
|
private WorkflowExecutionAuditMqConstants() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.event;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import tech.easyflow.common.mq.core.MQDeadLetterService;
|
||||||
|
import tech.easyflow.common.mq.core.MQMessage;
|
||||||
|
import tech.easyflow.common.mq.core.MQProducer;
|
||||||
|
|
||||||
|
import javax.annotation.PreDestroy;
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Deque;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流执行审计事件生产者。
|
||||||
|
*
|
||||||
|
* <p>少量固定 lane 保证同一实例 FIFO,跨 lane 并行发送和重试,避免单个异常
|
||||||
|
* 实例阻塞全部工作流。全局条数与字节预算共同约束本地重试内存。</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class WorkflowExecutionAuditProducer {
|
||||||
|
|
||||||
|
private static final Logger log =
|
||||||
|
LoggerFactory.getLogger(WorkflowExecutionAuditProducer.class);
|
||||||
|
private static final int DEFAULT_LANE_COUNT = 8;
|
||||||
|
private static final int MAX_LOCAL_BACKLOG = 10_000;
|
||||||
|
private static final long MAX_MESSAGE_BYTES =
|
||||||
|
64L * 1024L * 1024L;
|
||||||
|
private static final long MAX_BACKLOG_BYTES =
|
||||||
|
512L * 1024L * 1024L;
|
||||||
|
private static final long SHUTDOWN_FLUSH_MILLIS =
|
||||||
|
TimeUnit.SECONDS.toMillis(5L);
|
||||||
|
private static final int MAX_DRAIN_BATCH = 256;
|
||||||
|
private static final int MAX_SEND_ATTEMPTS = 16;
|
||||||
|
private static final long MAX_RETRY_DELAY_MILLIS =
|
||||||
|
TimeUnit.MINUTES.toMillis(1);
|
||||||
|
|
||||||
|
private final MQProducer mqProducer;
|
||||||
|
private final MQDeadLetterService deadLetterService;
|
||||||
|
private final List<DeliveryLane> lanes;
|
||||||
|
private final Object admissionLock =
|
||||||
|
new Object();
|
||||||
|
private final ScheduledExecutorService retryExecutor;
|
||||||
|
private final AtomicBoolean closed =
|
||||||
|
new AtomicBoolean();
|
||||||
|
private final int maxLocalBacklog;
|
||||||
|
private final long maxMessageBytes;
|
||||||
|
private final long maxBacklogBytes;
|
||||||
|
private final long shutdownFlushMillis;
|
||||||
|
private int backlogCount;
|
||||||
|
private long backlogBytes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建工作流执行审计事件生产者。
|
||||||
|
*
|
||||||
|
* @param mqProducer 通用 MQ 生产者
|
||||||
|
* @param deadLetterService 通用 MQ 死信服务
|
||||||
|
*/
|
||||||
|
@Autowired
|
||||||
|
public WorkflowExecutionAuditProducer(
|
||||||
|
MQProducer mqProducer,
|
||||||
|
MQDeadLetterService deadLetterService) {
|
||||||
|
this(
|
||||||
|
mqProducer,
|
||||||
|
deadLetterService,
|
||||||
|
DEFAULT_LANE_COUNT,
|
||||||
|
MAX_LOCAL_BACKLOG,
|
||||||
|
MAX_MESSAGE_BYTES,
|
||||||
|
MAX_BACKLOG_BYTES,
|
||||||
|
SHUTDOWN_FLUSH_MILLIS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带测试预算的工作流审计生产者。
|
||||||
|
*
|
||||||
|
* @param mqProducer 通用 MQ 生产者
|
||||||
|
* @param deadLetterService 通用 MQ 死信服务
|
||||||
|
* @param laneCount 固定发送 lane 数
|
||||||
|
* @param maxLocalBacklog 最大本地积压条数
|
||||||
|
* @param maxMessageBytes 单条消息最大字节数
|
||||||
|
* @param maxBacklogBytes 本地积压最大总字节数
|
||||||
|
* @param shutdownFlushMillis 关闭时最大收口时间
|
||||||
|
*/
|
||||||
|
WorkflowExecutionAuditProducer(
|
||||||
|
MQProducer mqProducer,
|
||||||
|
MQDeadLetterService deadLetterService,
|
||||||
|
int laneCount,
|
||||||
|
int maxLocalBacklog,
|
||||||
|
long maxMessageBytes,
|
||||||
|
long maxBacklogBytes,
|
||||||
|
long shutdownFlushMillis) {
|
||||||
|
this.mqProducer = mqProducer;
|
||||||
|
this.deadLetterService = deadLetterService;
|
||||||
|
this.maxLocalBacklog =
|
||||||
|
Math.max(1, maxLocalBacklog);
|
||||||
|
this.maxMessageBytes =
|
||||||
|
Math.max(1L, maxMessageBytes);
|
||||||
|
this.maxBacklogBytes =
|
||||||
|
Math.max(this.maxMessageBytes,
|
||||||
|
maxBacklogBytes);
|
||||||
|
this.shutdownFlushMillis =
|
||||||
|
Math.max(0L, shutdownFlushMillis);
|
||||||
|
int effectiveLaneCount =
|
||||||
|
Math.max(1, laneCount);
|
||||||
|
this.lanes =
|
||||||
|
new ArrayList<>(effectiveLaneCount);
|
||||||
|
for (int index = 0;
|
||||||
|
index < effectiveLaneCount;
|
||||||
|
index++) {
|
||||||
|
lanes.add(new DeliveryLane());
|
||||||
|
}
|
||||||
|
this.retryExecutor =
|
||||||
|
Executors.newScheduledThreadPool(
|
||||||
|
Math.min(
|
||||||
|
effectiveLaneCount, 4),
|
||||||
|
runnable -> {
|
||||||
|
Thread thread =
|
||||||
|
new Thread(
|
||||||
|
runnable,
|
||||||
|
"workflow-audit-producer-retry");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
|
for (DeliveryLane lane : lanes) {
|
||||||
|
retryExecutor.scheduleWithFixedDelay(
|
||||||
|
() -> drainReadyBatch(
|
||||||
|
lane, false),
|
||||||
|
10L,
|
||||||
|
10L,
|
||||||
|
TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 投递工作流执行审计事件。
|
||||||
|
*
|
||||||
|
* <p>同一实例稳定落在同一发送 lane 和 MQ 分片并保持事件顺序;不同 lane
|
||||||
|
* 独立发送和退避。</p>
|
||||||
|
*
|
||||||
|
* @param event 工作流执行审计事件
|
||||||
|
* @return Redis Stream 记录 ID,或本地排队标识
|
||||||
|
*/
|
||||||
|
public String send(
|
||||||
|
WorkflowExecutionAuditEvent event) {
|
||||||
|
if (closed.get()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Workflow audit producer is closed");
|
||||||
|
}
|
||||||
|
if (event == null
|
||||||
|
|| event.getType() == null) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Workflow execution audit event is required");
|
||||||
|
}
|
||||||
|
Date occurredAt =
|
||||||
|
event.getOccurredAt() == null
|
||||||
|
? new Date()
|
||||||
|
: event.getOccurredAt();
|
||||||
|
event.setOccurredAt(occurredAt);
|
||||||
|
|
||||||
|
MQMessage message =
|
||||||
|
new MQMessage();
|
||||||
|
message.setMessageId(event.getEventId());
|
||||||
|
message.setTopic(
|
||||||
|
WorkflowExecutionAuditMqConstants.TOPIC);
|
||||||
|
message.setKey(event.getInstanceId());
|
||||||
|
message.setCreatedAt(occurredAt);
|
||||||
|
message.setBody(
|
||||||
|
JSON.toJSONString(event));
|
||||||
|
long messageBytes =
|
||||||
|
messageBytes(message);
|
||||||
|
ensureMessageSize(message, messageBytes);
|
||||||
|
|
||||||
|
DeliveryLane lane =
|
||||||
|
laneFor(message.getKey());
|
||||||
|
synchronized (lane.lock) {
|
||||||
|
if (closed.get()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Workflow audit producer is closed");
|
||||||
|
}
|
||||||
|
if (lane.sending
|
||||||
|
|| !lane.backlog.isEmpty()) {
|
||||||
|
enqueueLast(
|
||||||
|
lane,
|
||||||
|
new PendingDelivery(
|
||||||
|
message,
|
||||||
|
0,
|
||||||
|
0L,
|
||||||
|
messageBytes));
|
||||||
|
return queuedId(message);
|
||||||
|
}
|
||||||
|
lane.sending = true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return mqProducer.send(message);
|
||||||
|
} catch (RuntimeException sendError) {
|
||||||
|
boolean closing;
|
||||||
|
synchronized (lane.lock) {
|
||||||
|
closing = closed.get();
|
||||||
|
if (!closing) {
|
||||||
|
enqueueFirst(
|
||||||
|
lane,
|
||||||
|
new PendingDelivery(
|
||||||
|
message,
|
||||||
|
1,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
+ retryDelayMillis(1),
|
||||||
|
messageBytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (closing) {
|
||||||
|
try {
|
||||||
|
deadLetterService.deadLetter(
|
||||||
|
message,
|
||||||
|
"producer closed during send failure");
|
||||||
|
} catch (RuntimeException deadLetterError) {
|
||||||
|
sendError.addSuppressed(
|
||||||
|
deadLetterError);
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Workflow audit producer closed during send",
|
||||||
|
sendError);
|
||||||
|
}
|
||||||
|
log.warn(
|
||||||
|
"工作流审计 MQ 暂时不可用,事件已进入有界重试队列,eventId={}",
|
||||||
|
message.getMessageId());
|
||||||
|
return queuedId(message);
|
||||||
|
} finally {
|
||||||
|
synchronized (lane.lock) {
|
||||||
|
lane.sending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在单个 lane 中按原顺序批量重试。
|
||||||
|
*
|
||||||
|
* @param lane 发送 lane
|
||||||
|
* @param ignoreRetryTime 关闭收口时是否忽略退避时间
|
||||||
|
*/
|
||||||
|
private void drainReadyBatch(
|
||||||
|
DeliveryLane lane,
|
||||||
|
boolean ignoreRetryTime) {
|
||||||
|
for (int index = 0;
|
||||||
|
index < MAX_DRAIN_BATCH;
|
||||||
|
index++) {
|
||||||
|
if (!drainOne(
|
||||||
|
lane, ignoreRetryTime)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试一个 lane 的队首消息。
|
||||||
|
*
|
||||||
|
* @param lane 发送 lane
|
||||||
|
* @param ignoreRetryTime 是否忽略退避时间
|
||||||
|
* @return 队首已移除且可继续排空时为 {@code true}
|
||||||
|
*/
|
||||||
|
private boolean drainOne(
|
||||||
|
DeliveryLane lane,
|
||||||
|
boolean ignoreRetryTime) {
|
||||||
|
PendingDelivery pending;
|
||||||
|
synchronized (lane.lock) {
|
||||||
|
if (lane.sending
|
||||||
|
|| lane.backlog.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pending = lane.backlog.peekFirst();
|
||||||
|
if (pending == null
|
||||||
|
|| (!ignoreRetryTime
|
||||||
|
&& pending.nextAttemptAtMillis()
|
||||||
|
> System.currentTimeMillis())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
lane.sending = true;
|
||||||
|
}
|
||||||
|
boolean removed = false;
|
||||||
|
try {
|
||||||
|
mqProducer.send(pending.message());
|
||||||
|
removed = removeHead(
|
||||||
|
lane, pending);
|
||||||
|
} catch (RuntimeException sendError) {
|
||||||
|
int nextAttempt =
|
||||||
|
pending.attempt() + 1;
|
||||||
|
if (nextAttempt
|
||||||
|
>= MAX_SEND_ATTEMPTS) {
|
||||||
|
if (deadLetter(
|
||||||
|
pending.message(),
|
||||||
|
sendError)) {
|
||||||
|
removed = removeHead(
|
||||||
|
lane, pending);
|
||||||
|
} else {
|
||||||
|
replaceHead(
|
||||||
|
lane,
|
||||||
|
pending,
|
||||||
|
pending.retryAt(
|
||||||
|
nextAttempt,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
+ MAX_RETRY_DELAY_MILLIS));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
replaceHead(
|
||||||
|
lane,
|
||||||
|
pending,
|
||||||
|
pending.retryAt(
|
||||||
|
nextAttempt,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
+ retryDelayMillis(
|
||||||
|
nextAttempt)));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
synchronized (lane.lock) {
|
||||||
|
lane.sending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将耗尽生产重试的事件写入通用死信流。
|
||||||
|
*
|
||||||
|
* @param message MQ 消息
|
||||||
|
* @param failure 最终发送异常
|
||||||
|
* @return 死信写入成功时为 {@code true}
|
||||||
|
*/
|
||||||
|
private boolean deadLetter(
|
||||||
|
MQMessage message,
|
||||||
|
RuntimeException failure) {
|
||||||
|
try {
|
||||||
|
deadLetterService.deadLetter(
|
||||||
|
message,
|
||||||
|
"producer send attempts exhausted: "
|
||||||
|
+ failure.getClass().getName()
|
||||||
|
+ ": "
|
||||||
|
+ failure.getMessage());
|
||||||
|
return true;
|
||||||
|
} catch (RuntimeException deadLetterError) {
|
||||||
|
log.error(
|
||||||
|
"工作流审计生产失败且死信写入失败,eventId={}",
|
||||||
|
message.getMessageId(),
|
||||||
|
deadLetterError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除仍位于 lane 队首的消息并归还全局积压预算。
|
||||||
|
*
|
||||||
|
* @param lane 发送 lane
|
||||||
|
* @param expected 期望队首
|
||||||
|
* @return 成功删除时为 {@code true}
|
||||||
|
*/
|
||||||
|
private boolean removeHead(
|
||||||
|
DeliveryLane lane,
|
||||||
|
PendingDelivery expected) {
|
||||||
|
synchronized (lane.lock) {
|
||||||
|
if (lane.backlog.peekFirst()
|
||||||
|
!= expected) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
lane.backlog.removeFirst();
|
||||||
|
releaseAdmission(expected);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子替换仍位于 lane 队首的消息。
|
||||||
|
*
|
||||||
|
* @param lane 发送 lane
|
||||||
|
* @param expected 当前队首
|
||||||
|
* @param replacement 替换项
|
||||||
|
*/
|
||||||
|
private void replaceHead(
|
||||||
|
DeliveryLane lane,
|
||||||
|
PendingDelivery expected,
|
||||||
|
PendingDelivery replacement) {
|
||||||
|
synchronized (lane.lock) {
|
||||||
|
if (lane.backlog.peekFirst()
|
||||||
|
== expected) {
|
||||||
|
lane.backlog.removeFirst();
|
||||||
|
lane.backlog.addFirst(
|
||||||
|
replacement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 入队到指定 lane 尾部。
|
||||||
|
*
|
||||||
|
* @param lane 发送 lane
|
||||||
|
* @param pending 待投递事件
|
||||||
|
*/
|
||||||
|
private void enqueueLast(
|
||||||
|
DeliveryLane lane,
|
||||||
|
PendingDelivery pending) {
|
||||||
|
reserveAdmission(pending);
|
||||||
|
lane.backlog.addLast(pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 入队到指定 lane 头部。
|
||||||
|
*
|
||||||
|
* @param lane 发送 lane
|
||||||
|
* @param pending 待投递事件
|
||||||
|
*/
|
||||||
|
private void enqueueFirst(
|
||||||
|
DeliveryLane lane,
|
||||||
|
PendingDelivery pending) {
|
||||||
|
reserveAdmission(pending);
|
||||||
|
lane.backlog.addFirst(pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预占全局积压条数和字节预算。
|
||||||
|
*
|
||||||
|
* @param pending 待入队消息
|
||||||
|
*/
|
||||||
|
private void reserveAdmission(
|
||||||
|
PendingDelivery pending) {
|
||||||
|
boolean rejected;
|
||||||
|
synchronized (admissionLock) {
|
||||||
|
rejected = backlogCount
|
||||||
|
>= maxLocalBacklog
|
||||||
|
|| pending.messageBytes()
|
||||||
|
> maxBacklogBytes
|
||||||
|
- backlogBytes;
|
||||||
|
if (!rejected) {
|
||||||
|
backlogCount++;
|
||||||
|
backlogBytes +=
|
||||||
|
pending.messageBytes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rejected) {
|
||||||
|
deadLetterService.deadLetter(
|
||||||
|
pending.message(),
|
||||||
|
"producer retry queue is full");
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Workflow audit producer retry queue is full");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归还一条积压消息占用的全局预算。
|
||||||
|
*
|
||||||
|
* @param pending 已移除消息
|
||||||
|
*/
|
||||||
|
private void releaseAdmission(
|
||||||
|
PendingDelivery pending) {
|
||||||
|
synchronized (admissionLock) {
|
||||||
|
backlogCount =
|
||||||
|
Math.max(0, backlogCount - 1);
|
||||||
|
backlogBytes =
|
||||||
|
Math.max(
|
||||||
|
0L,
|
||||||
|
backlogBytes
|
||||||
|
- pending.messageBytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验单条消息字节上限。
|
||||||
|
*
|
||||||
|
* @param message MQ 消息
|
||||||
|
* @param bytes 消息估算字节数
|
||||||
|
*/
|
||||||
|
private void ensureMessageSize(
|
||||||
|
MQMessage message,
|
||||||
|
long bytes) {
|
||||||
|
if (bytes <= maxMessageBytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deadLetterService.deadLetter(
|
||||||
|
message,
|
||||||
|
"producer message exceeds byte limit");
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Workflow audit message exceeds byte limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 估算 MQ 消息本地持有字节数。
|
||||||
|
*
|
||||||
|
* @param message MQ 消息
|
||||||
|
* @return UTF-8 负载和关键元数据字节数
|
||||||
|
*/
|
||||||
|
private long messageBytes(
|
||||||
|
MQMessage message) {
|
||||||
|
return utf8Bytes(message.getBody())
|
||||||
|
+ utf8Bytes(message.getMessageId())
|
||||||
|
+ utf8Bytes(message.getKey())
|
||||||
|
+ 128L;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算字符串 UTF-8 字节数。
|
||||||
|
*
|
||||||
|
* @param value 字符串
|
||||||
|
* @return 字节数
|
||||||
|
*/
|
||||||
|
private long utf8Bytes(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
long bytes = 0L;
|
||||||
|
for (int index = 0;
|
||||||
|
index < value.length();
|
||||||
|
index++) {
|
||||||
|
char current =
|
||||||
|
value.charAt(index);
|
||||||
|
if (current <= 0x7F) {
|
||||||
|
bytes++;
|
||||||
|
} else if (current <= 0x7FF) {
|
||||||
|
bytes += 2L;
|
||||||
|
} else if (Character.isHighSurrogate(
|
||||||
|
current)
|
||||||
|
&& index + 1 < value.length()
|
||||||
|
&& Character.isLowSurrogate(
|
||||||
|
value.charAt(index + 1))) {
|
||||||
|
bytes += 4L;
|
||||||
|
index++;
|
||||||
|
} else {
|
||||||
|
bytes += 3L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按稳定 key 选择固定发送 lane。
|
||||||
|
*
|
||||||
|
* @param key 工作流实例键
|
||||||
|
* @return 发送 lane
|
||||||
|
*/
|
||||||
|
private DeliveryLane laneFor(String key) {
|
||||||
|
int hash = key == null
|
||||||
|
? 0
|
||||||
|
: key.hashCode();
|
||||||
|
return lanes.get(
|
||||||
|
Math.floorMod(hash, lanes.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造本地排队返回标识。
|
||||||
|
*
|
||||||
|
* @param message 已排队消息
|
||||||
|
* @return 排队标识
|
||||||
|
*/
|
||||||
|
private String queuedId(
|
||||||
|
MQMessage message) {
|
||||||
|
return "queued:"
|
||||||
|
+ message.getMessageId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算生产者重试退避。
|
||||||
|
*
|
||||||
|
* @param attempt 已失败次数
|
||||||
|
* @return 退避毫秒数
|
||||||
|
*/
|
||||||
|
private long retryDelayMillis(int attempt) {
|
||||||
|
int shift =
|
||||||
|
Math.min(
|
||||||
|
16,
|
||||||
|
Math.max(0, attempt - 1));
|
||||||
|
return Math.min(
|
||||||
|
MAX_RETRY_DELAY_MILLIS,
|
||||||
|
100L << shift);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭生产者重试线程,并在有界时间内发送或死信收口积压事件。
|
||||||
|
*/
|
||||||
|
@PreDestroy
|
||||||
|
public void close() {
|
||||||
|
if (!closed.compareAndSet(
|
||||||
|
false, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
retryExecutor.shutdownNow();
|
||||||
|
long deadline =
|
||||||
|
System.nanoTime()
|
||||||
|
+ TimeUnit.MILLISECONDS.toNanos(
|
||||||
|
shutdownFlushMillis);
|
||||||
|
while (hasBacklog()
|
||||||
|
&& System.nanoTime() < deadline) {
|
||||||
|
boolean progressed = false;
|
||||||
|
for (DeliveryLane lane : lanes) {
|
||||||
|
int before = laneSize(lane);
|
||||||
|
drainReadyBatch(lane, true);
|
||||||
|
progressed |= laneSize(lane)
|
||||||
|
< before;
|
||||||
|
}
|
||||||
|
if (!progressed) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(10L);
|
||||||
|
} catch (InterruptedException error) {
|
||||||
|
Thread.currentThread()
|
||||||
|
.interrupt();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deadLetterRemainingBacklog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否仍有本地积压消息。
|
||||||
|
*
|
||||||
|
* @return 有积压时为 {@code true}
|
||||||
|
*/
|
||||||
|
private boolean hasBacklog() {
|
||||||
|
synchronized (admissionLock) {
|
||||||
|
return backlogCount > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 lane 当前积压条数。
|
||||||
|
*
|
||||||
|
* @param lane 发送 lane
|
||||||
|
* @return 积压条数
|
||||||
|
*/
|
||||||
|
private int laneSize(DeliveryLane lane) {
|
||||||
|
synchronized (lane.lock) {
|
||||||
|
return lane.backlog.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将关闭期限后剩余消息转入死信,避免进程内静默丢失。
|
||||||
|
*/
|
||||||
|
private void deadLetterRemainingBacklog() {
|
||||||
|
for (DeliveryLane lane : lanes) {
|
||||||
|
List<PendingDelivery> remaining =
|
||||||
|
new ArrayList<>();
|
||||||
|
synchronized (lane.lock) {
|
||||||
|
while (!lane.backlog.isEmpty()) {
|
||||||
|
PendingDelivery pending =
|
||||||
|
lane.backlog.removeFirst();
|
||||||
|
remaining.add(pending);
|
||||||
|
releaseAdmission(pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (PendingDelivery pending :
|
||||||
|
remaining) {
|
||||||
|
try {
|
||||||
|
deadLetterService.deadLetter(
|
||||||
|
pending.message(),
|
||||||
|
"producer shutdown flush timeout");
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
log.error(
|
||||||
|
"工作流审计关闭收口死信失败,eventId={}",
|
||||||
|
pending.message()
|
||||||
|
.getMessageId(),
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一条有序待投递审计消息。
|
||||||
|
*
|
||||||
|
* @param message MQ 消息
|
||||||
|
* @param attempt 已失败次数
|
||||||
|
* @param nextAttemptAtMillis 下次允许重试时间
|
||||||
|
* @param messageBytes 本地持有字节数
|
||||||
|
*/
|
||||||
|
private record PendingDelivery(
|
||||||
|
MQMessage message,
|
||||||
|
int attempt,
|
||||||
|
long nextAttemptAtMillis,
|
||||||
|
long messageBytes) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建下一次重试记录。
|
||||||
|
*
|
||||||
|
* @param nextAttempt 下一次尝试次数
|
||||||
|
* @param retryAt 下次允许发送时间
|
||||||
|
* @return 保留原消息与字节大小的新记录
|
||||||
|
*/
|
||||||
|
private PendingDelivery retryAt(
|
||||||
|
int nextAttempt,
|
||||||
|
long retryAt) {
|
||||||
|
return new PendingDelivery(
|
||||||
|
message,
|
||||||
|
nextAttempt,
|
||||||
|
retryAt,
|
||||||
|
messageBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个独立 FIFO 发送 lane。
|
||||||
|
*/
|
||||||
|
private static final class DeliveryLane {
|
||||||
|
private final Object lock =
|
||||||
|
new Object();
|
||||||
|
private final Deque<PendingDelivery> backlog =
|
||||||
|
new ArrayDeque<>();
|
||||||
|
private boolean sending;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,27 @@
|
|||||||
package tech.easyflow.ai.easyagentsflow.listener;
|
package tech.easyflow.ai.easyagentsflow.listener;
|
||||||
|
|
||||||
import cn.hutool.core.util.IdUtil;
|
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import com.alibaba.fastjson2.JSON;
|
import com.alibaba.fastjson2.JSON;
|
||||||
import com.easyagents.flow.core.chain.*;
|
import com.easyagents.flow.core.chain.*;
|
||||||
import com.easyagents.flow.core.chain.event.*;
|
import com.easyagents.flow.core.chain.event.*;
|
||||||
import com.easyagents.flow.core.chain.listener.ChainEventListener;
|
import com.easyagents.flow.core.chain.listener.ChainEventListener;
|
||||||
import com.easyagents.flow.core.chain.repository.NodeStateField;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditEvent;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditProducer;
|
||||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.support.WorkflowExecutionStepKey;
|
||||||
import tech.easyflow.ai.entity.Workflow;
|
import tech.easyflow.ai.entity.Workflow;
|
||||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||||
import tech.easyflow.ai.entity.WorkflowExecStep;
|
import tech.easyflow.ai.entity.WorkflowExecStep;
|
||||||
import tech.easyflow.ai.service.WorkflowExecResultService;
|
import tech.easyflow.ai.service.WorkflowExecResultService;
|
||||||
import tech.easyflow.ai.service.WorkflowExecStepService;
|
|
||||||
import tech.easyflow.ai.service.WorkflowService;
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
import tech.easyflow.ai.utils.WorkFlowUtil;
|
import tech.easyflow.ai.utils.WorkFlowUtil;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.EnumSet;
|
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class ChainEventListenerForSave implements ChainEventListener {
|
public class ChainEventListenerForSave implements ChainEventListener {
|
||||||
@@ -35,7 +34,7 @@ public class ChainEventListenerForSave implements ChainEventListener {
|
|||||||
@Resource
|
@Resource
|
||||||
private WorkflowExecResultService workflowExecResultService;
|
private WorkflowExecResultService workflowExecResultService;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowExecStepService workflowExecStepService;
|
private WorkflowExecutionAuditProducer auditProducer;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEvent(Event event, Chain chain) {
|
public void onEvent(Event event, Chain chain) {
|
||||||
@@ -60,19 +59,19 @@ public class ChainEventListenerForSave implements ChainEventListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void handleChainStartEvent(ChainStartEvent event, Chain chain) {
|
private void handleChainStartEvent(ChainStartEvent event, Chain chain) {
|
||||||
log.info("ChainStartEvent: {}", event);
|
|
||||||
ChainDefinition definition = chain.getDefinition();
|
ChainDefinition definition = chain.getDefinition();
|
||||||
ChainState state = chain.getState();
|
ChainState state = chain.getState();
|
||||||
|
log.info(
|
||||||
|
"workflow event type=chain-started, instanceId={}, definitionId={}, variableCount={}",
|
||||||
|
state.getInstanceId(),
|
||||||
|
definition == null ? null : definition.getId(),
|
||||||
|
event.getVariables() == null ? 0 : event.getVariables().size());
|
||||||
Workflow workflow = resolveWorkflow(definition);
|
Workflow workflow = resolveWorkflow(definition);
|
||||||
if (workflow == null) {
|
if (workflow == null) {
|
||||||
log.error("ChainStartEvent: workflow not found, definitionId={}", definition.getId());
|
log.error("ChainStartEvent: workflow not found, definitionId={}", definition.getId());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String instanceId = state.getInstanceId();
|
String instanceId = state.getInstanceId();
|
||||||
WorkflowExecResult existed = workflowExecResultService.getByExecKey(instanceId);
|
|
||||||
if (existed != null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
WorkflowExecResult record = new WorkflowExecResult();
|
WorkflowExecResult record = new WorkflowExecResult();
|
||||||
record.setExecKey(instanceId);
|
record.setExecKey(instanceId);
|
||||||
record.setWorkflowId(workflow.getId());
|
record.setWorkflowId(workflow.getId());
|
||||||
@@ -84,102 +83,157 @@ public class ChainEventListenerForSave implements ChainEventListener {
|
|||||||
record.setStatus(state.getStatus().getValue());
|
record.setStatus(state.getStatus().getValue());
|
||||||
record.setCreatedKey(WorkFlowUtil.getCreatedKey(chain));
|
record.setCreatedKey(WorkFlowUtil.getCreatedKey(chain));
|
||||||
record.setCreatedBy(WorkFlowUtil.getOperator(chain).getId().toString());
|
record.setCreatedBy(WorkFlowUtil.getOperator(chain).getId().toString());
|
||||||
|
// 启动记录保留同步确认,避免执行接口返回后立即查询时记录尚不可见。
|
||||||
try {
|
try {
|
||||||
workflowExecResultService.save(record);
|
workflowExecResultService.save(record);
|
||||||
} catch (DuplicateKeyException e) {
|
} catch (DuplicateKeyException duplicate) {
|
||||||
// 多节点重试时可能并发写同一 exec_key,按幂等处理。
|
// 重复启动或恢复按 exec_key 幂等处理。
|
||||||
log.debug("exec result already exists, execKey={}", instanceId, e);
|
log.debug("exec result already exists, execKey={}", instanceId, duplicate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleChainEndEvent(ChainEndEvent event, Chain chain) {
|
private void handleChainEndEvent(ChainEndEvent event, Chain chain) {
|
||||||
log.info("ChainEndEvent: {}", event);
|
|
||||||
ChainState state = chain.getState();
|
ChainState state = chain.getState();
|
||||||
String instanceId = state.getInstanceId();
|
String instanceId = state.getInstanceId();
|
||||||
WorkflowExecResult record = workflowExecResultService.getByExecKey(instanceId);
|
log.info(
|
||||||
if (record == null) {
|
"workflow event type=chain-ended, instanceId={}, status={}",
|
||||||
log.error("ChainEndEvent: record not found: {}", instanceId);
|
instanceId,
|
||||||
} else {
|
state.getStatus());
|
||||||
record.setEndTime(new Date());
|
WorkflowExecResult record = new WorkflowExecResult();
|
||||||
record.setStatus(state.getStatus().getValue());
|
record.setExecKey(instanceId);
|
||||||
record.setOutput(JSON.toJSONString(state.getExecuteResult()));
|
record.setEndTime(new Date());
|
||||||
ExceptionSummary error = state.getError();
|
record.setStatus(state.getStatus().getValue());
|
||||||
if (error != null) {
|
// 大型引用由审计消费者异步还原,避免阻塞工作流终态提交。
|
||||||
record.setErrorInfo(error.getRootCauseClass() + " --> " + error.getRootCauseMessage());
|
record.setOutput(JSON.toJSONString(
|
||||||
}
|
state.getExecuteResult()));
|
||||||
workflowExecResultService.updateById(record);
|
ExceptionSummary error = state.getError();
|
||||||
|
if (error != null) {
|
||||||
|
record.setErrorInfo(error.getRootCauseClass() + " --> " + error.getRootCauseMessage());
|
||||||
}
|
}
|
||||||
|
sendAuditEvent(
|
||||||
|
WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
|
||||||
|
instanceId + ":chain-ended",
|
||||||
|
instanceId,
|
||||||
|
record,
|
||||||
|
null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleNodeStartEvent(NodeStartEvent event, Chain chain) {
|
private void handleNodeStartEvent(NodeStartEvent event, Chain chain) {
|
||||||
log.info("NodeStartEvent: {}", event);
|
|
||||||
Node node = event.getNode();
|
Node node = event.getNode();
|
||||||
ChainState ancestorState = findAncestorState(chain.getState(), chain);
|
String auditInstanceId =
|
||||||
|
event.getAuditInstanceId();
|
||||||
|
ChainState ancestorState =
|
||||||
|
StrUtil.isBlank(auditInstanceId)
|
||||||
|
|| auditInstanceId.equals(
|
||||||
|
chain.getStateInstanceId())
|
||||||
|
? chain.getExecutionState()
|
||||||
|
: chain.getChainStateRepository()
|
||||||
|
.load(auditInstanceId);
|
||||||
|
if (ancestorState == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Workflow audit state not found: "
|
||||||
|
+ auditInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
String instanceId = ancestorState.getInstanceId();
|
String instanceId = ancestorState.getInstanceId();
|
||||||
NodeState nodeState = chain.getNodeState(node.getId());
|
NodeStatus nodeStatus = event.getStatus();
|
||||||
|
if (nodeStatus == null) {
|
||||||
String execKey = IdUtil.fastSimpleUUID();
|
NodeState nodeState = chain.getNodeState(node.getId());
|
||||||
chain.updateNodeStateSafely(node.getId(), state -> {
|
nodeStatus = nodeState.getStatus();
|
||||||
state.getMemory().put("executeId", execKey);
|
|
||||||
return EnumSet.of(NodeStateField.MEMORY);
|
|
||||||
});
|
|
||||||
|
|
||||||
WorkflowExecResult record = workflowExecResultService.getByExecKey(instanceId);
|
|
||||||
if (record == null) {
|
|
||||||
log.error("NodeStartEvent: record not found: {}", instanceId);
|
|
||||||
} else {
|
|
||||||
WorkflowExecStep step = new WorkflowExecStep();
|
|
||||||
step.setRecordId(record.getId());
|
|
||||||
step.setExecKey(execKey);
|
|
||||||
step.setNodeId(node.getId());
|
|
||||||
step.setNodeName(node.getName());
|
|
||||||
step.setInput(JSON.toJSONString(ancestorState.resolveParameters(node)));
|
|
||||||
step.setNodeData(JSON.toJSONString(node));
|
|
||||||
step.setStartTime(new Date());
|
|
||||||
step.setStatus(nodeState.getStatus().getValue());
|
|
||||||
workflowExecStepService.save(step);
|
|
||||||
}
|
}
|
||||||
|
log.info(
|
||||||
|
"workflow event type=node-started, instanceId={}, nodeId={}, nodeType={}, status={}",
|
||||||
|
instanceId,
|
||||||
|
node.getId(),
|
||||||
|
node.getClass().getSimpleName(),
|
||||||
|
nodeStatus);
|
||||||
|
|
||||||
|
String execKey = currentStepExecKey(
|
||||||
|
event.getExecutionAttemptKey(),
|
||||||
|
chain,
|
||||||
|
node);
|
||||||
|
WorkflowExecStep step = new WorkflowExecStep();
|
||||||
|
step.setExecKey(execKey);
|
||||||
|
step.setNodeId(node.getId());
|
||||||
|
step.setNodeName(node.getName());
|
||||||
|
// 业务线程保留大型引用,完整审计输入由 MQ 消费线程异步还原。
|
||||||
|
step.setInput(JSON.toJSONString(
|
||||||
|
ancestorState
|
||||||
|
.resolveParametersPreservingReferences(
|
||||||
|
node)));
|
||||||
|
step.setNodeData(JSON.toJSONString(node));
|
||||||
|
step.setStartTime(new Date());
|
||||||
|
step.setStatus(nodeStatus.getValue());
|
||||||
|
sendAuditEvent(
|
||||||
|
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
|
||||||
|
execKey + ":started",
|
||||||
|
instanceId,
|
||||||
|
null,
|
||||||
|
step);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleNodeEndEvent(NodeEndEvent event, Chain chain) {
|
private void handleNodeEndEvent(NodeEndEvent event, Chain chain) {
|
||||||
log.info("NodeEndEvent: {}", event);
|
|
||||||
Node node = event.getNode();
|
Node node = event.getNode();
|
||||||
NodeState nodeState = chain.getNodeState(node.getId());
|
String auditInstanceId =
|
||||||
String execKey = nodeState.getMemory().get("executeId").toString();
|
chain.getAuditInstanceId();
|
||||||
WorkflowExecStep step = workflowExecStepService.getByExecKey(execKey);
|
NodeState legacyNodeState = null;
|
||||||
if (step == null) {
|
NodeStatus nodeStatus =
|
||||||
log.error("NodeEndEvent: step not found: {}", execKey);
|
event.getStatus();
|
||||||
} else {
|
if (nodeStatus == null) {
|
||||||
step.setOutput(JSON.toJSONString(event.getResult()));
|
// 兼容旧版引擎未携带不可变终态的事件。
|
||||||
step.setEndTime(new Date());
|
legacyNodeState =
|
||||||
step.setStatus(nodeState.getStatus().getValue());
|
chain.getNodeState(
|
||||||
ExceptionSummary error = nodeState.getError();
|
node.getId());
|
||||||
if (error != null) {
|
nodeStatus =
|
||||||
step.setErrorInfo(error.getRootCauseClass() + " --> " + error.getRootCauseMessage());
|
legacyNodeState.getStatus();
|
||||||
}
|
|
||||||
workflowExecStepService.updateById(step);
|
|
||||||
}
|
}
|
||||||
|
log.info(
|
||||||
|
"workflow event type=node-ended, instanceId={}, nodeId={}, nodeType={}, status={}, resultFieldCount={}",
|
||||||
|
auditInstanceId,
|
||||||
|
node.getId(),
|
||||||
|
node.getClass().getSimpleName(),
|
||||||
|
nodeStatus,
|
||||||
|
event.getResult() == null ? 0 : event.getResult().size());
|
||||||
|
String execKey = currentStepExecKey(
|
||||||
|
event.getExecutionAttemptKey(),
|
||||||
|
chain,
|
||||||
|
node);
|
||||||
|
WorkflowExecStep step = new WorkflowExecStep();
|
||||||
|
step.setExecKey(execKey);
|
||||||
|
// 节点线程只投递轻量引用,完整执行记录仍由审计消费者透明还原。
|
||||||
|
step.setOutput(JSON.toJSONString(
|
||||||
|
event.getResult()));
|
||||||
|
step.setEndTime(new Date());
|
||||||
|
step.setStatus(nodeStatus.getValue());
|
||||||
|
ExceptionSummary error =
|
||||||
|
event.getError() == null
|
||||||
|
? (legacyNodeState == null
|
||||||
|
? null
|
||||||
|
: legacyNodeState.getError())
|
||||||
|
: new ExceptionSummary(
|
||||||
|
event.getError());
|
||||||
|
if (error != null) {
|
||||||
|
step.setErrorInfo(error.getRootCauseClass() + " --> " + error.getRootCauseMessage());
|
||||||
|
}
|
||||||
|
sendAuditEvent(
|
||||||
|
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
|
||||||
|
execKey + ":ended",
|
||||||
|
auditInstanceId,
|
||||||
|
null,
|
||||||
|
step);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleChainStatusChangeEvent(ChainStatusChangeEvent event, Chain chain) {
|
private void handleChainStatusChangeEvent(ChainStatusChangeEvent event, Chain chain) {
|
||||||
log.info("ChainStatusChangeEvent: {}", event);
|
log.info(
|
||||||
|
"workflow event type=chain-status-changed, instanceId={}, status={}",
|
||||||
|
chain.getStateInstanceId(),
|
||||||
|
event.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleChainResumeEvent(ChainResumeEvent event, Chain chain) {
|
private void handleChainResumeEvent(ChainResumeEvent event, Chain chain) {
|
||||||
log.info("ChainResumeEvent: {}", event);
|
log.info(
|
||||||
}
|
"workflow event type=chain-resumed, instanceId={}",
|
||||||
|
chain.getStateInstanceId());
|
||||||
/**
|
|
||||||
* 递归查找顶级状态
|
|
||||||
*/
|
|
||||||
private ChainState findAncestorState(ChainState state, Chain chain) {
|
|
||||||
String parentInstanceId = state.getParentInstanceId();
|
|
||||||
if (StrUtil.isEmpty(parentInstanceId)) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
ChainState chainState = chain.getChainStateRepository().load(parentInstanceId);
|
|
||||||
return findAncestorState(chainState, chain);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -203,4 +257,60 @@ public class ChainEventListenerForSave implements ChainEventListener {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成节点本次业务尝试的稳定执行步骤键。
|
||||||
|
*
|
||||||
|
* @param capturedAttemptKey 事件创建时捕获的业务尝试键
|
||||||
|
* @param chain 当前工作流
|
||||||
|
* @param node 当前节点
|
||||||
|
* @return 长度固定的执行步骤键
|
||||||
|
*/
|
||||||
|
private String currentStepExecKey(
|
||||||
|
String capturedAttemptKey,
|
||||||
|
Chain chain,
|
||||||
|
Node node) {
|
||||||
|
String attemptKey = capturedAttemptKey;
|
||||||
|
if (StrUtil.isBlank(attemptKey)) {
|
||||||
|
// 兼容旧版引擎直接构造、尚未携带不可变尝试键的事件。
|
||||||
|
NodeState nodeState =
|
||||||
|
chain.getNodeState(node.getId());
|
||||||
|
attemptKey = nodeState == null
|
||||||
|
? null
|
||||||
|
: nodeState
|
||||||
|
.getExecutionAttemptKey();
|
||||||
|
}
|
||||||
|
String execKey =
|
||||||
|
WorkflowExecutionStepKey.resolve(
|
||||||
|
attemptKey);
|
||||||
|
if (StrUtil.isBlank(execKey)) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Workflow execution attempt key is unavailable: " + node.getId());
|
||||||
|
}
|
||||||
|
return execKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 投递工作流执行审计事件。
|
||||||
|
*
|
||||||
|
* @param type 事件类型
|
||||||
|
* @param eventId 事件幂等 ID
|
||||||
|
* @param instanceId 顶级工作流实例 ID
|
||||||
|
* @param result 工作流执行记录快照
|
||||||
|
* @param step 节点执行步骤快照
|
||||||
|
*/
|
||||||
|
private void sendAuditEvent(WorkflowExecutionAuditEvent.Type type,
|
||||||
|
String eventId,
|
||||||
|
String instanceId,
|
||||||
|
WorkflowExecResult result,
|
||||||
|
WorkflowExecStep step) {
|
||||||
|
WorkflowExecutionAuditEvent auditEvent = new WorkflowExecutionAuditEvent();
|
||||||
|
auditEvent.setType(type);
|
||||||
|
auditEvent.setEventId(eventId);
|
||||||
|
auditEvent.setInstanceId(instanceId);
|
||||||
|
auditEvent.setOccurredAt(new Date());
|
||||||
|
auditEvent.setResult(result);
|
||||||
|
auditEvent.setStep(step);
|
||||||
|
auditProducer.send(auditEvent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,22 @@ public class BaseRepository {
|
|||||||
return clazz.cast(value);
|
return clazz.cast(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除并校验工作流运行状态缓存。
|
||||||
|
*
|
||||||
|
* @param key 缓存键
|
||||||
|
*/
|
||||||
|
protected void removeCache(String key) {
|
||||||
|
CacheResult result = cache.REMOVE(key);
|
||||||
|
CacheResultCode resultCode = result.getResultCode();
|
||||||
|
if (resultCode == CacheResultCode.NOT_EXISTS || resultCode == CacheResultCode.EXPIRED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.isSuccess()) {
|
||||||
|
throw cacheOperationException("删除", key, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建包含缓存操作上下文的异常。
|
* 构建包含缓存操作上下文的异常。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,14 +20,29 @@ public class ChainDefinitionRepositoryImpl implements ChainDefinitionRepository
|
|||||||
private ChainParser chainParser;
|
private ChainParser chainParser;
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowDatacenterContentService workflowDatacenterContentService;
|
private WorkflowDatacenterContentService workflowDatacenterContentService;
|
||||||
|
@Resource
|
||||||
|
private WorkflowDefinitionCache workflowDefinitionCache;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ChainDefinition getChainDefinitionById(String id) {
|
public ChainDefinition getChainDefinitionById(String id) {
|
||||||
|
return workflowDefinitionCache.get(id, () -> loadAndCompile(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从持久层加载工作流并编译定义。
|
||||||
|
*
|
||||||
|
* @param id 定义 ID
|
||||||
|
* @return 已编译工作流定义
|
||||||
|
*/
|
||||||
|
private ChainDefinition loadAndCompile(String id) {
|
||||||
boolean publishedDefinition = PublishedWorkflowDefinitionIds.isPublished(id);
|
boolean publishedDefinition = PublishedWorkflowDefinitionIds.isPublished(id);
|
||||||
String workflowId = PublishedWorkflowDefinitionIds.unwrap(id);
|
String workflowId = PublishedWorkflowDefinitionIds.unwrap(id);
|
||||||
Workflow workflow = publishedDefinition
|
Workflow workflow = publishedDefinition
|
||||||
? workflowService.getPublishedById(new java.math.BigInteger(workflowId))
|
? workflowService.getPublishedById(new java.math.BigInteger(workflowId))
|
||||||
: workflowService.getById(workflowId);
|
: workflowService.getById(workflowId);
|
||||||
|
if (workflow == null) {
|
||||||
|
throw new IllegalStateException("Workflow not found: " + workflowId);
|
||||||
|
}
|
||||||
String json = workflowDatacenterContentService.prepareContent(workflow.getContent());
|
String json = workflowDatacenterContentService.prepareContent(workflow.getContent());
|
||||||
ChainDefinition chainDefinition = chainParser.parse(json);
|
ChainDefinition chainDefinition = chainParser.parse(json);
|
||||||
chainDefinition.setId(id);
|
chainDefinition.setId(id);
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.repository;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||||
|
import com.easyagents.flow.core.chain.repository.ChainDefinitionSnapshotRepository;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.common.cache.VersionedObjectStore;
|
||||||
|
import tech.easyflow.common.constant.CacheKey;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.ObjectOutputStream;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.WeakHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于 Redis 版本对象存储的工作流实例定义快照仓储。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ChainDefinitionSnapshotRepositoryImpl extends BaseRepository
|
||||||
|
implements ChainDefinitionSnapshotRepository {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快照由工作流终态显式删除;长 TTL 仅用于异常中断后的孤儿兜底清理。
|
||||||
|
*/
|
||||||
|
private static final Duration SNAPSHOT_TTL = Duration.ofDays(7);
|
||||||
|
private static final Map<ChainDefinition, String> CONTENT_HASH_CACHE =
|
||||||
|
Collections.synchronizedMap(new WeakHashMap<>());
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private VersionedObjectStore versionedObjectStore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void save(String instanceId, ChainDefinition definition) {
|
||||||
|
String contentHash = contentHash(definition);
|
||||||
|
String immutableContentKey = contentKey(contentHash);
|
||||||
|
versionedObjectStore.createIfAbsent(
|
||||||
|
immutableContentKey,
|
||||||
|
definition,
|
||||||
|
0L,
|
||||||
|
SNAPSHOT_TTL);
|
||||||
|
// 每个新实例都延长不可变内容寿命,保证引用 TTL 内不会悬空。
|
||||||
|
versionedObjectStore.refreshExpirations(
|
||||||
|
List.of(immutableContentKey), SNAPSHOT_TTL);
|
||||||
|
versionedObjectStore.createIfAbsent(
|
||||||
|
stateKey(instanceId),
|
||||||
|
contentHash,
|
||||||
|
0L,
|
||||||
|
SNAPSHOT_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public ChainDefinition load(String instanceId) {
|
||||||
|
Object reference = versionedObjectStore.load(
|
||||||
|
stateKey(instanceId), Object.class);
|
||||||
|
if (reference instanceof String) {
|
||||||
|
ChainDefinition snapshot = versionedObjectStore.load(
|
||||||
|
contentKey((String) reference),
|
||||||
|
ChainDefinition.class);
|
||||||
|
if (snapshot != null) {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
} else if (reference instanceof ChainDefinition) {
|
||||||
|
// 兼容 XL12 之前按实例保存完整定义的运行中实例。
|
||||||
|
return (ChainDefinition) reference;
|
||||||
|
}
|
||||||
|
ChainDefinition legacy = getCache(legacyKey(instanceId), ChainDefinition.class);
|
||||||
|
if (legacy != null) {
|
||||||
|
save(instanceId, legacy);
|
||||||
|
}
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void remove(String instanceId) {
|
||||||
|
versionedObjectStore.deleteAll(List.of(stateKey(instanceId)));
|
||||||
|
removeCache(legacyKey(instanceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建定义快照缓存键。
|
||||||
|
*
|
||||||
|
* @param instanceId 工作流实例 ID
|
||||||
|
* @return 缓存键
|
||||||
|
*/
|
||||||
|
private String legacyKey(String instanceId) {
|
||||||
|
return CacheKey.CHAIN_DEFINITION_SNAPSHOT_CACHE_KEY + instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建不会随短期运行状态 TTL 漂移的快照键。
|
||||||
|
*
|
||||||
|
* @param instanceId 工作流实例 ID
|
||||||
|
* @return Redis 快照键
|
||||||
|
*/
|
||||||
|
private String stateKey(String instanceId) {
|
||||||
|
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:definition";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建按定义内容复用的快照键。
|
||||||
|
*
|
||||||
|
* @param contentHash 定义内容摘要
|
||||||
|
* @return Redis 内容键
|
||||||
|
*/
|
||||||
|
private String contentKey(String contentHash) {
|
||||||
|
return CacheKey.CHAIN_DEFINITION_SNAPSHOT_CACHE_KEY
|
||||||
|
+ "content:"
|
||||||
|
+ contentHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算定义序列化内容摘要;同一编译定义对象仅计算一次。
|
||||||
|
*
|
||||||
|
* @param definition 编译后的定义
|
||||||
|
* @return SHA-256 十六进制摘要
|
||||||
|
*/
|
||||||
|
private String contentHash(ChainDefinition definition) {
|
||||||
|
if (definition == null) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Chain definition required");
|
||||||
|
}
|
||||||
|
String cached = CONTENT_HASH_CACHE.get(definition);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
try (ByteArrayOutputStream output =
|
||||||
|
new ByteArrayOutputStream();
|
||||||
|
ObjectOutputStream objects =
|
||||||
|
new ObjectOutputStream(output)) {
|
||||||
|
objects.writeObject(definition);
|
||||||
|
objects.flush();
|
||||||
|
String calculated = HexFormat.of().formatHex(
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(output.toByteArray()));
|
||||||
|
CONTENT_HASH_CACHE.put(definition, calculated);
|
||||||
|
return calculated;
|
||||||
|
} catch (IOException | NoSuchAlgorithmException error) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Failed to hash chain definition", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,447 @@
|
|||||||
package tech.easyflow.ai.easyagentsflow.repository;
|
package tech.easyflow.ai.easyagentsflow.repository;
|
||||||
|
|
||||||
import com.easyagents.flow.core.chain.ChainState;
|
import com.easyagents.flow.core.chain.ChainState;
|
||||||
|
import com.easyagents.flow.core.chain.repository.ChainLock;
|
||||||
import com.easyagents.flow.core.chain.repository.ChainStateField;
|
import com.easyagents.flow.core.chain.repository.ChainStateField;
|
||||||
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||||
|
import tech.easyflow.common.cache.VersionedFields;
|
||||||
|
import tech.easyflow.common.cache.VersionedObjectStore;
|
||||||
import tech.easyflow.common.constant.CacheKey;
|
import tech.easyflow.common.constant.CacheKey;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||||
|
import java.util.concurrent.ThreadFactory;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于 Redis 字段化 CAS、认领守卫和短期实例锁的工作流状态仓储。
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class ChainStateRepositoryImpl extends BaseRepository implements ChainStateRepository {
|
public class ChainStateRepositoryImpl extends BaseRepository implements ChainStateRepository {
|
||||||
|
|
||||||
@Override
|
private static final Duration STATE_TTL = Duration.ofDays(3);
|
||||||
public ChainState load(String instanceId) {
|
private static final Duration MIGRATION_MARKER_TTL = Duration.ofDays(4);
|
||||||
String key = CacheKey.CHAIN_CACHE_KEY + instanceId;
|
private static final Duration FENCING_COUNTER_TTL = Duration.ofDays(4);
|
||||||
ChainState chainState = getCache(key, ChainState.class);
|
private static final Duration MIN_LOCK_LEASE = Duration.ofSeconds(30);
|
||||||
if (chainState == null) {
|
private static final ScheduledThreadPoolExecutor LOCK_RENEW_EXECUTOR =
|
||||||
chainState = new ChainState();
|
createLockRenewExecutor();
|
||||||
chainState.setInstanceId(instanceId);
|
private final Set<String> legacyInstances = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||||
putCache(key, chainState);
|
|
||||||
}
|
@Resource
|
||||||
return chainState;
|
private RedisLockExecutor redisLockExecutor;
|
||||||
|
@Resource
|
||||||
|
private VersionedObjectStore versionedObjectStore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建会主动移除已取消任务的锁续期线程池。
|
||||||
|
*
|
||||||
|
* <p>绝大多数实例锁仅持有数毫秒;开启 remove-on-cancel 可避免高吞吐场景下,
|
||||||
|
* 已取消的十秒延迟续期任务在队列中短时堆积。</p>
|
||||||
|
*
|
||||||
|
* @return 小型多线程锁续期调度器
|
||||||
|
*/
|
||||||
|
private static ScheduledThreadPoolExecutor createLockRenewExecutor() {
|
||||||
|
ScheduledThreadPoolExecutor executor =
|
||||||
|
new ScheduledThreadPoolExecutor(2, new LockRenewThreadFactory());
|
||||||
|
executor.setRemoveOnCancelPolicy(true);
|
||||||
|
executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
|
||||||
|
return executor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public ChainState load(String instanceId) {
|
||||||
|
String stateKey = stateKey(instanceId);
|
||||||
|
VersionedFields snapshot = versionedObjectStore.loadFields(stateKey);
|
||||||
|
if (WorkflowStateFields.isFieldFormat(snapshot)) {
|
||||||
|
legacyInstances.remove(instanceId);
|
||||||
|
return WorkflowStateFields.decodeChain(snapshot);
|
||||||
|
}
|
||||||
|
if (snapshot != null) {
|
||||||
|
ChainState payloadState = versionedObjectStore.load(stateKey, ChainState.class);
|
||||||
|
if (payloadState != null) {
|
||||||
|
rewritePayloadState(stateKey, payloadState);
|
||||||
|
return payloadState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasMigrationMarker(instanceId)) {
|
||||||
|
legacyInstances.remove(instanceId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String legacyKey = CacheKey.CHAIN_CACHE_KEY + instanceId;
|
||||||
|
ChainState legacyState = getCache(legacyKey, ChainState.class);
|
||||||
|
if (legacyState == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 活跃旧实例继续沿用旧写路径,避免滚动升级期间两个格式同时推进。
|
||||||
|
if (legacyState.getStatus() == null || !legacyState.getStatus().isTerminal()) {
|
||||||
|
legacyInstances.add(instanceId);
|
||||||
|
return legacyState;
|
||||||
|
}
|
||||||
|
migrateTerminalLegacyState(instanceId, legacyKey, legacyState);
|
||||||
|
VersionedFields migrated = versionedObjectStore.loadFields(stateKey);
|
||||||
|
if (!WorkflowStateFields.isFieldFormat(migrated)) {
|
||||||
|
throw new IllegalStateException("Workflow state migration failed: " + instanceId);
|
||||||
|
}
|
||||||
|
return WorkflowStateFields.decodeChain(migrated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Long loadVersion(String instanceId) {
|
||||||
|
if (!legacyInstances.contains(instanceId)) {
|
||||||
|
Long version = versionedObjectStore.loadVersion(stateKey(instanceId));
|
||||||
|
if (version != null) {
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChainState state = load(instanceId);
|
||||||
|
return state == null ? null : state.getVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public ChainState create(String instanceId) {
|
||||||
|
ChainState existing = load(instanceId);
|
||||||
|
if (existing != null) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
ChainState created = new ChainState();
|
||||||
|
created.setInstanceId(instanceId);
|
||||||
|
if (versionedObjectStore.createFieldsIfAbsent(
|
||||||
|
stateKey(instanceId),
|
||||||
|
WorkflowStateFields.allChainFields(created),
|
||||||
|
created.getVersion(),
|
||||||
|
STATE_TTL)) {
|
||||||
|
touchMigrationMarker(instanceId);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
VersionedFields concurrent = versionedObjectStore.loadFields(stateKey(instanceId));
|
||||||
|
if (!WorkflowStateFields.isFieldFormat(concurrent)) {
|
||||||
|
throw new IllegalStateException("Unable to initialize workflow state: " + instanceId);
|
||||||
|
}
|
||||||
|
return WorkflowStateFields.decodeChain(concurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public boolean tryUpdate(ChainState newState, EnumSet<ChainStateField> fields) {
|
public boolean tryUpdate(ChainState newState, EnumSet<ChainStateField> fields) {
|
||||||
String key = CacheKey.CHAIN_CACHE_KEY + newState.getInstanceId();
|
return tryUpdate(newState, fields, 0L);
|
||||||
putCache(key, newState);
|
}
|
||||||
return true;
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean tryUpdate(
|
||||||
|
ChainState newState, EnumSet<ChainStateField> fields, long fencingToken) {
|
||||||
|
return tryUpdate(newState, fields, fencingToken, null, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean tryUpdate(
|
||||||
|
ChainState newState,
|
||||||
|
EnumSet<ChainStateField> fields,
|
||||||
|
long lockFencingToken,
|
||||||
|
String claimId,
|
||||||
|
long claimGeneration) {
|
||||||
|
String instanceId = newState.getInstanceId();
|
||||||
|
if (legacyInstances.contains(instanceId)) {
|
||||||
|
String legacyKey = CacheKey.CHAIN_CACHE_KEY + instanceId;
|
||||||
|
putCache(legacyKey, newState);
|
||||||
|
if (newState.getStatus() != null && newState.getStatus().isTerminal()) {
|
||||||
|
migrateTerminalLegacyState(instanceId, legacyKey, newState);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
long newVersion = newState.getVersion();
|
||||||
|
if (newVersion <= 0L) {
|
||||||
|
throw new IllegalArgumentException("newState.version must be positive");
|
||||||
|
}
|
||||||
|
requireClaimId(claimId, claimGeneration);
|
||||||
|
boolean updated;
|
||||||
|
if (lockFencingToken > 0L) {
|
||||||
|
String claimGuardKey = claimGeneration > 0L
|
||||||
|
? executionGuardKey(instanceId, claimId)
|
||||||
|
: lockFenceKey(instanceId);
|
||||||
|
long effectiveClaimGeneration = claimGeneration > 0L
|
||||||
|
? claimGeneration
|
||||||
|
: lockFencingToken;
|
||||||
|
updated = versionedObjectStore.compareAndSetFieldsAndRefresh(
|
||||||
|
stateKey(instanceId),
|
||||||
|
newVersion - 1L,
|
||||||
|
WorkflowStateFields.chainFields(newState, fields),
|
||||||
|
newVersion,
|
||||||
|
lockFenceKey(instanceId),
|
||||||
|
lockFencingToken,
|
||||||
|
claimGuardKey,
|
||||||
|
effectiveClaimGeneration,
|
||||||
|
STATE_TTL,
|
||||||
|
markerKey(instanceId),
|
||||||
|
MIGRATION_MARKER_TTL);
|
||||||
|
} else {
|
||||||
|
updated = versionedObjectStore.compareAndSetFields(
|
||||||
|
stateKey(instanceId),
|
||||||
|
newVersion - 1L,
|
||||||
|
WorkflowStateFields.chainFields(newState, fields),
|
||||||
|
newVersion,
|
||||||
|
STATE_TTL);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将当前 Redis Hash 中的旧完整 payload 原地改写为字段化状态。
|
||||||
|
*
|
||||||
|
* @param stateKey 状态键
|
||||||
|
* @param state 旧完整状态
|
||||||
|
*/
|
||||||
|
private void rewritePayloadState(String stateKey, ChainState state) {
|
||||||
|
boolean rewritten = versionedObjectStore.rewriteAsFields(
|
||||||
|
stateKey,
|
||||||
|
state.getVersion(),
|
||||||
|
WorkflowStateFields.allChainFields(state),
|
||||||
|
STATE_TTL);
|
||||||
|
if (rewritten) {
|
||||||
|
touchMigrationMarker(state.getInstanceId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在旧实例终态后完成受控迁移并留下 tombstone。
|
||||||
|
*
|
||||||
|
* @param instanceId 实例 ID
|
||||||
|
* @param legacyKey 旧 JetCache 键
|
||||||
|
* @param state 终态状态
|
||||||
|
*/
|
||||||
|
private void migrateTerminalLegacyState(
|
||||||
|
String instanceId, String legacyKey, ChainState state) {
|
||||||
|
boolean created = versionedObjectStore.createFieldsIfAbsent(
|
||||||
|
stateKey(instanceId),
|
||||||
|
WorkflowStateFields.allChainFields(state),
|
||||||
|
state.getVersion(),
|
||||||
|
STATE_TTL);
|
||||||
|
VersionedFields existing = versionedObjectStore.loadFields(stateKey(instanceId));
|
||||||
|
if (!created && !WorkflowStateFields.isFieldFormat(existing)) {
|
||||||
|
throw new IllegalStateException("Workflow state migration conflict: " + instanceId);
|
||||||
|
}
|
||||||
|
touchMigrationMarker(instanceId);
|
||||||
|
removeCache(legacyKey);
|
||||||
|
legacyInstances.remove(instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断实例是否已经切换到字段化格式。
|
||||||
|
*
|
||||||
|
* @param instanceId 实例 ID
|
||||||
|
* @return 已存在迁移 tombstone 时为 {@code true}
|
||||||
|
*/
|
||||||
|
private boolean hasMigrationMarker(String instanceId) {
|
||||||
|
return versionedObjectStore.loadFields(markerKey(instanceId)) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建或刷新迁移 tombstone,防止新状态过期后复活旧缓存。
|
||||||
|
*
|
||||||
|
* @param instanceId 实例 ID
|
||||||
|
*/
|
||||||
|
private void touchMigrationMarker(String instanceId) {
|
||||||
|
Map<String, Serializable> marker = Map.of(
|
||||||
|
WorkflowStateFields.FORMAT_FIELD, WorkflowStateFields.FORMAT_VERSION);
|
||||||
|
if (!versionedObjectStore.createFieldsIfAbsent(
|
||||||
|
markerKey(instanceId), marker, 0L, MIGRATION_MARKER_TTL)) {
|
||||||
|
versionedObjectStore.compareAndSetFields(
|
||||||
|
markerKey(instanceId), 0L, marker, 0L, MIGRATION_MARKER_TTL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建工作流状态 CAS key。
|
||||||
|
*
|
||||||
|
* @param instanceId 工作流实例 ID
|
||||||
|
* @return Redis 状态 key
|
||||||
|
*/
|
||||||
|
private String stateKey(String instanceId) {
|
||||||
|
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:chain";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建状态格式 tombstone 键。
|
||||||
|
*
|
||||||
|
* @param instanceId 实例 ID
|
||||||
|
* @return Redis marker 键
|
||||||
|
*/
|
||||||
|
private String markerKey(String instanceId) {
|
||||||
|
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:format";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建本次触发器认领的执行守卫键。
|
||||||
|
*
|
||||||
|
* @param instanceId 工作流实例 ID
|
||||||
|
* @param claimId 触发器 ID
|
||||||
|
* @return 执行守卫键
|
||||||
|
*/
|
||||||
|
private String executionGuardKey(String instanceId, String claimId) {
|
||||||
|
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:claim:" + claimId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验分布式提交所需的 claim ID。
|
||||||
|
*
|
||||||
|
* @param claimId 触发器 ID
|
||||||
|
* @param fencingToken 当前认领 token
|
||||||
|
*/
|
||||||
|
private void requireClaimId(String claimId, long fencingToken) {
|
||||||
|
if (fencingToken > 0L && (claimId == null || claimId.trim().isEmpty())) {
|
||||||
|
throw new IllegalArgumentException("claimId is required with fencingToken");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工作流实例级 Redis 分布式锁。
|
||||||
|
*
|
||||||
|
* @param instanceId 工作流实例 ID
|
||||||
|
* @param timeout 等待锁的最大时间
|
||||||
|
* @param unit 时间单位
|
||||||
|
* @return 分布式锁句柄
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public ChainLock getLock(String instanceId, long timeout, TimeUnit unit) {
|
||||||
|
if (instanceId == null || instanceId.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("instanceId must not be blank");
|
||||||
|
}
|
||||||
|
if (redisLockExecutor == null) {
|
||||||
|
throw new IllegalStateException("RedisLockExecutor is not configured");
|
||||||
|
}
|
||||||
|
Duration waitTimeout = Duration.ofMillis(Math.max(1L, unit.toMillis(timeout)));
|
||||||
|
Duration leaseTimeout = waitTimeout.compareTo(MIN_LOCK_LEASE) > 0 ? waitTimeout : MIN_LOCK_LEASE;
|
||||||
|
RedisLockExecutor.LockHandle handle = redisLockExecutor.tryAcquireFenced(
|
||||||
|
CacheKey.CHAIN_LOCK_KEY + "{" + instanceId + "}",
|
||||||
|
lockFenceKey(instanceId),
|
||||||
|
waitTimeout,
|
||||||
|
leaseTimeout,
|
||||||
|
FENCING_COUNTER_TTL);
|
||||||
|
if (handle == null) {
|
||||||
|
return new RedisChainLock(null, 0L);
|
||||||
|
}
|
||||||
|
return new RedisChainLock(handle, handle.getFencingToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建实例锁 fencing token 键。
|
||||||
|
*
|
||||||
|
* @param instanceId 工作流实例 ID
|
||||||
|
* @return fencing token 哈希键
|
||||||
|
*/
|
||||||
|
private String lockFenceKey(String instanceId) {
|
||||||
|
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:fence";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 工作流锁适配器。
|
||||||
|
*/
|
||||||
|
private static final class RedisChainLock implements ChainLock {
|
||||||
|
|
||||||
|
private final RedisLockExecutor.LockHandle handle;
|
||||||
|
private final long fencingToken;
|
||||||
|
private final AtomicBoolean valid = new AtomicBoolean(true);
|
||||||
|
private final ScheduledFuture<?> renewTask;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建锁适配器。
|
||||||
|
*
|
||||||
|
* @param handle Redis 锁句柄;为空表示未获取
|
||||||
|
* @param fencingToken 本次锁持有期的 fencing token
|
||||||
|
*/
|
||||||
|
private RedisChainLock(
|
||||||
|
RedisLockExecutor.LockHandle handle, long fencingToken) {
|
||||||
|
this.handle = handle;
|
||||||
|
this.fencingToken = fencingToken;
|
||||||
|
this.renewTask = handle == null ? null : LOCK_RENEW_EXECUTOR.scheduleWithFixedDelay(
|
||||||
|
() -> {
|
||||||
|
if (!handle.renew()) {
|
||||||
|
valid.set(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MIN_LOCK_LEASE.toMillis() / 3L,
|
||||||
|
MIN_LOCK_LEASE.toMillis() / 3L,
|
||||||
|
TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isAcquired() {
|
||||||
|
return handle != null && valid.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isValid() {
|
||||||
|
return isAcquired();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public long getFencingToken() {
|
||||||
|
return fencingToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
valid.set(false);
|
||||||
|
if (renewTask != null) {
|
||||||
|
renewTask.cancel(false);
|
||||||
|
}
|
||||||
|
if (handle != null) {
|
||||||
|
handle.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建守护型工作流状态锁续期线程。
|
||||||
|
*/
|
||||||
|
private static final class LockRenewThreadFactory implements ThreadFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Thread newThread(Runnable runnable) {
|
||||||
|
Thread thread = new Thread(runnable, "workflow-state-lock-renew");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,33 +1,320 @@
|
|||||||
package tech.easyflow.ai.easyagentsflow.repository;
|
package tech.easyflow.ai.easyagentsflow.repository;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.ChainState;
|
||||||
import com.easyagents.flow.core.chain.NodeState;
|
import com.easyagents.flow.core.chain.NodeState;
|
||||||
import com.easyagents.flow.core.chain.repository.NodeStateField;
|
import com.easyagents.flow.core.chain.repository.NodeStateField;
|
||||||
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
|
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.common.cache.VersionedObjectStore;
|
||||||
|
import tech.easyflow.common.cache.VersionedFields;
|
||||||
import tech.easyflow.common.constant.CacheKey;
|
import tech.easyflow.common.constant.CacheKey;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ConcurrentMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于 Redis 原子版本存储的节点状态仓储。
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class NodeStateRepositoryImpl extends BaseRepository implements NodeStateRepository {
|
public class NodeStateRepositoryImpl extends BaseRepository implements NodeStateRepository {
|
||||||
|
|
||||||
|
private static final Duration STATE_TTL = Duration.ofDays(3);
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private VersionedObjectStore versionedObjectStore;
|
||||||
|
private final ConcurrentMap<String, Boolean> legacyFormats = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public NodeState load(String instanceId, String nodeId) {
|
public NodeState load(String instanceId, String nodeId) {
|
||||||
String key = CacheKey.NODE_CACHE_KEY + instanceId + ":" + nodeId;
|
String stateKey = stateKey(instanceId, nodeId);
|
||||||
NodeState nodeState = getCache(key, NodeState.class);
|
VersionedFields snapshot = versionedObjectStore.loadFields(stateKey);
|
||||||
if (nodeState == null) {
|
if (WorkflowStateFields.isFieldFormat(snapshot)) {
|
||||||
nodeState = new NodeState();
|
return WorkflowStateFields.decodeNode(snapshot);
|
||||||
nodeState.setChainInstanceId(instanceId);
|
|
||||||
nodeState.setNodeId(nodeId);
|
|
||||||
putCache(key, nodeState);
|
|
||||||
}
|
}
|
||||||
return nodeState;
|
if (snapshot != null) {
|
||||||
|
NodeState payloadState = versionedObjectStore.load(stateKey, NodeState.class);
|
||||||
|
if (payloadState != null) {
|
||||||
|
WorkflowStateFields.normalizeNode(
|
||||||
|
payloadState);
|
||||||
|
versionedObjectStore.rewriteAsFields(
|
||||||
|
stateKey,
|
||||||
|
payloadState.getVersion(),
|
||||||
|
WorkflowStateFields.allNodeFields(payloadState),
|
||||||
|
STATE_TTL);
|
||||||
|
return payloadState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String legacyKey = legacyStateKey(instanceId, nodeId);
|
||||||
|
NodeState legacyState = getCache(legacyKey, NodeState.class);
|
||||||
|
if (legacyState == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
WorkflowStateFields.normalizeNode(
|
||||||
|
legacyState);
|
||||||
|
if (isLegacyInstance(instanceId)) {
|
||||||
|
return legacyState;
|
||||||
|
}
|
||||||
|
versionedObjectStore.createFieldsIfAbsent(
|
||||||
|
stateKey,
|
||||||
|
WorkflowStateFields.allNodeFields(legacyState),
|
||||||
|
legacyState.getVersion(),
|
||||||
|
STATE_TTL);
|
||||||
|
VersionedFields migrated = versionedObjectStore.loadFields(stateKey);
|
||||||
|
if (!WorkflowStateFields.isFieldFormat(migrated)) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Workflow node state migration failed: " + instanceId + "/" + nodeId);
|
||||||
|
}
|
||||||
|
removeCache(legacyKey);
|
||||||
|
return WorkflowStateFields.decodeNode(migrated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public NodeState create(String instanceId, String nodeId, long chainStateVersion) {
|
||||||
|
return create(instanceId, nodeId, chainStateVersion, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public NodeState create(
|
||||||
|
String instanceId,
|
||||||
|
String nodeId,
|
||||||
|
long chainStateVersion,
|
||||||
|
long fencingToken) {
|
||||||
|
return create(instanceId, nodeId, chainStateVersion, fencingToken, null, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public NodeState create(
|
||||||
|
String instanceId,
|
||||||
|
String nodeId,
|
||||||
|
long chainStateVersion,
|
||||||
|
long lockFencingToken,
|
||||||
|
String claimId,
|
||||||
|
long claimGeneration) {
|
||||||
|
NodeState existing = load(instanceId, nodeId);
|
||||||
|
if (existing != null) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
NodeState created = new NodeState();
|
||||||
|
created.setChainInstanceId(instanceId);
|
||||||
|
created.setNodeId(nodeId);
|
||||||
|
if (isLegacyInstance(instanceId)) {
|
||||||
|
putCache(legacyStateKey(instanceId, nodeId), created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
requireClaimId(claimId, claimGeneration);
|
||||||
|
String claimGuardKey = claimGeneration > 0L
|
||||||
|
? executionGuardKey(instanceId, claimId)
|
||||||
|
: lockFenceKey(instanceId);
|
||||||
|
long effectiveClaimGeneration = claimGeneration > 0L
|
||||||
|
? claimGeneration
|
||||||
|
: lockFencingToken;
|
||||||
|
boolean createdNow = lockFencingToken > 0L
|
||||||
|
? versionedObjectStore.createFieldsIfAbsent(
|
||||||
|
stateKey(instanceId, nodeId),
|
||||||
|
WorkflowStateFields.allNodeFields(created),
|
||||||
|
created.getVersion(),
|
||||||
|
chainStateKey(instanceId),
|
||||||
|
chainStateVersion,
|
||||||
|
lockFenceKey(instanceId),
|
||||||
|
lockFencingToken,
|
||||||
|
claimGuardKey,
|
||||||
|
effectiveClaimGeneration,
|
||||||
|
STATE_TTL)
|
||||||
|
: versionedObjectStore.createFieldsIfAbsent(
|
||||||
|
stateKey(instanceId, nodeId),
|
||||||
|
WorkflowStateFields.allNodeFields(created),
|
||||||
|
created.getVersion(),
|
||||||
|
chainStateKey(instanceId),
|
||||||
|
chainStateVersion,
|
||||||
|
STATE_TTL);
|
||||||
|
if (createdNow) {
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
VersionedFields concurrent = versionedObjectStore.loadFields(
|
||||||
|
stateKey(instanceId, nodeId));
|
||||||
|
return WorkflowStateFields.isFieldFormat(concurrent)
|
||||||
|
? WorkflowStateFields.decodeNode(concurrent)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public boolean tryUpdate(NodeState newState, EnumSet<NodeStateField> fields, long chainStateVersion) {
|
public boolean tryUpdate(NodeState newState, EnumSet<NodeStateField> fields, long chainStateVersion) {
|
||||||
String key = CacheKey.NODE_CACHE_KEY + newState.getChainInstanceId() + ":" + newState.getNodeId();
|
return tryUpdate(newState, fields, chainStateVersion, 0L);
|
||||||
putCache(key, newState);
|
}
|
||||||
return true;
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean tryUpdate(
|
||||||
|
NodeState newState,
|
||||||
|
EnumSet<NodeStateField> fields,
|
||||||
|
long chainStateVersion,
|
||||||
|
long fencingToken) {
|
||||||
|
return tryUpdate(
|
||||||
|
newState, fields, chainStateVersion, fencingToken, null, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean tryUpdate(
|
||||||
|
NodeState newState,
|
||||||
|
EnumSet<NodeStateField> fields,
|
||||||
|
long chainStateVersion,
|
||||||
|
long lockFencingToken,
|
||||||
|
String claimId,
|
||||||
|
long claimGeneration) {
|
||||||
|
String instanceId = newState.getChainInstanceId();
|
||||||
|
if (isLegacyInstance(instanceId)) {
|
||||||
|
putCache(legacyStateKey(instanceId, newState.getNodeId()), newState);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
long newVersion = newState.getVersion();
|
||||||
|
if (newVersion <= 0L) {
|
||||||
|
throw new IllegalArgumentException("newState.version must be positive");
|
||||||
|
}
|
||||||
|
requireClaimId(claimId, claimGeneration);
|
||||||
|
String claimGuardKey = claimGeneration > 0L
|
||||||
|
? executionGuardKey(instanceId, claimId)
|
||||||
|
: lockFenceKey(instanceId);
|
||||||
|
long effectiveClaimGeneration = claimGeneration > 0L
|
||||||
|
? claimGeneration
|
||||||
|
: lockFencingToken;
|
||||||
|
return lockFencingToken > 0L
|
||||||
|
? versionedObjectStore.compareAndSetFields(
|
||||||
|
stateKey(instanceId, newState.getNodeId()),
|
||||||
|
newVersion - 1L,
|
||||||
|
WorkflowStateFields.nodeFields(newState, fields),
|
||||||
|
newVersion,
|
||||||
|
chainStateKey(instanceId),
|
||||||
|
chainStateVersion,
|
||||||
|
lockFenceKey(instanceId),
|
||||||
|
lockFencingToken,
|
||||||
|
claimGuardKey,
|
||||||
|
effectiveClaimGeneration,
|
||||||
|
STATE_TTL)
|
||||||
|
: versionedObjectStore.compareAndSetFields(
|
||||||
|
stateKey(instanceId, newState.getNodeId()),
|
||||||
|
newVersion - 1L,
|
||||||
|
WorkflowStateFields.nodeFields(newState, fields),
|
||||||
|
newVersion,
|
||||||
|
chainStateKey(instanceId),
|
||||||
|
chainStateVersion,
|
||||||
|
STATE_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断实例是否仍由旧 JetCache 状态推进。
|
||||||
|
*
|
||||||
|
* @param instanceId 实例 ID
|
||||||
|
* @return 活跃旧格式实例时为 {@code true}
|
||||||
|
*/
|
||||||
|
private boolean isLegacyInstance(String instanceId) {
|
||||||
|
return legacyFormats.computeIfAbsent(instanceId, this::detectLegacyInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从持久化状态检测实例格式。
|
||||||
|
*
|
||||||
|
* @param instanceId 实例 ID
|
||||||
|
* @return 活跃旧格式实例时为 {@code true}
|
||||||
|
*/
|
||||||
|
private boolean detectLegacyInstance(String instanceId) {
|
||||||
|
VersionedFields chainSnapshot = versionedObjectStore.loadFields(
|
||||||
|
chainStateKey(instanceId));
|
||||||
|
if (WorkflowStateFields.isFieldFormat(chainSnapshot)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
VersionedFields marker = versionedObjectStore.loadFields(
|
||||||
|
CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:format");
|
||||||
|
if (marker != null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return getCache(CacheKey.CHAIN_CACHE_KEY + instanceId, ChainState.class) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建旧节点状态键。
|
||||||
|
*
|
||||||
|
* @param instanceId 实例 ID
|
||||||
|
* @param nodeId 节点 ID
|
||||||
|
* @return JetCache 键
|
||||||
|
*/
|
||||||
|
private String legacyStateKey(String instanceId, String nodeId) {
|
||||||
|
return CacheKey.NODE_CACHE_KEY + instanceId + ":" + nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建节点状态 CAS key。
|
||||||
|
*
|
||||||
|
* @param instanceId 工作流实例 ID
|
||||||
|
* @param nodeId 节点 ID
|
||||||
|
* @return Redis 状态 key
|
||||||
|
*/
|
||||||
|
private String stateKey(String instanceId, String nodeId) {
|
||||||
|
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:node:" + nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建节点状态提交所依赖的工作流状态 key。
|
||||||
|
*
|
||||||
|
* @param instanceId 工作流实例 ID
|
||||||
|
* @return Redis 工作流状态 key
|
||||||
|
*/
|
||||||
|
private String chainStateKey(String instanceId) {
|
||||||
|
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:chain";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建实例锁 fencing token 键。
|
||||||
|
*
|
||||||
|
* @param instanceId 工作流实例 ID
|
||||||
|
* @return fencing token 键
|
||||||
|
*/
|
||||||
|
private String lockFenceKey(String instanceId) {
|
||||||
|
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:fence";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建实例 fencing token 键。
|
||||||
|
*
|
||||||
|
* @param instanceId 工作流实例 ID
|
||||||
|
* @return fencing token 键
|
||||||
|
*/
|
||||||
|
private String executionGuardKey(String instanceId, String claimId) {
|
||||||
|
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:claim:" + claimId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验分布式提交所需的 claim ID。
|
||||||
|
*
|
||||||
|
* @param claimId 触发器 ID
|
||||||
|
* @param fencingToken 当前认领 token
|
||||||
|
*/
|
||||||
|
private void requireClaimId(String claimId, long fencingToken) {
|
||||||
|
if (fencingToken > 0L && (claimId == null || claimId.trim().isEmpty())) {
|
||||||
|
throw new IllegalArgumentException("claimId is required with fencingToken");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,785 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.repository;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.runtime.Trigger;
|
||||||
|
import com.easyagents.flow.core.chain.runtime.TriggerClaimLostException;
|
||||||
|
import com.easyagents.flow.core.chain.runtime.TriggerStore;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import tech.easyflow.common.constant.CacheKey;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.IdentityHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于 Redis 有序集合和租约认领的工作流触发器仓储。
|
||||||
|
*
|
||||||
|
* <p>当前实现的待执行集合与触发器数据使用同一 Lua 操作,派生触发器保存时会同时
|
||||||
|
* 校验实例锁 fencing token 和父触发器认领代际。部署约束为 Redis Standalone 或
|
||||||
|
* Sentinel;不支持 Redis Cluster。</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class RedisTriggerStore implements TriggerStore {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RedisTriggerStore.class);
|
||||||
|
private static final Duration TRIGGER_TTL = Duration.ofDays(3);
|
||||||
|
private static final Duration CLAIM_GENERATION_TTL = Duration.ofDays(4);
|
||||||
|
/**
|
||||||
|
* 与底层调度器本地 Future 容量对齐,避免已预热任务反复占据扫描窗口。
|
||||||
|
*/
|
||||||
|
private static final int DUE_BATCH_SIZE = 1024;
|
||||||
|
private static final int RECOVERY_BATCH_SIZE = 1000;
|
||||||
|
private static final DefaultRedisScript<Long> SAVE_SCRIPT = longScript(
|
||||||
|
"redis.call('psetex', KEYS[1], ARGV[1], ARGV[2]); "
|
||||||
|
+ "redis.call('zadd', KEYS[2], ARGV[3], ARGV[4]); return 1");
|
||||||
|
private static final DefaultRedisScript<Long> SAVE_IF_ABSENT_SCRIPT =
|
||||||
|
longScript(
|
||||||
|
"if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
|
||||||
|
+ "redis.call('psetex', KEYS[1], ARGV[1], ARGV[2]); "
|
||||||
|
+ "redis.call('zadd', KEYS[2], ARGV[3], ARGV[4]); return 1");
|
||||||
|
private static final DefaultRedisScript<Long> LOCK_GUARDED_SAVE_SCRIPT = longScript(
|
||||||
|
"local fence = redis.call('hget', KEYS[3], 'version'); "
|
||||||
|
+ "if not fence or fence ~= ARGV[5] then return 0 end; "
|
||||||
|
+ "redis.call('psetex', KEYS[1], ARGV[1], ARGV[2]); "
|
||||||
|
+ "redis.call('zadd', KEYS[2], ARGV[3], ARGV[4]); return 1");
|
||||||
|
private static final DefaultRedisScript<Long>
|
||||||
|
LOCK_GUARDED_SAVE_IF_ABSENT_SCRIPT =
|
||||||
|
longScript(
|
||||||
|
"local fence = redis.call('hget', KEYS[3], 'version'); "
|
||||||
|
+ "if not fence or fence ~= ARGV[5] then return -1 end; "
|
||||||
|
+ "if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
|
||||||
|
+ "redis.call('psetex', KEYS[1], ARGV[1], ARGV[2]); "
|
||||||
|
+ "redis.call('zadd', KEYS[2], ARGV[3], ARGV[4]); return 1");
|
||||||
|
private static final DefaultRedisScript<Long> DOUBLE_GUARDED_SAVE_SCRIPT = longScript(
|
||||||
|
"local fence = redis.call('hget', KEYS[3], 'version'); "
|
||||||
|
+ "local claim = redis.call('hget', KEYS[4], 'version'); "
|
||||||
|
+ "if not fence or fence ~= ARGV[5] "
|
||||||
|
+ "or not claim or claim ~= ARGV[6] then return 0 end; "
|
||||||
|
+ "redis.call('psetex', KEYS[1], ARGV[1], ARGV[2]); "
|
||||||
|
+ "redis.call('zadd', KEYS[2], ARGV[3], ARGV[4]); return 1");
|
||||||
|
private static final DefaultRedisScript<Long>
|
||||||
|
DOUBLE_GUARDED_SAVE_IF_ABSENT_SCRIPT =
|
||||||
|
longScript(
|
||||||
|
"local fence = redis.call('hget', KEYS[3], 'version'); "
|
||||||
|
+ "local claim = redis.call('hget', KEYS[4], 'version'); "
|
||||||
|
+ "if not fence or fence ~= ARGV[5] "
|
||||||
|
+ "or not claim or claim ~= ARGV[6] then return -1 end; "
|
||||||
|
+ "if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
|
||||||
|
+ "redis.call('psetex', KEYS[1], ARGV[1], ARGV[2]); "
|
||||||
|
+ "redis.call('zadd', KEYS[2], ARGV[3], ARGV[4]); return 1");
|
||||||
|
private static final DefaultRedisScript<String> CLAIM_SCRIPT = stringScript(
|
||||||
|
"if redis.call('exists', KEYS[1]) == 0 then "
|
||||||
|
+ "redis.call('zrem', KEYS[3], ARGV[3]); return nil end; "
|
||||||
|
+ "local claimed = redis.call('set', KEYS[2], ARGV[1], 'PX', ARGV[2], 'NX'); "
|
||||||
|
+ "if not claimed then return nil end; "
|
||||||
|
+ "local payload = redis.call('get', KEYS[1]); "
|
||||||
|
+ "local generation = redis.call('hincrby', KEYS[5], 'version', 1); "
|
||||||
|
+ "redis.call('pexpire', KEYS[5], ARGV[5]); "
|
||||||
|
+ "redis.call('hset', KEYS[4], 'version', generation); "
|
||||||
|
+ "redis.call('pexpire', KEYS[4], ARGV[2]); "
|
||||||
|
+ "redis.call('zadd', KEYS[3], ARGV[4], ARGV[3]); "
|
||||||
|
+ "return tostring(generation) .. '\\n' .. payload");
|
||||||
|
private static final DefaultRedisScript<Long> ACK_SCRIPT = longScript(
|
||||||
|
"if redis.call('get', KEYS[1]) == ARGV[1] then "
|
||||||
|
+ "redis.call('del', KEYS[1]); redis.call('del', KEYS[2]); "
|
||||||
|
+ "redis.call('del', KEYS[4]); "
|
||||||
|
+ "redis.call('zrem', KEYS[3], ARGV[2]); return 1 else return 0 end");
|
||||||
|
private static final DefaultRedisScript<Long> RELEASE_SCRIPT = longScript(
|
||||||
|
"if redis.call('get', KEYS[1]) == ARGV[1] then "
|
||||||
|
+ "redis.call('del', KEYS[1]); redis.call('del', KEYS[4]); "
|
||||||
|
+ "redis.call('psetex', KEYS[3], ARGV[4], ARGV[5]); "
|
||||||
|
+ "redis.call('zadd', KEYS[2], ARGV[3], ARGV[2]); "
|
||||||
|
+ "return 1 else return 0 end");
|
||||||
|
private static final DefaultRedisScript<Long> MARK_DEAD_LETTER_PENDING_SCRIPT =
|
||||||
|
longScript(
|
||||||
|
"if redis.call('get', KEYS[1]) == ARGV[1] then "
|
||||||
|
+ "redis.call('psetex', KEYS[2], ARGV[2], ARGV[3]); "
|
||||||
|
+ "return 1 else return 0 end");
|
||||||
|
private static final DefaultRedisScript<Long> DEAD_LETTER_SCRIPT = longScript(
|
||||||
|
"if redis.call('get', KEYS[1]) == ARGV[1] then "
|
||||||
|
+ "redis.call('psetex', KEYS[4], ARGV[3], ARGV[4]); "
|
||||||
|
+ "redis.call('del', KEYS[5]); "
|
||||||
|
+ "redis.call('del', KEYS[1]); redis.call('del', KEYS[2]); "
|
||||||
|
+ "redis.call('zrem', KEYS[3], ARGV[2]); return 1 else return 0 end");
|
||||||
|
private static final DefaultRedisScript<Long> RENEW_SCRIPT = longScript(
|
||||||
|
"local guard = redis.call('hget', KEYS[3], 'version'); "
|
||||||
|
+ "if redis.call('get', KEYS[1]) == ARGV[1] "
|
||||||
|
+ "and guard and guard == ARGV[5] then "
|
||||||
|
+ "redis.call('pexpire', KEYS[1], ARGV[2]); "
|
||||||
|
+ "redis.call('pexpire', KEYS[3], ARGV[2]); "
|
||||||
|
+ "redis.call('zadd', KEYS[2], ARGV[4], ARGV[3]); "
|
||||||
|
+ "return 1 else return 0 end");
|
||||||
|
private static final DefaultRedisScript<Long> REMOVE_SCRIPT = longScript(
|
||||||
|
"redis.call('del', KEYS[1]); redis.call('del', KEYS[2]); "
|
||||||
|
+ "return redis.call('zrem', KEYS[3], ARGV[1])");
|
||||||
|
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final Map<Trigger, ClaimContext> claimContexts =
|
||||||
|
Collections.synchronizedMap(new IdentityHashMap<>());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Redis 触发器仓储。
|
||||||
|
*
|
||||||
|
* @param redisTemplate Redis 字符串模板
|
||||||
|
* @param objectMapper JSON 序列化器
|
||||||
|
*/
|
||||||
|
public RedisTriggerStore(StringRedisTemplate redisTemplate,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.redisTemplate = redisTemplate;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Trigger save(Trigger trigger) {
|
||||||
|
if (trigger.getId() == null) {
|
||||||
|
trigger.setId(UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
long ttlMillis = Math.max(
|
||||||
|
TRIGGER_TTL.toMillis(),
|
||||||
|
Math.max(0L, trigger.getTriggerAt() - System.currentTimeMillis())
|
||||||
|
+ TRIGGER_TTL.toMillis());
|
||||||
|
List<String> keys = new ArrayList<>();
|
||||||
|
keys.add(dataKey(trigger.getId()));
|
||||||
|
keys.add(CacheKey.TRIGGER_PENDING_KEY);
|
||||||
|
Long saved;
|
||||||
|
long requiredLockFencingToken = trigger.getRequiredLockFencingToken();
|
||||||
|
long requiredClaimGeneration = trigger.getRequiredFencingToken();
|
||||||
|
if (requiredLockFencingToken > 0L && requiredClaimGeneration > 0L) {
|
||||||
|
String requiredClaimId = requireText(
|
||||||
|
trigger.getRequiredFencingClaimId(),
|
||||||
|
"required fencing claim ID");
|
||||||
|
keys.add(lockFenceKey(trigger.getStateInstanceId()));
|
||||||
|
keys.add(executionGuardKey(trigger.getStateInstanceId(), requiredClaimId));
|
||||||
|
saved = redisTemplate.execute(
|
||||||
|
DOUBLE_GUARDED_SAVE_SCRIPT,
|
||||||
|
keys,
|
||||||
|
String.valueOf(ttlMillis),
|
||||||
|
serialize(trigger),
|
||||||
|
String.valueOf(trigger.getTriggerAt()),
|
||||||
|
trigger.getId(),
|
||||||
|
String.valueOf(requiredLockFencingToken),
|
||||||
|
String.valueOf(requiredClaimGeneration));
|
||||||
|
} else if (requiredLockFencingToken > 0L) {
|
||||||
|
keys.add(lockFenceKey(trigger.getStateInstanceId()));
|
||||||
|
saved = redisTemplate.execute(
|
||||||
|
LOCK_GUARDED_SAVE_SCRIPT,
|
||||||
|
keys,
|
||||||
|
String.valueOf(ttlMillis),
|
||||||
|
serialize(trigger),
|
||||||
|
String.valueOf(trigger.getTriggerAt()),
|
||||||
|
trigger.getId(),
|
||||||
|
String.valueOf(requiredLockFencingToken));
|
||||||
|
} else if (requiredClaimGeneration > 0L) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"required lock fencing token is required with claim generation");
|
||||||
|
} else {
|
||||||
|
saved = redisTemplate.execute(
|
||||||
|
SAVE_SCRIPT,
|
||||||
|
keys,
|
||||||
|
String.valueOf(ttlMillis),
|
||||||
|
serialize(trigger),
|
||||||
|
String.valueOf(trigger.getTriggerAt()),
|
||||||
|
trigger.getId());
|
||||||
|
}
|
||||||
|
if (!Long.valueOf(1L).equals(saved)) {
|
||||||
|
throw new TriggerClaimLostException(trigger.getId());
|
||||||
|
}
|
||||||
|
return trigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean saveIfAbsent(Trigger trigger) {
|
||||||
|
String triggerId =
|
||||||
|
requireText(trigger.getId(),
|
||||||
|
"stable trigger ID");
|
||||||
|
long ttlMillis = Math.max(
|
||||||
|
TRIGGER_TTL.toMillis(),
|
||||||
|
Math.max(0L,
|
||||||
|
trigger.getTriggerAt()
|
||||||
|
- System.currentTimeMillis())
|
||||||
|
+ TRIGGER_TTL.toMillis());
|
||||||
|
List<String> keys = new ArrayList<>();
|
||||||
|
keys.add(dataKey(triggerId));
|
||||||
|
keys.add(CacheKey.TRIGGER_PENDING_KEY);
|
||||||
|
Long saved;
|
||||||
|
long requiredLockFencingToken =
|
||||||
|
trigger.getRequiredLockFencingToken();
|
||||||
|
long requiredClaimGeneration =
|
||||||
|
trigger.getRequiredFencingToken();
|
||||||
|
if (requiredLockFencingToken > 0L
|
||||||
|
&& requiredClaimGeneration > 0L) {
|
||||||
|
String requiredClaimId = requireText(
|
||||||
|
trigger.getRequiredFencingClaimId(),
|
||||||
|
"required fencing claim ID");
|
||||||
|
keys.add(lockFenceKey(
|
||||||
|
trigger.getStateInstanceId()));
|
||||||
|
keys.add(executionGuardKey(
|
||||||
|
trigger.getStateInstanceId(),
|
||||||
|
requiredClaimId));
|
||||||
|
saved = redisTemplate.execute(
|
||||||
|
DOUBLE_GUARDED_SAVE_IF_ABSENT_SCRIPT,
|
||||||
|
keys,
|
||||||
|
String.valueOf(ttlMillis),
|
||||||
|
serialize(trigger),
|
||||||
|
String.valueOf(trigger.getTriggerAt()),
|
||||||
|
triggerId,
|
||||||
|
String.valueOf(requiredLockFencingToken),
|
||||||
|
String.valueOf(requiredClaimGeneration));
|
||||||
|
} else if (requiredLockFencingToken > 0L) {
|
||||||
|
keys.add(lockFenceKey(
|
||||||
|
trigger.getStateInstanceId()));
|
||||||
|
saved = redisTemplate.execute(
|
||||||
|
LOCK_GUARDED_SAVE_IF_ABSENT_SCRIPT,
|
||||||
|
keys,
|
||||||
|
String.valueOf(ttlMillis),
|
||||||
|
serialize(trigger),
|
||||||
|
String.valueOf(trigger.getTriggerAt()),
|
||||||
|
triggerId,
|
||||||
|
String.valueOf(requiredLockFencingToken));
|
||||||
|
} else if (requiredClaimGeneration > 0L) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"required lock fencing token is required with claim generation");
|
||||||
|
} else {
|
||||||
|
saved = redisTemplate.execute(
|
||||||
|
SAVE_IF_ABSENT_SCRIPT,
|
||||||
|
keys,
|
||||||
|
String.valueOf(ttlMillis),
|
||||||
|
serialize(trigger),
|
||||||
|
String.valueOf(trigger.getTriggerAt()),
|
||||||
|
triggerId);
|
||||||
|
}
|
||||||
|
if (Long.valueOf(1L).equals(saved)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (Long.valueOf(0L).equals(saved)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Long.valueOf(-1L).equals(saved)) {
|
||||||
|
throw new TriggerClaimLostException(triggerId);
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Trigger create returned no result: "
|
||||||
|
+ triggerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean remove(String triggerId) {
|
||||||
|
List<String> guardKeys = removeLocalClaims(triggerId);
|
||||||
|
if (!guardKeys.isEmpty()) {
|
||||||
|
redisTemplate.delete(guardKeys);
|
||||||
|
}
|
||||||
|
Long removed = redisTemplate.execute(
|
||||||
|
REMOVE_SCRIPT,
|
||||||
|
java.util.Arrays.asList(
|
||||||
|
dataKey(triggerId), claimKey(triggerId), CacheKey.TRIGGER_PENDING_KEY),
|
||||||
|
triggerId);
|
||||||
|
return Long.valueOf(1L).equals(removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Trigger find(String triggerId) {
|
||||||
|
return deserialize(redisTemplate.opsForValue().get(dataKey(triggerId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<Trigger> findDue(long uptoTimestamp) {
|
||||||
|
return findByScore(0L, uptoTimestamp, DUE_BATCH_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<Trigger> findAllPending() {
|
||||||
|
return findByScore(0L, Long.MAX_VALUE, RECOVERY_BATCH_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Trigger claim(String triggerId, long leaseMillis) {
|
||||||
|
return claim(find(triggerId), leaseMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Trigger claim(Trigger candidate, long leaseMillis) {
|
||||||
|
if (candidate == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String triggerId = requireText(candidate.getId(), "trigger ID");
|
||||||
|
String instanceId = requireText(candidate.getStateInstanceId(), "state instance ID");
|
||||||
|
String claimToken = UUID.randomUUID().toString();
|
||||||
|
long lease = Math.max(1L, leaseMillis);
|
||||||
|
String guardKey = executionGuardKey(instanceId, triggerId);
|
||||||
|
String payload = redisTemplate.execute(
|
||||||
|
CLAIM_SCRIPT,
|
||||||
|
Arrays.asList(
|
||||||
|
dataKey(triggerId),
|
||||||
|
claimKey(triggerId),
|
||||||
|
CacheKey.TRIGGER_PENDING_KEY,
|
||||||
|
guardKey,
|
||||||
|
claimGenerationKey(instanceId)),
|
||||||
|
claimToken,
|
||||||
|
String.valueOf(lease),
|
||||||
|
triggerId,
|
||||||
|
String.valueOf(System.currentTimeMillis() + lease),
|
||||||
|
String.valueOf(CLAIM_GENERATION_TTL.toMillis()));
|
||||||
|
int separator = payload == null ? -1 : payload.indexOf('\n');
|
||||||
|
if (payload != null && separator <= 0) {
|
||||||
|
redisTemplate.delete(guardKey);
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Claim result is missing generation: " + triggerId);
|
||||||
|
}
|
||||||
|
long claimGeneration = payload == null
|
||||||
|
? 0L
|
||||||
|
: Long.parseLong(payload.substring(0, separator));
|
||||||
|
Trigger trigger = deserialize(
|
||||||
|
payload == null ? null : payload.substring(separator + 1));
|
||||||
|
if (trigger != null) {
|
||||||
|
if (!triggerId.equals(trigger.getId())
|
||||||
|
|| !instanceId.equals(trigger.getStateInstanceId())) {
|
||||||
|
redisTemplate.delete(guardKey);
|
||||||
|
throw new IllegalStateException("Claimed trigger identity changed: " + triggerId);
|
||||||
|
}
|
||||||
|
trigger.setFencingToken(claimGeneration);
|
||||||
|
claimContexts.put(
|
||||||
|
trigger,
|
||||||
|
new ClaimContext(claimToken, guardKey, claimGeneration));
|
||||||
|
}
|
||||||
|
return trigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean renewClaim(Trigger trigger, long leaseMillis) {
|
||||||
|
ClaimContext claim = claimContexts.get(trigger);
|
||||||
|
if (claim == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
long lease = Math.max(1L, leaseMillis);
|
||||||
|
Long renewed = redisTemplate.execute(
|
||||||
|
RENEW_SCRIPT,
|
||||||
|
Arrays.asList(
|
||||||
|
claimKey(trigger.getId()),
|
||||||
|
CacheKey.TRIGGER_PENDING_KEY,
|
||||||
|
claim.guardKey),
|
||||||
|
claim.ownerToken,
|
||||||
|
String.valueOf(lease),
|
||||||
|
trigger.getId(),
|
||||||
|
String.valueOf(System.currentTimeMillis() + lease),
|
||||||
|
String.valueOf(claim.claimGeneration));
|
||||||
|
return Long.valueOf(1L).equals(renewed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void acknowledge(Trigger trigger) {
|
||||||
|
ClaimContext claim = claimContexts.get(trigger);
|
||||||
|
if (claim == null) {
|
||||||
|
throw new TriggerClaimLostException(
|
||||||
|
trigger.getId());
|
||||||
|
}
|
||||||
|
Long acknowledged = redisTemplate.execute(
|
||||||
|
ACK_SCRIPT,
|
||||||
|
Arrays.asList(
|
||||||
|
claimKey(trigger.getId()),
|
||||||
|
dataKey(trigger.getId()),
|
||||||
|
CacheKey.TRIGGER_PENDING_KEY,
|
||||||
|
claim.guardKey),
|
||||||
|
claim.ownerToken,
|
||||||
|
trigger.getId());
|
||||||
|
finishClaimMutation(
|
||||||
|
trigger, claim, acknowledged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void release(Trigger trigger) {
|
||||||
|
ClaimContext claim = claimContexts.get(trigger);
|
||||||
|
if (claim == null) {
|
||||||
|
throw new TriggerClaimLostException(
|
||||||
|
trigger.getId());
|
||||||
|
}
|
||||||
|
Long released = redisTemplate.execute(
|
||||||
|
RELEASE_SCRIPT,
|
||||||
|
Arrays.asList(
|
||||||
|
claimKey(trigger.getId()),
|
||||||
|
CacheKey.TRIGGER_PENDING_KEY,
|
||||||
|
dataKey(trigger.getId()),
|
||||||
|
claim.guardKey),
|
||||||
|
claim.ownerToken,
|
||||||
|
trigger.getId(),
|
||||||
|
String.valueOf(trigger.getTriggerAt()),
|
||||||
|
String.valueOf(TRIGGER_TTL.toMillis()),
|
||||||
|
serialize(trigger));
|
||||||
|
finishClaimMutation(
|
||||||
|
trigger, claim, released);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void markDeadLetterPending(
|
||||||
|
Trigger trigger) {
|
||||||
|
ClaimContext claim =
|
||||||
|
claimContexts.get(trigger);
|
||||||
|
if (claim == null) {
|
||||||
|
throw new TriggerClaimLostException(
|
||||||
|
trigger.getId());
|
||||||
|
}
|
||||||
|
Long marked = redisTemplate.execute(
|
||||||
|
MARK_DEAD_LETTER_PENDING_SCRIPT,
|
||||||
|
Arrays.asList(
|
||||||
|
claimKey(trigger.getId()),
|
||||||
|
dataKey(trigger.getId())),
|
||||||
|
claim.ownerToken,
|
||||||
|
String.valueOf(
|
||||||
|
TRIGGER_TTL.toMillis()),
|
||||||
|
serialize(trigger));
|
||||||
|
if (Long.valueOf(1L).equals(marked)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Long.valueOf(0L).equals(marked)) {
|
||||||
|
claimContexts.remove(trigger, claim);
|
||||||
|
throw new TriggerClaimLostException(
|
||||||
|
trigger.getId());
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Dead-letter marker returned no result: "
|
||||||
|
+ trigger.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void deadLetter(Trigger trigger, String reason) {
|
||||||
|
ClaimContext claim = claimContexts.get(trigger);
|
||||||
|
if (claim == null) {
|
||||||
|
throw new TriggerClaimLostException(
|
||||||
|
trigger.getId());
|
||||||
|
}
|
||||||
|
Long moved = redisTemplate.execute(
|
||||||
|
DEAD_LETTER_SCRIPT,
|
||||||
|
Arrays.asList(
|
||||||
|
claimKey(trigger.getId()),
|
||||||
|
dataKey(trigger.getId()),
|
||||||
|
CacheKey.TRIGGER_PENDING_KEY,
|
||||||
|
CacheKey.TRIGGER_DEAD_LETTER_KEY + trigger.getId(),
|
||||||
|
claim.guardKey),
|
||||||
|
claim.ownerToken,
|
||||||
|
trigger.getId(),
|
||||||
|
String.valueOf(TRIGGER_TTL.toMillis()),
|
||||||
|
serialize(trigger));
|
||||||
|
if (Long.valueOf(1L).equals(moved)) {
|
||||||
|
claimContexts.remove(trigger, claim);
|
||||||
|
log.error(
|
||||||
|
"Workflow trigger moved to dead letter, triggerId={}, reason={}",
|
||||||
|
trigger.getId(),
|
||||||
|
reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Long.valueOf(0L).equals(moved)) {
|
||||||
|
claimContexts.remove(trigger, claim);
|
||||||
|
throw new TriggerClaimLostException(
|
||||||
|
trigger.getId());
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Dead-letter operation returned no result: "
|
||||||
|
+ trigger.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 claim 变更结果,并在 Redis 已完成或确认失去 owner 后清理本地凭证。
|
||||||
|
*
|
||||||
|
* @param trigger 已认领触发器
|
||||||
|
* @param claim 本地认领上下文
|
||||||
|
* @param result Redis 原子脚本结果
|
||||||
|
*/
|
||||||
|
private void finishClaimMutation(
|
||||||
|
Trigger trigger,
|
||||||
|
ClaimContext claim,
|
||||||
|
Long result) {
|
||||||
|
if (Long.valueOf(1L).equals(result)) {
|
||||||
|
claimContexts.remove(trigger, claim);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Long.valueOf(0L).equals(result)) {
|
||||||
|
claimContexts.remove(trigger, claim);
|
||||||
|
throw new TriggerClaimLostException(
|
||||||
|
trigger.getId());
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Trigger claim mutation returned no result: "
|
||||||
|
+ trigger.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理指定触发器 ID 的所有进程内认领凭证。
|
||||||
|
*
|
||||||
|
* @param triggerId 触发器 ID
|
||||||
|
*/
|
||||||
|
private List<String> removeLocalClaims(String triggerId) {
|
||||||
|
List<String> guardKeys = new ArrayList<>();
|
||||||
|
synchronized (claimContexts) {
|
||||||
|
claimContexts.entrySet().removeIf(entry -> {
|
||||||
|
if (!triggerId.equals(entry.getKey().getId())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
guardKeys.add(entry.getValue().guardKey);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return guardKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按触发时间范围批量加载触发器。
|
||||||
|
*
|
||||||
|
* @param minScore 最小触发时间
|
||||||
|
* @param maxScore 最大触发时间
|
||||||
|
* @param limit 最大返回数
|
||||||
|
* @return 保持触发时间顺序的触发器列表
|
||||||
|
*/
|
||||||
|
private List<Trigger> findByScore(long minScore, long maxScore, int limit) {
|
||||||
|
java.util.Set<String> triggerIds = redisTemplate.opsForZSet().rangeByScore(
|
||||||
|
CacheKey.TRIGGER_PENDING_KEY, minScore, maxScore, 0, limit);
|
||||||
|
if (triggerIds == null || triggerIds.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<String> ids = new ArrayList<>(triggerIds);
|
||||||
|
List<String> keys = new ArrayList<>(ids.size());
|
||||||
|
for (String triggerId : ids) {
|
||||||
|
keys.add(dataKey(triggerId));
|
||||||
|
}
|
||||||
|
List<String> payloads = redisTemplate.opsForValue().multiGet(keys);
|
||||||
|
List<Trigger> triggers = new ArrayList<>(ids.size());
|
||||||
|
if (payloads == null) {
|
||||||
|
return triggers;
|
||||||
|
}
|
||||||
|
for (int index = 0; index < payloads.size(); index++) {
|
||||||
|
String payload = payloads.get(index);
|
||||||
|
Trigger trigger;
|
||||||
|
try {
|
||||||
|
trigger = deserialize(payload);
|
||||||
|
} catch (IllegalStateException error) {
|
||||||
|
quarantine(ids.get(index), payload, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trigger != null) {
|
||||||
|
triggers.add(trigger);
|
||||||
|
} else {
|
||||||
|
redisTemplate.opsForZSet().remove(CacheKey.TRIGGER_PENDING_KEY, ids.get(index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return triggers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 隔离无法反序列化的触发器,避免毒数据持续阻断批量扫描。
|
||||||
|
*
|
||||||
|
* @param triggerId 触发器 ID
|
||||||
|
* @param payload 原始负载
|
||||||
|
* @param error 解析异常
|
||||||
|
*/
|
||||||
|
private void quarantine(String triggerId, String payload, RuntimeException error) {
|
||||||
|
if (payload != null) {
|
||||||
|
redisTemplate.opsForValue().set(
|
||||||
|
CacheKey.TRIGGER_DEAD_LETTER_KEY + triggerId,
|
||||||
|
payload,
|
||||||
|
TRIGGER_TTL);
|
||||||
|
}
|
||||||
|
remove(triggerId);
|
||||||
|
log.error("Quarantined invalid workflow trigger payload, triggerId={}", triggerId, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化触发器。
|
||||||
|
*
|
||||||
|
* @param trigger 触发器
|
||||||
|
* @return JSON 文本
|
||||||
|
*/
|
||||||
|
private String serialize(Trigger trigger) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(trigger);
|
||||||
|
} catch (JsonProcessingException error) {
|
||||||
|
throw new IllegalStateException("Failed to serialize workflow trigger: " + trigger.getId(), error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反序列化触发器。
|
||||||
|
*
|
||||||
|
* @param payload JSON 文本
|
||||||
|
* @return 触发器;输入为空时返回 null
|
||||||
|
*/
|
||||||
|
private Trigger deserialize(String payload) {
|
||||||
|
if (payload == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(payload, Trigger.class);
|
||||||
|
} catch (JsonProcessingException error) {
|
||||||
|
throw new IllegalStateException("Failed to deserialize workflow trigger", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建触发器数据键。
|
||||||
|
*
|
||||||
|
* @param triggerId 触发器 ID
|
||||||
|
* @return Redis 键
|
||||||
|
*/
|
||||||
|
private String dataKey(String triggerId) {
|
||||||
|
return CacheKey.TRIGGER_DATA_KEY + triggerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建触发器认领键。
|
||||||
|
*
|
||||||
|
* @param triggerId 触发器 ID
|
||||||
|
* @return Redis 键
|
||||||
|
*/
|
||||||
|
private String claimKey(String triggerId) {
|
||||||
|
return CacheKey.TRIGGER_CLAIM_KEY + triggerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建触发器认领代际分配计数器键。
|
||||||
|
*
|
||||||
|
* @param instanceId 工作流实例 ID
|
||||||
|
* @return 认领代际计数器键
|
||||||
|
*/
|
||||||
|
private String claimGenerationKey(String instanceId) {
|
||||||
|
return CacheKey.CHAIN_STATE_CAS_KEY + "{" + instanceId + "}:claim-seq";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建实例锁 fencing token 键。
|
||||||
|
*
|
||||||
|
* @param instanceId 工作流实例 ID
|
||||||
|
* @return fencing token 键
|
||||||
|
*/
|
||||||
|
private String lockFenceKey(String instanceId) {
|
||||||
|
return CacheKey.CHAIN_STATE_CAS_KEY
|
||||||
|
+ "{"
|
||||||
|
+ requireText(instanceId, "state instance ID")
|
||||||
|
+ "}:fence";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建与一次触发器认领绑定的执行守卫键。
|
||||||
|
*
|
||||||
|
* @param instanceId 工作流实例 ID
|
||||||
|
* @param claimId 触发器 ID
|
||||||
|
* @return 执行守卫键
|
||||||
|
*/
|
||||||
|
private String executionGuardKey(String instanceId, String claimId) {
|
||||||
|
return CacheKey.CHAIN_STATE_CAS_KEY
|
||||||
|
+ "{"
|
||||||
|
+ requireText(instanceId, "state instance ID")
|
||||||
|
+ "}:claim:"
|
||||||
|
+ requireText(claimId, "claim ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验必填文本。
|
||||||
|
*
|
||||||
|
* @param value 原始值
|
||||||
|
* @param name 字段名称
|
||||||
|
* @return 去除首尾空白后的值
|
||||||
|
*/
|
||||||
|
private String requireText(String value, String name) {
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(name + " must not be blank");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前进程持有的一次触发器认领上下文。
|
||||||
|
*/
|
||||||
|
private static final class ClaimContext {
|
||||||
|
|
||||||
|
private final String ownerToken;
|
||||||
|
private final String guardKey;
|
||||||
|
private final long claimGeneration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建认领上下文。
|
||||||
|
*
|
||||||
|
* @param ownerToken Redis claim owner token
|
||||||
|
* @param guardKey 执行守卫键
|
||||||
|
* @param claimGeneration 本次认领的单调代际
|
||||||
|
*/
|
||||||
|
private ClaimContext(
|
||||||
|
String ownerToken, String guardKey, long claimGeneration) {
|
||||||
|
this.ownerToken = ownerToken;
|
||||||
|
this.guardKey = guardKey;
|
||||||
|
this.claimGeneration = claimGeneration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Long 返回值的 Redis 脚本。
|
||||||
|
*
|
||||||
|
* @param scriptText Lua 脚本文本
|
||||||
|
* @return Redis 脚本
|
||||||
|
*/
|
||||||
|
private static DefaultRedisScript<Long> longScript(String scriptText) {
|
||||||
|
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
|
||||||
|
script.setScriptText(scriptText);
|
||||||
|
script.setResultType(Long.class);
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建字符串返回值的 Redis 脚本。
|
||||||
|
*
|
||||||
|
* @param scriptText Lua 脚本文本
|
||||||
|
* @return Redis 脚本
|
||||||
|
*/
|
||||||
|
private static DefaultRedisScript<String> stringScript(String scriptText) {
|
||||||
|
DefaultRedisScript<String> script = new DefaultRedisScript<>();
|
||||||
|
script.setScriptText(scriptText);
|
||||||
|
script.setResultType(String.class);
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.repository;
|
||||||
|
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||||
|
import tech.easyflow.common.constant.CacheKey;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于 Redis 的工作流定义缓存版本令牌仓储。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class RedisWorkflowDefinitionVersionStore implements WorkflowDefinitionVersionStore {
|
||||||
|
|
||||||
|
private static final Duration TOKEN_TTL = Duration.ofDays(7);
|
||||||
|
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建版本令牌仓储。
|
||||||
|
*
|
||||||
|
* @param redisTemplate Redis 字符串模板
|
||||||
|
*/
|
||||||
|
public RedisWorkflowDefinitionVersionStore(StringRedisTemplate redisTemplate) {
|
||||||
|
this.redisTemplate = redisTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String currentToken(String definitionId) {
|
||||||
|
String key = versionKey(definitionId);
|
||||||
|
String current = redisTemplate.opsForValue().get(key);
|
||||||
|
if (current != null) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
String candidate = UUID.randomUUID().toString();
|
||||||
|
Boolean created = redisTemplate.opsForValue().setIfAbsent(key, candidate, TOKEN_TTL);
|
||||||
|
if (Boolean.TRUE.equals(created)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
current = redisTemplate.opsForValue().get(key);
|
||||||
|
if (current == null) {
|
||||||
|
throw new IllegalStateException("Workflow definition version token is unavailable: " + definitionId);
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void invalidateWorkflow(String workflowId) {
|
||||||
|
String token = UUID.randomUUID().toString();
|
||||||
|
redisTemplate.opsForValue().set(versionKey(workflowId), token, TOKEN_TTL);
|
||||||
|
redisTemplate.opsForValue().set(
|
||||||
|
versionKey(PublishedWorkflowDefinitionIds.published(workflowId)),
|
||||||
|
token,
|
||||||
|
TOKEN_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建版本令牌 Redis 键。
|
||||||
|
*
|
||||||
|
* @param definitionId 定义 ID
|
||||||
|
* @return Redis 键
|
||||||
|
*/
|
||||||
|
private String versionKey(String definitionId) {
|
||||||
|
return CacheKey.WORKFLOW_DEFINITION_VERSION_KEY + definitionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.repository;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.event.TransactionPhase;
|
||||||
|
import org.springframework.transaction.event.TransactionalEventListener;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.config.WorkflowRuntimeProperties;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.event.WorkflowDefinitionChangedEvent;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.ObjectOutputStream;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ConcurrentMap;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带跨实例版本校验和本地有界 LRU 的工作流编译定义缓存。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WorkflowDefinitionCache {
|
||||||
|
|
||||||
|
private static final int MAX_COMPILE_RETRIES = 3;
|
||||||
|
|
||||||
|
private final WorkflowDefinitionVersionStore versionStore;
|
||||||
|
private final int maxEntries;
|
||||||
|
private final long maxBytes;
|
||||||
|
private final long expireAfterAccessNanos;
|
||||||
|
private final Map<String, CacheEntry> entries = new LinkedHashMap<>(16, 0.75F, true);
|
||||||
|
private final ConcurrentMap<String, Object> compileLocks = new ConcurrentHashMap<>();
|
||||||
|
private long currentBytes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建工作流定义缓存。
|
||||||
|
*
|
||||||
|
* @param versionStore 跨实例版本令牌仓储
|
||||||
|
* @param properties 工作流运行时配置
|
||||||
|
*/
|
||||||
|
public WorkflowDefinitionCache(
|
||||||
|
WorkflowDefinitionVersionStore versionStore, WorkflowRuntimeProperties properties) {
|
||||||
|
this.versionStore = versionStore;
|
||||||
|
this.maxEntries = Math.max(1, properties.getDefinitionCacheMaxEntries());
|
||||||
|
this.maxBytes = Math.max(
|
||||||
|
1L, properties.getDefinitionCacheMaxBytes());
|
||||||
|
Duration expireAfterAccess = properties.getDefinitionCacheExpireAfterAccess();
|
||||||
|
this.expireAfterAccessNanos = expireAfterAccess == null
|
||||||
|
? Duration.ofMinutes(30).toNanos()
|
||||||
|
: Math.max(1L, expireAfterAccess.toNanos());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已编译定义;缓存未命中时只允许一个线程执行加载与编译。
|
||||||
|
*
|
||||||
|
* @param definitionId 定义 ID
|
||||||
|
* @param loader 定义加载与编译函数
|
||||||
|
* @return 已编译工作流定义
|
||||||
|
*/
|
||||||
|
public ChainDefinition get(String definitionId, Supplier<ChainDefinition> loader) {
|
||||||
|
String token = versionStore.currentToken(definitionId);
|
||||||
|
ChainDefinition cached = getCached(definitionId, token);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object compileLock = compileLocks.computeIfAbsent(definitionId, ignored -> new Object());
|
||||||
|
try {
|
||||||
|
synchronized (compileLock) {
|
||||||
|
for (int attempt = 0; attempt < MAX_COMPILE_RETRIES; attempt++) {
|
||||||
|
token = versionStore.currentToken(definitionId);
|
||||||
|
cached = getCached(definitionId, token);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
ChainDefinition compiled = loader.get();
|
||||||
|
String tokenAfterCompile = versionStore.currentToken(definitionId);
|
||||||
|
if (token.equals(tokenAfterCompile)) {
|
||||||
|
put(definitionId, token, compiled);
|
||||||
|
return compiled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
compileLocks.remove(definitionId, compileLock);
|
||||||
|
}
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Workflow definition changed repeatedly while compiling: " + definitionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理定义变更并更新跨实例版本令牌。
|
||||||
|
*
|
||||||
|
* @param event 工作流定义变更事件
|
||||||
|
*/
|
||||||
|
@TransactionalEventListener(
|
||||||
|
phase = TransactionPhase.AFTER_COMMIT,
|
||||||
|
fallbackExecution = true)
|
||||||
|
public void onDefinitionChanged(WorkflowDefinitionChangedEvent event) {
|
||||||
|
if (event == null || event.workflowId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
versionStore.invalidateWorkflow(event.workflowId());
|
||||||
|
synchronized (entries) {
|
||||||
|
removeEntry(event.workflowId());
|
||||||
|
removeEntry(PublishedWorkflowDefinitionIds.published(
|
||||||
|
event.workflowId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取仍有效的本地缓存项。
|
||||||
|
*
|
||||||
|
* @param definitionId 定义 ID
|
||||||
|
* @param token 当前版本令牌
|
||||||
|
* @return 命中的定义;未命中时返回 null
|
||||||
|
*/
|
||||||
|
private ChainDefinition getCached(String definitionId, String token) {
|
||||||
|
long now = System.nanoTime();
|
||||||
|
synchronized (entries) {
|
||||||
|
CacheEntry entry = entries.get(definitionId);
|
||||||
|
if (entry == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!entry.token.equals(token) || now - entry.lastAccessNanos > expireAfterAccessNanos) {
|
||||||
|
removeEntry(definitionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
entry.lastAccessNanos = now;
|
||||||
|
return entry.definition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存本地缓存并按 LRU 淘汰。
|
||||||
|
*
|
||||||
|
* @param definitionId 定义 ID
|
||||||
|
* @param token 版本令牌
|
||||||
|
* @param definition 已编译定义
|
||||||
|
*/
|
||||||
|
private void put(String definitionId, String token, ChainDefinition definition) {
|
||||||
|
synchronized (entries) {
|
||||||
|
removeEntry(definitionId);
|
||||||
|
long weight = serializedSize(definition);
|
||||||
|
entries.put(definitionId, new CacheEntry(
|
||||||
|
token,
|
||||||
|
definition,
|
||||||
|
System.nanoTime(),
|
||||||
|
weight));
|
||||||
|
currentBytes += weight;
|
||||||
|
while (entries.size() > maxEntries
|
||||||
|
|| (currentBytes > maxBytes
|
||||||
|
&& entries.size() > 1)) {
|
||||||
|
String eldestKey = entries.keySet().iterator().next();
|
||||||
|
removeEntry(eldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除缓存项并同步维护重量。
|
||||||
|
*
|
||||||
|
* @param definitionId 定义 ID
|
||||||
|
*/
|
||||||
|
private void removeEntry(String definitionId) {
|
||||||
|
CacheEntry removed = entries.remove(definitionId);
|
||||||
|
if (removed != null) {
|
||||||
|
currentBytes = Math.max(
|
||||||
|
0L, currentBytes - removed.weightBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用实际 Java 序列化大小作为缓存重量。
|
||||||
|
*
|
||||||
|
* @param definition 编译定义
|
||||||
|
* @return 序列化字节数
|
||||||
|
*/
|
||||||
|
private long serializedSize(ChainDefinition definition) {
|
||||||
|
try (ByteArrayOutputStream output =
|
||||||
|
new ByteArrayOutputStream();
|
||||||
|
ObjectOutputStream objects =
|
||||||
|
new ObjectOutputStream(output)) {
|
||||||
|
objects.writeObject(definition);
|
||||||
|
objects.flush();
|
||||||
|
return Math.max(1L, output.size());
|
||||||
|
} catch (IOException error) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Failed to estimate workflow definition size",
|
||||||
|
error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地定义缓存项。
|
||||||
|
*/
|
||||||
|
private static final class CacheEntry {
|
||||||
|
|
||||||
|
private final String token;
|
||||||
|
private final ChainDefinition definition;
|
||||||
|
private final long weightBytes;
|
||||||
|
private long lastAccessNanos;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建本地缓存项。
|
||||||
|
*
|
||||||
|
* @param token 版本令牌
|
||||||
|
* @param definition 已编译定义
|
||||||
|
* @param lastAccessNanos 最近访问时间
|
||||||
|
*/
|
||||||
|
private CacheEntry(
|
||||||
|
String token,
|
||||||
|
ChainDefinition definition,
|
||||||
|
long lastAccessNanos,
|
||||||
|
long weightBytes) {
|
||||||
|
this.token = token;
|
||||||
|
this.definition = definition;
|
||||||
|
this.lastAccessNanos = lastAccessNanos;
|
||||||
|
this.weightBytes = weightBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流定义缓存版本令牌仓储。
|
||||||
|
*/
|
||||||
|
public interface WorkflowDefinitionVersionStore {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取定义当前版本令牌,不存在时原子创建。
|
||||||
|
*
|
||||||
|
* @param definitionId 定义 ID,包含可选发布态前缀
|
||||||
|
* @return 当前版本令牌
|
||||||
|
*/
|
||||||
|
String currentToken(String definitionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同时使指定工作流的草稿和发布态版本令牌失效。
|
||||||
|
*
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
*/
|
||||||
|
void invalidateWorkflow(String workflowId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.repository;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.ChainState;
|
||||||
|
import com.easyagents.flow.core.chain.NodeState;
|
||||||
|
import com.easyagents.flow.core.chain.repository.ChainStateField;
|
||||||
|
import com.easyagents.flow.core.chain.repository.NodeStateField;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.support.WorkflowExecutionStepKey;
|
||||||
|
import tech.easyflow.common.cache.VersionedFields;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流状态对象与 Redis 字段之间的无反射映射。
|
||||||
|
*/
|
||||||
|
final class WorkflowStateFields {
|
||||||
|
|
||||||
|
static final String FORMAT_FIELD = "_format";
|
||||||
|
static final String FORMAT_VERSION = "2";
|
||||||
|
private static final String NODE_ID_FIELD = "_nodeId";
|
||||||
|
private static final String CHAIN_INSTANCE_ID_FIELD = "_chainInstanceId";
|
||||||
|
|
||||||
|
private WorkflowStateFields() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断快照是否采用字段化格式。
|
||||||
|
*
|
||||||
|
* @param snapshot Redis 快照
|
||||||
|
* @return 字段化格式时为 {@code true}
|
||||||
|
*/
|
||||||
|
static boolean isFieldFormat(VersionedFields snapshot) {
|
||||||
|
return snapshot != null
|
||||||
|
&& FORMAT_VERSION.equals(snapshot.getFields().get(FORMAT_FIELD));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将完整工作流状态编码为字段。
|
||||||
|
*
|
||||||
|
* @param state 工作流状态
|
||||||
|
* @return 完整字段映射
|
||||||
|
*/
|
||||||
|
static Map<String, Serializable> allChainFields(ChainState state) {
|
||||||
|
EnumSet<ChainStateField> fields = EnumSet.allOf(ChainStateField.class);
|
||||||
|
fields.remove(ChainStateField.VERSION);
|
||||||
|
return chainFields(state, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将变化的工作流状态字段编码为可独立提交的值。
|
||||||
|
*
|
||||||
|
* @param state 工作流状态
|
||||||
|
* @param fields 变化字段
|
||||||
|
* @return 字段映射
|
||||||
|
*/
|
||||||
|
static Map<String, Serializable> chainFields(
|
||||||
|
ChainState state, EnumSet<ChainStateField> fields) {
|
||||||
|
Map<String, Serializable> values = new LinkedHashMap<>();
|
||||||
|
values.put(FORMAT_FIELD, FORMAT_VERSION);
|
||||||
|
for (ChainStateField field : fields) {
|
||||||
|
Serializable value = switch (field) {
|
||||||
|
case INSTANCE_ID -> state.getInstanceId();
|
||||||
|
case STATUS -> state.getStatus();
|
||||||
|
case MESSAGE -> state.getMessage();
|
||||||
|
case ERROR -> state.getError();
|
||||||
|
case MEMORY -> state.getMemory();
|
||||||
|
case COMPUTE_COST -> state.getComputeCost();
|
||||||
|
case SUSPEND_NODE_IDS -> serializable(state.getSuspendNodeIds());
|
||||||
|
case SUSPEND_FOR_PARAMETERS -> serializable(state.getSuspendForParameters());
|
||||||
|
case EXECUTE_RESULT -> serializable(state.getExecuteResult());
|
||||||
|
case CHAIN_DEFINITION_ID -> state.getChainDefinitionId();
|
||||||
|
case ENVIRONMENT -> serializable(state.getEnvironment());
|
||||||
|
case PARENT_INSTANCE_ID -> state.getParentInstanceId();
|
||||||
|
case AUDIT_INSTANCE_ID -> state.getAuditInstanceId();
|
||||||
|
case TRIGGER_NODE_IDS -> serializable(state.getTriggerNodeIds());
|
||||||
|
case TRIGGER_EDGE_IDS -> serializable(state.getTriggerEdgeIds());
|
||||||
|
case UNCHECKED_EDGE_IDS -> serializable(state.getUncheckedEdgeIds());
|
||||||
|
case UNCHECKED_NODE_IDS -> serializable(state.getUncheckedNodeIds());
|
||||||
|
case STARTED_AT -> state.getStartedAt();
|
||||||
|
case CHILD_EXECUTION_COUNT -> state.getChildExecutionCount();
|
||||||
|
case VERSION, PAYLOAD, NODE_STATES, CHILD_STATE_IDS -> null;
|
||||||
|
};
|
||||||
|
if (field != ChainStateField.VERSION
|
||||||
|
&& field != ChainStateField.PAYLOAD
|
||||||
|
&& field != ChainStateField.NODE_STATES
|
||||||
|
&& field != ChainStateField.CHILD_STATE_IDS) {
|
||||||
|
values.put(field.name(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从字段快照还原工作流状态。
|
||||||
|
*
|
||||||
|
* @param snapshot Redis 字段快照
|
||||||
|
* @return 工作流状态
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
static ChainState decodeChain(VersionedFields snapshot) {
|
||||||
|
Map<String, Object> fields = snapshot.getFields();
|
||||||
|
ChainState state = new ChainState();
|
||||||
|
state.setInstanceId((String) fields.get(ChainStateField.INSTANCE_ID.name()));
|
||||||
|
state.setStatus((com.easyagents.flow.core.chain.ChainStatus)
|
||||||
|
fields.get(ChainStateField.STATUS.name()));
|
||||||
|
state.setMessage((String) fields.get(ChainStateField.MESSAGE.name()));
|
||||||
|
state.setError((com.easyagents.flow.core.chain.ExceptionSummary)
|
||||||
|
fields.get(ChainStateField.ERROR.name()));
|
||||||
|
Object memory = fields.get(ChainStateField.MEMORY.name());
|
||||||
|
state.setMemory(memory == null
|
||||||
|
? new ConcurrentHashMap<>()
|
||||||
|
: new ConcurrentHashMap<>((Map<String, Object>) memory));
|
||||||
|
state.setComputeCost(number(fields.get(ChainStateField.COMPUTE_COST.name())));
|
||||||
|
state.setSuspendNodeIds((java.util.Set<String>)
|
||||||
|
fields.get(ChainStateField.SUSPEND_NODE_IDS.name()));
|
||||||
|
state.setSuspendForParameters((java.util.List<com.easyagents.flow.core.chain.Parameter>)
|
||||||
|
fields.get(ChainStateField.SUSPEND_FOR_PARAMETERS.name()));
|
||||||
|
state.setExecuteResult((Map<String, Object>)
|
||||||
|
fields.get(ChainStateField.EXECUTE_RESULT.name()));
|
||||||
|
state.setChainDefinitionId((String)
|
||||||
|
fields.get(ChainStateField.CHAIN_DEFINITION_ID.name()));
|
||||||
|
state.setEnvironment((Map<String, Object>)
|
||||||
|
fields.get(ChainStateField.ENVIRONMENT.name()));
|
||||||
|
state.setParentInstanceId((String)
|
||||||
|
fields.get(ChainStateField.PARENT_INSTANCE_ID.name()));
|
||||||
|
state.setAuditInstanceId((String)
|
||||||
|
fields.get(ChainStateField.AUDIT_INSTANCE_ID.name()));
|
||||||
|
state.setTriggerNodeIds((java.util.List<String>)
|
||||||
|
fields.get(ChainStateField.TRIGGER_NODE_IDS.name()));
|
||||||
|
state.setTriggerEdgeIds((java.util.List<String>)
|
||||||
|
fields.get(ChainStateField.TRIGGER_EDGE_IDS.name()));
|
||||||
|
state.setUncheckedEdgeIds((java.util.List<String>)
|
||||||
|
fields.get(ChainStateField.UNCHECKED_EDGE_IDS.name()));
|
||||||
|
state.setUncheckedNodeIds((java.util.List<String>)
|
||||||
|
fields.get(ChainStateField.UNCHECKED_NODE_IDS.name()));
|
||||||
|
state.setStartedAt(number(fields.get(ChainStateField.STARTED_AT.name())));
|
||||||
|
state.setChildExecutionCount(number(
|
||||||
|
fields.get(ChainStateField.CHILD_EXECUTION_COUNT.name())));
|
||||||
|
state.setVersion(snapshot.getVersion());
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将完整节点状态编码为字段。
|
||||||
|
*
|
||||||
|
* @param state 节点状态
|
||||||
|
* @return 完整字段映射
|
||||||
|
*/
|
||||||
|
static Map<String, Serializable> allNodeFields(NodeState state) {
|
||||||
|
EnumSet<NodeStateField> fields = EnumSet.allOf(NodeStateField.class);
|
||||||
|
fields.remove(NodeStateField.VERSION);
|
||||||
|
return nodeFields(state, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将变化的节点状态字段编码为可独立提交的值。
|
||||||
|
*
|
||||||
|
* @param state 节点状态
|
||||||
|
* @param fields 变化字段
|
||||||
|
* @return 字段映射
|
||||||
|
*/
|
||||||
|
static Map<String, Serializable> nodeFields(
|
||||||
|
NodeState state, EnumSet<NodeStateField> fields) {
|
||||||
|
Map<String, Serializable> values = new LinkedHashMap<>();
|
||||||
|
values.put(FORMAT_FIELD, FORMAT_VERSION);
|
||||||
|
values.put(NODE_ID_FIELD, state.getNodeId());
|
||||||
|
values.put(CHAIN_INSTANCE_ID_FIELD, state.getChainInstanceId());
|
||||||
|
for (NodeStateField field : fields) {
|
||||||
|
Serializable value = switch (field) {
|
||||||
|
case STATUS -> state.getStatus();
|
||||||
|
case ERROR -> state.getError();
|
||||||
|
case MEMORY -> state.getMemory();
|
||||||
|
case RETRY_COUNT -> state.getRetryCount();
|
||||||
|
case EXECUTE_COUNT -> state.getExecuteCount();
|
||||||
|
case EXECUTE_EDGE_IDS -> serializable(state.getExecuteEdgeIds());
|
||||||
|
case EXECUTION_ATTEMPT_KEY -> state.getExecutionAttemptKey();
|
||||||
|
case LOOP_COUNT -> state.getLoopCount();
|
||||||
|
case TRIGGER_COUNT -> state.getTriggerCount();
|
||||||
|
case TRIGGER_EDGE_IDS -> serializable(state.getTriggerEdgeIds());
|
||||||
|
case INSTANCE_ID, MESSAGE, PAYLOAD, NODE_STATES, COMPUTE_COST,
|
||||||
|
SUSPEND_NODE_IDS, SUSPEND_FOR_PARAMETERS, EXECUTE_RESULT,
|
||||||
|
ENVIRONMENT, VERSION -> null;
|
||||||
|
};
|
||||||
|
if (field == NodeStateField.STATUS
|
||||||
|
|| field == NodeStateField.ERROR
|
||||||
|
|| field == NodeStateField.MEMORY
|
||||||
|
|| field == NodeStateField.RETRY_COUNT
|
||||||
|
|| field == NodeStateField.EXECUTE_COUNT
|
||||||
|
|| field == NodeStateField.EXECUTE_EDGE_IDS
|
||||||
|
|| field == NodeStateField.EXECUTION_ATTEMPT_KEY
|
||||||
|
|| field == NodeStateField.LOOP_COUNT
|
||||||
|
|| field == NodeStateField.TRIGGER_COUNT
|
||||||
|
|| field == NodeStateField.TRIGGER_EDGE_IDS) {
|
||||||
|
values.put(field.name(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从字段快照还原节点状态。
|
||||||
|
*
|
||||||
|
* @param snapshot Redis 字段快照
|
||||||
|
* @return 节点状态
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
static NodeState decodeNode(VersionedFields snapshot) {
|
||||||
|
Map<String, Object> fields = snapshot.getFields();
|
||||||
|
NodeState state = new NodeState();
|
||||||
|
state.setNodeId((String) fields.get(NODE_ID_FIELD));
|
||||||
|
state.setChainInstanceId((String) fields.get(CHAIN_INSTANCE_ID_FIELD));
|
||||||
|
state.setStatus((com.easyagents.flow.core.chain.NodeStatus)
|
||||||
|
fields.get(NodeStateField.STATUS.name()));
|
||||||
|
state.setError((com.easyagents.flow.core.chain.ExceptionSummary)
|
||||||
|
fields.get(NodeStateField.ERROR.name()));
|
||||||
|
Object memory = fields.get(NodeStateField.MEMORY.name());
|
||||||
|
state.setMemory(memory == null
|
||||||
|
? new ConcurrentHashMap<>()
|
||||||
|
: new ConcurrentHashMap<>((Map<String, Object>) memory));
|
||||||
|
state.setRetryCount(integer(fields.get(NodeStateField.RETRY_COUNT.name())));
|
||||||
|
state.setExecuteCount(atomic(fields.get(NodeStateField.EXECUTE_COUNT.name())));
|
||||||
|
state.setExecuteEdgeIds(defaultList(
|
||||||
|
(java.util.List<String>) fields.get(NodeStateField.EXECUTE_EDGE_IDS.name())));
|
||||||
|
state.setExecutionAttemptKey(
|
||||||
|
(String) fields.get(
|
||||||
|
NodeStateField
|
||||||
|
.EXECUTION_ATTEMPT_KEY
|
||||||
|
.name()));
|
||||||
|
state.setLoopCount(integer(fields.get(NodeStateField.LOOP_COUNT.name())));
|
||||||
|
state.setTriggerCount(atomic(fields.get(NodeStateField.TRIGGER_COUNT.name())));
|
||||||
|
state.setTriggerEdgeIds(defaultList(
|
||||||
|
(java.util.List<String>) fields.get(NodeStateField.TRIGGER_EDGE_IDS.name())));
|
||||||
|
state.setVersion(snapshot.getVersion());
|
||||||
|
return normalizeNode(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 补齐升级前节点快照缺失的执行尝试键。
|
||||||
|
*
|
||||||
|
* @param state 字段化或旧对象节点状态
|
||||||
|
* @return 原节点状态
|
||||||
|
*/
|
||||||
|
static NodeState normalizeNode(NodeState state) {
|
||||||
|
if (state == null
|
||||||
|
|| (state.getExecutionAttemptKey() != null
|
||||||
|
&& !state.getExecutionAttemptKey().isBlank())
|
||||||
|
|| state.getMemory() == null) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
Object legacyExecKey =
|
||||||
|
state.getMemory().get(
|
||||||
|
"executeId");
|
||||||
|
if (legacyExecKey instanceof String) {
|
||||||
|
state.setExecutionAttemptKey(
|
||||||
|
WorkflowExecutionStepKey
|
||||||
|
.encodeLegacy(
|
||||||
|
(String) legacyExecKey));
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Serializable serializable(Object value) {
|
||||||
|
return value == null ? null : (Serializable) value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long number(Object value) {
|
||||||
|
return value instanceof Number ? ((Number) value).longValue() : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int integer(Object value) {
|
||||||
|
return value instanceof Number ? ((Number) value).intValue() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AtomicInteger atomic(Object value) {
|
||||||
|
if (value instanceof AtomicInteger) {
|
||||||
|
return new AtomicInteger(((AtomicInteger) value).get());
|
||||||
|
}
|
||||||
|
return new AtomicInteger(integer(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static java.util.List<String> defaultList(java.util.List<String> value) {
|
||||||
|
return value == null ? new java.util.ArrayList<>() : new java.util.ArrayList<>(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package tech.easyflow.ai.easyagentsflow.service;
|
|||||||
import com.easyagents.flow.core.chain.ChainState;
|
import com.easyagents.flow.core.chain.ChainState;
|
||||||
import com.easyagents.flow.core.chain.ExceptionSummary;
|
import com.easyagents.flow.core.chain.ExceptionSummary;
|
||||||
import com.easyagents.flow.core.chain.NodeState;
|
import com.easyagents.flow.core.chain.NodeState;
|
||||||
|
import com.easyagents.flow.core.chain.NodeStatus;
|
||||||
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
||||||
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
|
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
@@ -14,14 +15,24 @@ import javax.annotation.Resource;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为工作流设计器提供执行状态查询与结果解析能力。
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class TinyFlowService {
|
public class TinyFlowService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流执行器及其状态仓储入口。
|
||||||
|
*/
|
||||||
@Resource
|
@Resource
|
||||||
private ChainExecutor chainExecutor;
|
private ChainExecutor chainExecutor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取执行状态
|
* 获取工作流及其节点的执行状态。
|
||||||
|
*
|
||||||
|
* @param executeId 工作流执行实例 ID
|
||||||
|
* @param nodes 设计器中的节点列表
|
||||||
|
* @return 工作流执行状态
|
||||||
*/
|
*/
|
||||||
public ChainInfo getChainStatus(String executeId, List<NodeInfo> nodes) {
|
public ChainInfo getChainStatus(String executeId, List<NodeInfo> nodes) {
|
||||||
|
|
||||||
@@ -33,7 +44,7 @@ public class TinyFlowService {
|
|||||||
|
|
||||||
if (nodes != null) {
|
if (nodes != null) {
|
||||||
for (NodeInfo node : nodes) {
|
for (NodeInfo node : nodes) {
|
||||||
processNodeState(executeId, node, chainStateRepository, nodeStateRepository);
|
processNodeState(executeId, node, chainState, nodeStateRepository);
|
||||||
res.getNodes().put(node.getNodeId(), node);
|
res.getNodes().put(node.getNodeId(), node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,21 +52,31 @@ public class TinyFlowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理节点状态
|
* 使用同一工作流状态快照补充节点状态,避免轮询期间重复读取工作流状态。
|
||||||
|
*
|
||||||
|
* @param currentExecuteId 工作流执行实例 ID
|
||||||
|
* @param node 待补充状态的节点
|
||||||
|
* @param currentChainState 当前轮询取得的工作流状态快照
|
||||||
|
* @param nodeStateRepository 节点状态仓储
|
||||||
*/
|
*/
|
||||||
private void processNodeState(String currentExecuteId,
|
private void processNodeState(String currentExecuteId,
|
||||||
NodeInfo node,
|
NodeInfo node,
|
||||||
ChainStateRepository chainStateRepository,
|
ChainState currentChainState,
|
||||||
NodeStateRepository nodeStateRepository) {
|
NodeStateRepository nodeStateRepository) {
|
||||||
|
|
||||||
// 加载当前层的状态
|
|
||||||
ChainState currentChainState = chainStateRepository.load(currentExecuteId);
|
|
||||||
NodeState currentNodeState = nodeStateRepository.load(currentExecuteId, node.getNodeId());
|
NodeState currentNodeState = nodeStateRepository.load(currentExecuteId, node.getNodeId());
|
||||||
|
|
||||||
setNodeStatus(node, currentNodeState, currentChainState);
|
setNodeStatus(node, currentNodeState, currentChainState);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ChainInfo getChainInfo(String executeId, ChainState chainState) {
|
/**
|
||||||
|
* 将工作流状态转换为设计器响应。
|
||||||
|
*
|
||||||
|
* @param executeId 工作流执行实例 ID
|
||||||
|
* @param chainState 工作流状态快照
|
||||||
|
* @return 设计器工作流状态
|
||||||
|
*/
|
||||||
|
private ChainInfo getChainInfo(String executeId, ChainState chainState) {
|
||||||
ChainInfo res = new ChainInfo();
|
ChainInfo res = new ChainInfo();
|
||||||
res.setExecuteId(executeId);
|
res.setExecuteId(executeId);
|
||||||
res.setStatus(chainState.getStatus().getValue());
|
res.setStatus(chainState.getStatus().getValue());
|
||||||
@@ -65,24 +86,41 @@ public class TinyFlowService {
|
|||||||
}
|
}
|
||||||
Map<String, Object> executeResult = chainState.getExecuteResult();
|
Map<String, Object> executeResult = chainState.getExecuteResult();
|
||||||
if (executeResult != null && !executeResult.isEmpty()) {
|
if (executeResult != null && !executeResult.isEmpty()) {
|
||||||
res.setResult(executeResult);
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> resolved = (Map<String, Object>)
|
||||||
|
chainExecutor.resolveResultReferences(executeResult);
|
||||||
|
res.setResult(resolved);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将节点状态和节点执行结果写入设计器节点。
|
||||||
|
*
|
||||||
|
* @param node 设计器节点
|
||||||
|
* @param nodeState 节点状态;节点尚未开始执行时可为空
|
||||||
|
* @param chainState 工作流状态快照
|
||||||
|
*/
|
||||||
private void setNodeStatus(NodeInfo node, NodeState nodeState, ChainState chainState) {
|
private void setNodeStatus(NodeInfo node, NodeState nodeState, ChainState chainState) {
|
||||||
String nodeId = node.getNodeId();
|
String nodeId = node.getNodeId();
|
||||||
// 如果状态为空或不存在,可能不需要覆盖,这里视具体业务逻辑而定,目前保持原逻辑
|
// 旧仓储会为未启动节点返回 READY 状态;纯读取仓储返回空时保持相同行为但不产生写入。
|
||||||
node.setStatus(nodeState.getStatus().getValue());
|
node.setStatus(nodeState == null
|
||||||
|
? NodeStatus.READY.getValue()
|
||||||
|
: nodeState.getStatus().getValue());
|
||||||
|
|
||||||
ExceptionSummary error = nodeState.getError();
|
if (nodeState != null) {
|
||||||
if (error != null) {
|
ExceptionSummary error = nodeState.getError();
|
||||||
node.setMessage(error.getRootCauseClass() + " --> " + error.getRootCauseMessage());
|
if (error != null) {
|
||||||
|
node.setMessage(error.getRootCauseClass() + " --> " + error.getRootCauseMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> nodeExecuteResult = chainState.getNodeExecuteResult(nodeId);
|
Map<String, Object> nodeExecuteResult = chainState.getNodeExecuteResult(nodeId);
|
||||||
if (nodeExecuteResult != null && !nodeExecuteResult.isEmpty()) {
|
if (nodeExecuteResult != null && !nodeExecuteResult.isEmpty()) {
|
||||||
node.setResult(nodeExecuteResult);
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> resolved = (Map<String, Object>)
|
||||||
|
chainExecutor.resolveResultReferences(nodeExecuteResult);
|
||||||
|
node.setResult(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只有当参数不为空时才覆盖
|
// 只有当参数不为空时才覆盖
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import tech.easyflow.common.web.exceptions.BusinessException;
|
|||||||
import tech.easyflow.datacenter.execution.model.DatacenterSchemaResponse;
|
import tech.easyflow.datacenter.execution.model.DatacenterSchemaResponse;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.ArrayDeque;
|
import java.util.ArrayDeque;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -50,6 +51,8 @@ public class WorkflowCheckService {
|
|||||||
private static final String TYPE_PLUGIN = "plugin-node";
|
private static final String TYPE_PLUGIN = "plugin-node";
|
||||||
private static final String TYPE_MAKE_FILE = "make-file";
|
private static final String TYPE_MAKE_FILE = "make-file";
|
||||||
private static final String SYSTEM_START_PARAM_NAME = "user_input";
|
private static final String SYSTEM_START_PARAM_NAME = "user_input";
|
||||||
|
private static final int MIN_LOOP_COUNT = 1;
|
||||||
|
private static final int MAX_LOOP_COUNT = 300;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private WorkflowService workflowService;
|
private WorkflowService workflowService;
|
||||||
@@ -171,6 +174,7 @@ public class WorkflowCheckService {
|
|||||||
"父节点不存在: " + node.parentId, node.id, null, node.name);
|
"父节点不存在: " + node.parentId, node.id, null, node.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
checkLoopConfigurations(nodes, nodeMap, issues, issueKeys);
|
||||||
|
|
||||||
List<EdgeView> edges = new ArrayList<>();
|
List<EdgeView> edges = new ArrayList<>();
|
||||||
Set<String> edgeIds = new HashSet<>();
|
Set<String> edgeIds = new HashSet<>();
|
||||||
@@ -221,6 +225,159 @@ public class WorkflowCheckService {
|
|||||||
return parsedWorkflow;
|
return parsedWorkflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验普通循环、显式循环和循环父子层级。
|
||||||
|
*
|
||||||
|
* @param nodes 节点列表
|
||||||
|
* @param nodeMap 节点索引
|
||||||
|
* @param issues 问题列表
|
||||||
|
* @param issueKeys 问题去重键
|
||||||
|
*/
|
||||||
|
private void checkLoopConfigurations(
|
||||||
|
List<NodeView> nodes,
|
||||||
|
Map<String, NodeView> nodeMap,
|
||||||
|
List<WorkflowCheckIssue> issues,
|
||||||
|
Set<String> issueKeys) {
|
||||||
|
for (NodeView node : nodes) {
|
||||||
|
checkConfiguredLoopCount(node, issues, issueKeys);
|
||||||
|
checkFixedExplicitLoopCount(node, issues, issueKeys);
|
||||||
|
if (StringUtils.hasText(node.parentId)) {
|
||||||
|
NodeView parent = nodeMap.get(node.parentId);
|
||||||
|
if (parent != null && !TYPE_LOOP.equals(parent.type)) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
issueKeys,
|
||||||
|
"NODE_PARENT_NOT_LOOP",
|
||||||
|
"嵌套节点的父节点必须是循环节点",
|
||||||
|
node.id,
|
||||||
|
null,
|
||||||
|
node.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkLoopParentCycle(node, nodeMap, issues, issueKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验普通节点启用循环后的总执行次数。
|
||||||
|
*
|
||||||
|
* @param node 节点
|
||||||
|
* @param issues 问题列表
|
||||||
|
* @param issueKeys 问题去重键
|
||||||
|
*/
|
||||||
|
private void checkConfiguredLoopCount(
|
||||||
|
NodeView node,
|
||||||
|
List<WorkflowCheckIssue> issues,
|
||||||
|
Set<String> issueKeys) {
|
||||||
|
if (node.data == null
|
||||||
|
|| !Boolean.TRUE.equals(node.data.getBoolean("loopEnable"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object value = node.data.get("maxLoopCount");
|
||||||
|
if (value != null) {
|
||||||
|
addLoopCountIssueIfInvalid(
|
||||||
|
value, "LOOP_COUNT_INVALID", node, issues, issueKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验显式循环节点使用固定数值时的次数范围。
|
||||||
|
*
|
||||||
|
* @param node 节点
|
||||||
|
* @param issues 问题列表
|
||||||
|
* @param issueKeys 问题去重键
|
||||||
|
*/
|
||||||
|
private void checkFixedExplicitLoopCount(
|
||||||
|
NodeView node,
|
||||||
|
List<WorkflowCheckIssue> issues,
|
||||||
|
Set<String> issueKeys) {
|
||||||
|
if (!TYPE_LOOP.equals(node.type) || node.data == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JSONArray loopVars = node.data.getJSONArray("loopVars");
|
||||||
|
if (loopVars == null || loopVars.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JSONObject loopVar = loopVars.getJSONObject(0);
|
||||||
|
if (loopVar == null || !"fixed".equals(loopVar.getString("refType"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object value = loopVar.get("value");
|
||||||
|
if (value != null && StringUtils.hasText(String.valueOf(value))) {
|
||||||
|
addLoopCountIssueIfInvalid(
|
||||||
|
value,
|
||||||
|
"EXPLICIT_LOOP_COUNT_INVALID",
|
||||||
|
node,
|
||||||
|
issues,
|
||||||
|
issueKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在次数值无效时添加校验问题。
|
||||||
|
*
|
||||||
|
* @param value 原始次数
|
||||||
|
* @param code 问题编码
|
||||||
|
* @param node 节点
|
||||||
|
* @param issues 问题列表
|
||||||
|
* @param issueKeys 问题去重键
|
||||||
|
*/
|
||||||
|
private void addLoopCountIssueIfInvalid(
|
||||||
|
Object value,
|
||||||
|
String code,
|
||||||
|
NodeView node,
|
||||||
|
List<WorkflowCheckIssue> issues,
|
||||||
|
Set<String> issueKeys) {
|
||||||
|
try {
|
||||||
|
int count = new BigDecimal(String.valueOf(value).trim())
|
||||||
|
.intValueExact();
|
||||||
|
if (count >= MIN_LOOP_COUNT && count <= MAX_LOOP_COUNT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (ArithmeticException | NumberFormatException ignored) {
|
||||||
|
// 统一在下方返回用户可执行的范围提示。
|
||||||
|
}
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
issueKeys,
|
||||||
|
code,
|
||||||
|
"循环次数必须是 1~300 的整数",
|
||||||
|
node.id,
|
||||||
|
null,
|
||||||
|
node.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 parentId 层级不存在循环引用。
|
||||||
|
*
|
||||||
|
* @param node 起始节点
|
||||||
|
* @param nodeMap 节点索引
|
||||||
|
* @param issues 问题列表
|
||||||
|
* @param issueKeys 问题去重键
|
||||||
|
*/
|
||||||
|
private void checkLoopParentCycle(
|
||||||
|
NodeView node,
|
||||||
|
Map<String, NodeView> nodeMap,
|
||||||
|
List<WorkflowCheckIssue> issues,
|
||||||
|
Set<String> issueKeys) {
|
||||||
|
Set<String> visited = new HashSet<>();
|
||||||
|
NodeView current = node;
|
||||||
|
while (current != null && StringUtils.hasText(current.parentId)) {
|
||||||
|
if (!visited.add(current.id)) {
|
||||||
|
addIssue(
|
||||||
|
issues,
|
||||||
|
issueKeys,
|
||||||
|
"LOOP_PARENT_CYCLE",
|
||||||
|
"循环嵌套层级存在循环引用",
|
||||||
|
node.id,
|
||||||
|
null,
|
||||||
|
node.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current = nodeMap.get(current.parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void checkDatacenterNodes(ParsedWorkflow parsed, List<WorkflowCheckIssue> issues, Set<String> issueKeys) {
|
private void checkDatacenterNodes(ParsedWorkflow parsed, List<WorkflowCheckIssue> issues, Set<String> issueKeys) {
|
||||||
for (NodeView node : parsed.nodes) {
|
for (NodeView node : parsed.nodes) {
|
||||||
if (node == null) {
|
if (node == null) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.support;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import cn.hutool.crypto.digest.DigestUtil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流节点执行步骤键转换工具。
|
||||||
|
*/
|
||||||
|
public final class WorkflowExecutionStepKey {
|
||||||
|
|
||||||
|
private static final String LEGACY_PREFIX =
|
||||||
|
"easyflow-legacy-exec-key:";
|
||||||
|
|
||||||
|
private WorkflowExecutionStepKey() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将旧快照中的最终执行键编码为可随节点生命周期传递的兼容键。
|
||||||
|
*
|
||||||
|
* @param execKey 旧版最终执行键
|
||||||
|
* @return 兼容键;输入为空时为 {@code null}
|
||||||
|
*/
|
||||||
|
public static String encodeLegacy(String execKey) {
|
||||||
|
return StrUtil.isBlank(execKey)
|
||||||
|
? null
|
||||||
|
: LEGACY_PREFIX + execKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将节点业务尝试键转换为最终执行步骤键。
|
||||||
|
*
|
||||||
|
* @param executionAttemptKey 业务尝试键或旧版兼容键
|
||||||
|
* @return 最终执行步骤键;输入为空时为 {@code null}
|
||||||
|
*/
|
||||||
|
public static String resolve(String executionAttemptKey) {
|
||||||
|
if (StrUtil.isBlank(executionAttemptKey)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (executionAttemptKey.startsWith(
|
||||||
|
LEGACY_PREFIX)) {
|
||||||
|
return executionAttemptKey.substring(
|
||||||
|
LEGACY_PREFIX.length());
|
||||||
|
}
|
||||||
|
return DigestUtil.sha256Hex(
|
||||||
|
executionAttemptKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import com.easyagents.core.model.chat.tool.Tool;
|
|||||||
import com.mybatisflex.annotation.Column;
|
import com.mybatisflex.annotation.Column;
|
||||||
import com.mybatisflex.annotation.Table;
|
import com.mybatisflex.annotation.Table;
|
||||||
import tech.easyflow.ai.easyagents.tool.PluginTool;
|
import tech.easyflow.ai.easyagents.tool.PluginTool;
|
||||||
|
import tech.easyflow.ai.entity.Plugin;
|
||||||
import tech.easyflow.ai.entity.base.PluginItemBase;
|
import tech.easyflow.ai.entity.base.PluginItemBase;
|
||||||
|
|
||||||
|
|
||||||
@@ -31,4 +32,14 @@ public class PluginItem extends PluginItemBase {
|
|||||||
return new PluginTool(this);
|
return new PluginTool(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用调用方已经加载的插件快照创建工具,避免执行热路径重复查询。
|
||||||
|
*
|
||||||
|
* @param plugin 插件快照
|
||||||
|
* @return 插件工具
|
||||||
|
*/
|
||||||
|
public Tool toFunction(Plugin plugin) {
|
||||||
|
return new PluginTool(this, plugin);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,14 @@ import tech.easyflow.ai.entity.base.WorkflowExecResultBase;
|
|||||||
@Table(value = "tb_workflow_exec_result", comment = "工作流执行记录")
|
@Table(value = "tb_workflow_exec_result", comment = "工作流执行记录")
|
||||||
public class WorkflowExecResult extends WorkflowExecResultBase {
|
public class WorkflowExecResult extends WorkflowExecResultBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工作流执行耗时。
|
||||||
|
*
|
||||||
|
* @return 起止时间完整时返回毫秒耗时,否则返回 null
|
||||||
|
*/
|
||||||
public Long getExecTime() {
|
public Long getExecTime() {
|
||||||
if (getEndTime() == null) {
|
if (getStartTime() == null
|
||||||
|
|| getEndTime() == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return getEndTime().getTime() - getStartTime().getTime();
|
return getEndTime().getTime() - getStartTime().getTime();
|
||||||
|
|||||||
@@ -20,8 +20,14 @@ public class WorkflowExecStep extends WorkflowExecStepBase {
|
|||||||
@Column(ignore = true)
|
@Column(ignore = true)
|
||||||
private String nodeType;
|
private String nodeType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取节点执行耗时。
|
||||||
|
*
|
||||||
|
* @return 起止时间完整时返回毫秒耗时,否则返回 null
|
||||||
|
*/
|
||||||
public Long getExecTime() {
|
public Long getExecTime() {
|
||||||
if (getEndTime() == null) {
|
if (getStartTime() == null
|
||||||
|
|| getEndTime() == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return getEndTime().getTime() - getStartTime().getTime();
|
return getEndTime().getTime() - getStartTime().getTime();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.easyagents.flow.core.node.BaseNode;
|
|||||||
import com.easyagents.flow.core.util.JsConditionUtil;
|
import com.easyagents.flow.core.util.JsConditionUtil;
|
||||||
import com.easyagents.flow.core.util.StringUtil;
|
import com.easyagents.flow.core.util.StringUtil;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
@@ -16,6 +17,7 @@ import java.util.regex.Pattern;
|
|||||||
* 条件判断节点:首个命中(if / else-if)语义。
|
* 条件判断节点:首个命中(if / else-if)语义。
|
||||||
*/
|
*/
|
||||||
public class ConditionNode extends BaseNode {
|
public class ConditionNode extends BaseNode {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
private static final Pattern TEMPLATE_PARAM_PATTERN = Pattern.compile("\\{\\{\\s*([^{}]+?)\\s*}}");
|
private static final Pattern TEMPLATE_PARAM_PATTERN = Pattern.compile("\\{\\{\\s*([^{}]+?)\\s*}}");
|
||||||
|
|
||||||
private String branchMode = "first_match";
|
private String branchMode = "first_match";
|
||||||
@@ -116,7 +118,10 @@ public class ConditionNode extends BaseNode {
|
|||||||
|
|
||||||
while (matcher.find()) {
|
while (matcher.find()) {
|
||||||
String path = matcher.group(1) == null ? "" : matcher.group(1).trim();
|
String path = matcher.group(1) == null ? "" : matcher.group(1).trim();
|
||||||
Object value = StringUtil.noText(path) ? null : chain.getState().resolveValue(path);
|
Object value = StringUtil.noText(path)
|
||||||
|
? null
|
||||||
|
: chain.getExecutionState()
|
||||||
|
.resolveValue(path);
|
||||||
matcher.appendReplacement(output, Matcher.quoteReplacement(toJsLiteral(value)));
|
matcher.appendReplacement(output, Matcher.quoteReplacement(toJsLiteral(value)));
|
||||||
}
|
}
|
||||||
matcher.appendTail(output);
|
matcher.appendTail(output);
|
||||||
@@ -218,7 +223,8 @@ public class ConditionNode extends BaseNode {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return chain.getState().resolveValue(path);
|
return chain.getExecutionState()
|
||||||
|
.resolveValue(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isEmpty(Object value) {
|
private boolean isEmpty(Object value) {
|
||||||
@@ -380,7 +386,9 @@ public class ConditionNode extends BaseNode {
|
|||||||
this.branches = branches;
|
this.branches = branches;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ConditionBranch {
|
public static class ConditionBranch implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
private String id;
|
private String id;
|
||||||
private String label;
|
private String label;
|
||||||
private String mode;
|
private String mode;
|
||||||
@@ -428,7 +436,9 @@ public class ConditionNode extends BaseNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ConditionRule {
|
public static class ConditionRule implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
private String id;
|
private String id;
|
||||||
private String joiner;
|
private String joiner;
|
||||||
private String leftRef;
|
private String leftRef;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import java.util.Map;
|
|||||||
* @since 2026-04-14
|
* @since 2026-04-14
|
||||||
*/
|
*/
|
||||||
public class DocNode extends BaseNode {
|
public class DocNode extends BaseNode {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行文件内容提取。
|
* 执行文件内容提取。
|
||||||
@@ -30,7 +32,8 @@ public class DocNode extends BaseNode {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> execute(Chain chain) {
|
public Map<String, Object> execute(Chain chain) {
|
||||||
Map<String, Object> map = chain.getState().resolveParameters(this);
|
Map<String, Object> map =
|
||||||
|
chain.getExecutionState().resolveParameters(this);
|
||||||
DocNodeFileContentExtractor extractor = SpringContextUtil.getBean(DocNodeFileContentExtractor.class);
|
DocNodeFileContentExtractor extractor = SpringContextUtil.getBean(DocNodeFileContentExtractor.class);
|
||||||
List<DocNodeFileContentExtractor.DocExtractedDocument> documents = extractor.extractDocuments(map.get("file"));
|
List<DocNodeFileContentExtractor.DocExtractedDocument> documents = extractor.extractDocuments(map.get("file"));
|
||||||
|
|
||||||
|
|||||||
@@ -3,27 +3,38 @@ package tech.easyflow.ai.node;
|
|||||||
import cn.hutool.core.io.FileTypeUtil;
|
import cn.hutool.core.io.FileTypeUtil;
|
||||||
import cn.hutool.core.util.IdUtil;
|
import cn.hutool.core.util.IdUtil;
|
||||||
import com.easyagents.core.util.StringUtil;
|
import com.easyagents.core.util.StringUtil;
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import com.mybatisflex.core.tenant.TenantManager;
|
import com.mybatisflex.core.tenant.TenantManager;
|
||||||
import com.easyagents.flow.core.chain.Chain;
|
import com.easyagents.flow.core.chain.Chain;
|
||||||
import com.easyagents.flow.core.chain.Parameter;
|
import com.easyagents.flow.core.chain.Parameter;
|
||||||
|
import com.easyagents.flow.core.chain.runtime.RetryableTriggerException;
|
||||||
import com.easyagents.flow.core.node.BaseNode;
|
import com.easyagents.flow.core.node.BaseNode;
|
||||||
|
import com.easyagents.flow.core.util.IoBulkhead;
|
||||||
import tech.easyflow.ai.entity.Resource;
|
import tech.easyflow.ai.entity.Resource;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.config.WorkflowRuntimeProperties;
|
||||||
import tech.easyflow.ai.service.ResourceService;
|
import tech.easyflow.ai.service.ResourceService;
|
||||||
import tech.easyflow.ai.utils.DocUtil;
|
import tech.easyflow.ai.utils.DocUtil;
|
||||||
import tech.easyflow.ai.utils.WorkFlowUtil;
|
import tech.easyflow.ai.utils.WorkFlowUtil;
|
||||||
import tech.easyflow.common.constant.enums.EnumResourceOriginType;
|
import tech.easyflow.common.constant.enums.EnumResourceOriginType;
|
||||||
|
import tech.easyflow.common.cache.RedisIdempotencyExecutor;
|
||||||
|
import tech.easyflow.common.cache.RedisIdempotencyExecutor.IdempotentOperationInProgressException;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.filestorage.FileStorageManager;
|
import tech.easyflow.common.filestorage.FileStorageManager;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageWriteResult;
|
||||||
import tech.easyflow.common.util.SpringContextUtil;
|
import tech.easyflow.common.util.SpringContextUtil;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
public class DownloadNode extends BaseNode {
|
public class DownloadNode extends BaseNode {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
private Integer resourceType;
|
private Integer resourceType;
|
||||||
|
|
||||||
@@ -36,57 +47,199 @@ public class DownloadNode extends BaseNode {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> execute(Chain chain) {
|
public Map<String, Object> execute(Chain chain) {
|
||||||
Map<String, Object> map = chain.getState().resolveParameters(this);
|
Map<String, Object> map =
|
||||||
Map<String, Object> res = new HashMap<>();
|
chain.getExecutionState().resolveParameters(this);
|
||||||
|
|
||||||
String originUrl = map.get("originUrl").toString();
|
String originUrl = map.get("originUrl").toString();
|
||||||
|
LoginAccount account = WorkFlowUtil.getOperator(chain);
|
||||||
|
ResourceService resourceService =
|
||||||
|
SpringContextUtil.getBean(ResourceService.class);
|
||||||
|
String idempotencyKey =
|
||||||
|
chain.currentExecutionIdempotencyKey(this.id);
|
||||||
|
String resourceName = idempotencyKey == null
|
||||||
|
? IdUtil.simpleUUID()
|
||||||
|
: UUID.nameUUIDFromBytes(
|
||||||
|
idempotencyKey.getBytes(StandardCharsets.UTF_8))
|
||||||
|
.toString()
|
||||||
|
.replace("-", "");
|
||||||
|
|
||||||
byte[] bytes = DocUtil.downloadFile(originUrl);
|
Resource existing = findResource(
|
||||||
|
resourceService, resourceName, account);
|
||||||
String suffix = FileTypeUtil.getType(new ByteArrayInputStream(bytes));
|
if (existing != null) {
|
||||||
|
return output(existing.getResourceUrl());
|
||||||
if (suffix == null) {
|
|
||||||
suffix = "unknown";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String fileName = IdUtil.simpleUUID() + "." + suffix;
|
WorkflowRuntimeProperties runtimeProperties =
|
||||||
|
SpringContextUtil.getBean(WorkflowRuntimeProperties.class);
|
||||||
|
AtomicReference<String> resourceUrl =
|
||||||
|
new AtomicReference<>();
|
||||||
|
if (idempotencyKey == null) {
|
||||||
|
resourceUrl.set(downloadAndPersist(
|
||||||
|
originUrl,
|
||||||
|
resourceName,
|
||||||
|
account,
|
||||||
|
resourceService,
|
||||||
|
runtimeProperties));
|
||||||
|
} else {
|
||||||
|
RedisIdempotencyExecutor idempotencyExecutor =
|
||||||
|
SpringContextUtil.getBean(
|
||||||
|
RedisIdempotencyExecutor.class);
|
||||||
|
boolean executed;
|
||||||
|
try {
|
||||||
|
executed = idempotencyExecutor.executeOnce(
|
||||||
|
idempotencyKey + ":download-resource",
|
||||||
|
() -> resourceUrl.set(downloadAndPersist(
|
||||||
|
originUrl,
|
||||||
|
resourceName,
|
||||||
|
account,
|
||||||
|
resourceService,
|
||||||
|
runtimeProperties)));
|
||||||
|
} catch (IdempotentOperationInProgressException conflict) {
|
||||||
|
throw new RetryableTriggerException(
|
||||||
|
"下载幂等操作仍在处理中", conflict);
|
||||||
|
}
|
||||||
|
if (!executed) {
|
||||||
|
Resource completed = findResource(
|
||||||
|
resourceService, resourceName, account);
|
||||||
|
if (completed == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Download idempotency receipt exists "
|
||||||
|
+ "without resource record");
|
||||||
|
}
|
||||||
|
resourceUrl.set(completed.getResourceUrl());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output(resourceUrl.get());
|
||||||
|
}
|
||||||
|
|
||||||
FileStorageManager manager = SpringContextUtil.getBean(FileStorageManager.class);
|
/**
|
||||||
|
* 流式下载、稳定上传并持久化素材记录。
|
||||||
|
*
|
||||||
|
* @param originUrl 原始 URL
|
||||||
|
* @param resourceName 稳定资源名称
|
||||||
|
* @param account 操作账号
|
||||||
|
* @param resourceService 素材服务
|
||||||
|
* @param runtimeProperties 工作流运行配置
|
||||||
|
* @return 素材 URL
|
||||||
|
*/
|
||||||
|
private String downloadAndPersist(
|
||||||
|
String originUrl,
|
||||||
|
String resourceName,
|
||||||
|
LoginAccount account,
|
||||||
|
ResourceService resourceService,
|
||||||
|
WorkflowRuntimeProperties runtimeProperties) {
|
||||||
|
try (DocUtil.DownloadedFile downloadedFile =
|
||||||
|
DocUtil.downloadFileToTemp(originUrl, runtimeProperties.getDownloadMaxBytes())) {
|
||||||
|
String suffix = FileTypeUtil.getType(downloadedFile.path().toFile());
|
||||||
|
if (suffix == null) {
|
||||||
|
suffix = "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
String resourceUrl = manager.save(new CustomFile(fileName, bytes));
|
String fileName = resourceName + "." + suffix;
|
||||||
|
FileStorageManager manager = SpringContextUtil.getBean(FileStorageManager.class);
|
||||||
|
FileStorageWriteHandle handle =
|
||||||
|
manager.prepareRecoverableWrite(
|
||||||
|
"workflow/download", fileName);
|
||||||
|
boolean existedBefore = manager.existsRecoverable(handle);
|
||||||
|
FileStorageWriteResult writeResult;
|
||||||
|
try {
|
||||||
|
try (IoBulkhead.Permit ignored =
|
||||||
|
IoBulkhead.storage().acquire(
|
||||||
|
"storage:upload")) {
|
||||||
|
writeResult = manager.saveRecoverable(
|
||||||
|
new TemporaryFileMultipartFile(
|
||||||
|
fileName,
|
||||||
|
downloadedFile.path(),
|
||||||
|
downloadedFile.contentType()),
|
||||||
|
handle);
|
||||||
|
}
|
||||||
|
|
||||||
Resource resource = new Resource();
|
Resource alreadySaved = findResource(
|
||||||
|
resourceService, resourceName, account);
|
||||||
|
if (alreadySaved != null) {
|
||||||
|
return alreadySaved.getResourceUrl();
|
||||||
|
}
|
||||||
|
Resource resource = new Resource();
|
||||||
|
resource.setDeptId(account.getDeptId());
|
||||||
|
resource.setTenantId(account.getTenantId());
|
||||||
|
resource.setResourceType(this.resourceType);
|
||||||
|
resource.setResourceName(resourceName);
|
||||||
|
resource.setSuffix(suffix);
|
||||||
|
resource.setResourceUrl(writeResult.getUrl());
|
||||||
|
resource.setOrigin(
|
||||||
|
EnumResourceOriginType.GENERATE.getCode());
|
||||||
|
resource.setCreated(new Date());
|
||||||
|
resource.setCreatedBy(account.getId());
|
||||||
|
resource.setModified(new Date());
|
||||||
|
resource.setModifiedBy(account.getId());
|
||||||
|
resource.setFileSize(
|
||||||
|
BigInteger.valueOf(downloadedFile.size()));
|
||||||
|
try {
|
||||||
|
TenantManager.ignoreTenantCondition();
|
||||||
|
if (!resourceService.save(resource)) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"素材记录保存失败");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
TenantManager.restoreTenantCondition();
|
||||||
|
}
|
||||||
|
return writeResult.getUrl();
|
||||||
|
} catch (RuntimeException | Error error) {
|
||||||
|
if (!existedBefore) {
|
||||||
|
try {
|
||||||
|
manager.deleteRecoverable(handle);
|
||||||
|
} catch (RuntimeException cleanupError) {
|
||||||
|
error.addSuppressed(cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LoginAccount account = WorkFlowUtil.getOperator(chain);
|
/**
|
||||||
|
* 查询同一稳定执行生成的素材记录。
|
||||||
resource.setDeptId(account.getDeptId());
|
*
|
||||||
resource.setTenantId(account.getTenantId());
|
* @param service 素材服务
|
||||||
resource.setResourceType(this.resourceType);
|
* @param resourceName 稳定资源名
|
||||||
resource.setResourceName(DocUtil.getFileNameByUrl(resourceUrl).split("\\.")[0]);
|
* @param account 操作账号
|
||||||
resource.setSuffix(suffix);
|
* @return 已存在记录
|
||||||
resource.setResourceUrl(resourceUrl);
|
*/
|
||||||
resource.setOrigin(EnumResourceOriginType.GENERATE.getCode());
|
private Resource findResource(
|
||||||
resource.setCreated(new Date());
|
ResourceService service,
|
||||||
resource.setCreatedBy(account.getId());
|
String resourceName,
|
||||||
resource.setModified(new Date());
|
LoginAccount account) {
|
||||||
resource.setModifiedBy(account.getId());
|
|
||||||
resource.setFileSize(BigInteger.valueOf(bytes.length));
|
|
||||||
try {
|
try {
|
||||||
TenantManager.ignoreTenantCondition();
|
TenantManager.ignoreTenantCondition();
|
||||||
ResourceService service = SpringContextUtil.getBean(ResourceService.class);
|
return service.getOne(QueryWrapper.create()
|
||||||
service.save(resource);
|
.where(Resource::getResourceName)
|
||||||
|
.eq(resourceName)
|
||||||
|
.and(Resource::getTenantId)
|
||||||
|
.eq(account.getTenantId())
|
||||||
|
.and(Resource::getResourceType)
|
||||||
|
.eq(this.resourceType));
|
||||||
} finally {
|
} finally {
|
||||||
TenantManager.restoreTenantCondition();
|
TenantManager.restoreTenantCondition();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按节点定义的输出名称返回资源 URL。
|
||||||
|
*
|
||||||
|
* @param resourceUrl 资源 URL
|
||||||
|
* @return 节点输出
|
||||||
|
*/
|
||||||
|
private Map<String, Object> output(String resourceUrl) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
String key = "resourceUrl";
|
String key = "resourceUrl";
|
||||||
List<Parameter> outputDefs = getOutputDefs();
|
List<Parameter> outputDefs = getOutputDefs();
|
||||||
if (outputDefs != null && !outputDefs.isEmpty()) {
|
if (outputDefs != null && !outputDefs.isEmpty()) {
|
||||||
String defName = outputDefs.get(0).getName();
|
String defName = outputDefs.get(0).getName();
|
||||||
if (StringUtil.hasText(defName)) key = defName;
|
if (StringUtil.hasText(defName)) {
|
||||||
|
key = defName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
res.put(key, resourceUrl);
|
result.put(key, resourceUrl);
|
||||||
return res;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getResourceType() {
|
public Integer getResourceType() {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package tech.easyflow.ai.node;
|
package tech.easyflow.ai.node;
|
||||||
|
|
||||||
import cn.hutool.core.thread.ThreadUtil;
|
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import com.alibaba.fastjson2.JSON;
|
import com.alibaba.fastjson2.JSON;
|
||||||
import com.alibaba.fastjson2.JSONArray;
|
import com.alibaba.fastjson2.JSONArray;
|
||||||
@@ -22,13 +21,35 @@ import java.util.LinkedList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.*;
|
import java.util.concurrent.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
@Component("giteeReader")
|
@Component("giteeReader")
|
||||||
public class GiteeParseService implements ReadDocService {
|
public class GiteeParseService implements ReadDocService {
|
||||||
|
|
||||||
@Value("${node.gitee.appKey}")
|
@Value("${node.gitee.appKey}")
|
||||||
private String appKey;
|
private String appKey;
|
||||||
|
@Value("${node.gitee.parse-timeout-ms:900000}")
|
||||||
|
private long parseTimeoutMillis;
|
||||||
private static final Logger log = LoggerFactory.getLogger(GiteeParseService.class);
|
private static final Logger log = LoggerFactory.getLogger(GiteeParseService.class);
|
||||||
|
private static final int PARSER_THREADS = 5;
|
||||||
|
private static final int PARSER_QUEUE_CAPACITY = 64;
|
||||||
|
private static final AtomicInteger THREAD_SEQUENCE = new AtomicInteger();
|
||||||
|
private static final ExecutorService PARSER_EXECUTOR =
|
||||||
|
new ThreadPoolExecutor(
|
||||||
|
PARSER_THREADS,
|
||||||
|
PARSER_THREADS,
|
||||||
|
0L,
|
||||||
|
TimeUnit.MILLISECONDS,
|
||||||
|
new ArrayBlockingQueue<>(PARSER_QUEUE_CAPACITY),
|
||||||
|
runnable -> {
|
||||||
|
Thread thread = new Thread(
|
||||||
|
runnable,
|
||||||
|
"gitee-document-parser-"
|
||||||
|
+ THREAD_SEQUENCE.incrementAndGet());
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
},
|
||||||
|
new ThreadPoolExecutor.AbortPolicy());
|
||||||
@Resource(name = "defaultCache")
|
@Resource(name = "defaultCache")
|
||||||
private Cache<String, Object> defaultCache;
|
private Cache<String, Object> defaultCache;
|
||||||
|
|
||||||
@@ -45,7 +66,9 @@ public class GiteeParseService implements ReadDocService {
|
|||||||
return cache.toString();
|
return cache.toString();
|
||||||
}
|
}
|
||||||
String content;
|
String content;
|
||||||
ExecutorService executor = Executors.newFixedThreadPool(5);
|
long timeoutMillis = Math.max(1_000L, parseTimeoutMillis);
|
||||||
|
long deadlineNanos = System.nanoTime()
|
||||||
|
+ TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
|
||||||
try {
|
try {
|
||||||
byte[] b = DocUtil.readBytes(is);
|
byte[] b = DocUtil.readBytes(is);
|
||||||
Map<Integer, byte[]> split = splitDocFile(DocUtil.getSuffix(fileName), b, 30);
|
Map<Integer, byte[]> split = splitDocFile(DocUtil.getSuffix(fileName), b, 30);
|
||||||
@@ -54,12 +77,23 @@ public class GiteeParseService implements ReadDocService {
|
|||||||
for (Map.Entry<Integer, byte[]> entry : split.entrySet()) {
|
for (Map.Entry<Integer, byte[]> entry : split.entrySet()) {
|
||||||
int index = entry.getKey();
|
int index = entry.getKey();
|
||||||
byte[] splitBytes = entry.getValue();
|
byte[] splitBytes = entry.getValue();
|
||||||
tasks.add(() -> splitContent(index + "-" + fileName, splitBytes));
|
tasks.add(() -> splitContent(
|
||||||
|
index + "-" + fileName,
|
||||||
|
splitBytes,
|
||||||
|
deadlineNanos));
|
||||||
}
|
}
|
||||||
// 提交所有任务并等待完成
|
long remainingNanos = Math.max(
|
||||||
List<Future<String>> futures = executor.invokeAll(tasks);
|
1L, deadlineNanos - System.nanoTime());
|
||||||
|
List<Future<String>> futures = PARSER_EXECUTOR.invokeAll(
|
||||||
|
tasks, remainingNanos, TimeUnit.NANOSECONDS);
|
||||||
StringBuilder res = new StringBuilder();
|
StringBuilder res = new StringBuilder();
|
||||||
for (Future<String> future : futures) {
|
for (Future<String> future : futures) {
|
||||||
|
if (future.isCancelled()) {
|
||||||
|
throw new TimeoutException(
|
||||||
|
"文档解析超过 "
|
||||||
|
+ timeoutMillis
|
||||||
|
+ "ms");
|
||||||
|
}
|
||||||
String call = future.get();
|
String call = future.get();
|
||||||
if (StrUtil.isEmpty(call)) {
|
if (StrUtil.isEmpty(call)) {
|
||||||
throw new RuntimeException("读取文件任务失败:" + call);
|
throw new RuntimeException("读取文件任务失败:" + call);
|
||||||
@@ -69,20 +103,12 @@ public class GiteeParseService implements ReadDocService {
|
|||||||
content = res.toString();
|
content = res.toString();
|
||||||
|
|
||||||
defaultCache.put(CacheKey.DOC_NODE_CONTENT_KEY + fileName, content);
|
defaultCache.put(CacheKey.DOC_NODE_CONTENT_KEY + fileName, content);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new RuntimeException("读取文档内容被中断", e);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("读取文档内容失败:", e);
|
log.error("读取文档内容失败:", e);
|
||||||
throw new RuntimeException("读取文档内容失败:", e);
|
throw new RuntimeException("读取文档内容失败:", e);
|
||||||
} finally {
|
|
||||||
// 关闭线程池
|
|
||||||
executor.shutdown();
|
|
||||||
try {
|
|
||||||
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
|
|
||||||
executor.shutdownNow();
|
|
||||||
}
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
executor.shutdownNow();
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
@@ -104,7 +130,8 @@ public class GiteeParseService implements ReadDocService {
|
|||||||
.addHeader("Authorization", "Bearer " + appKey)
|
.addHeader("Authorization", "Bearer " + appKey)
|
||||||
.post(requestBody).build();
|
.post(requestBody).build();
|
||||||
|
|
||||||
OkHttpClient okHttpClient = OkHttpClientUtil.buildDefaultClient();
|
// 创建任务是非幂等 POST,禁止 OkHttp 在连接失败后隐式重发。
|
||||||
|
OkHttpClient okHttpClient = OkHttpClientUtil.buildNoRetryClient();
|
||||||
Call call = okHttpClient.newCall(request);
|
Call call = okHttpClient.newCall(request);
|
||||||
try (Response response = call.execute()) {
|
try (Response response = call.execute()) {
|
||||||
if (response.body() == null) {
|
if (response.body() == null) {
|
||||||
@@ -112,7 +139,11 @@ public class GiteeParseService implements ReadDocService {
|
|||||||
}
|
}
|
||||||
String jsonStr = response.body().string();
|
String jsonStr = response.body().string();
|
||||||
JSONObject object = JSON.parseObject(jsonStr);
|
JSONObject object = JSON.parseObject(jsonStr);
|
||||||
log.info("读取文件接口返回:{}", jsonStr);
|
log.info(
|
||||||
|
"文档解析任务已创建,fileName={}, status={}, taskId={}",
|
||||||
|
fileName,
|
||||||
|
object.getString("status"),
|
||||||
|
object.getString("task_id"));
|
||||||
String error = object.getString("error");
|
String error = object.getString("error");
|
||||||
if (StrUtil.isNotEmpty(error)) {
|
if (StrUtil.isNotEmpty(error)) {
|
||||||
throw new RuntimeException(object.getString("message"));
|
throw new RuntimeException(object.getString("message"));
|
||||||
@@ -154,7 +185,10 @@ public class GiteeParseService implements ReadDocService {
|
|||||||
}
|
}
|
||||||
return md.toString();
|
return md.toString();
|
||||||
} else {
|
} else {
|
||||||
System.out.println(taskId + " >>>>>>>>> " + object);
|
log.debug(
|
||||||
|
"文档解析任务等待中,taskId={}, status={}",
|
||||||
|
taskId,
|
||||||
|
object.getString("status"));
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("请求失败:", e);
|
log.error("请求失败:", e);
|
||||||
@@ -175,10 +209,16 @@ public class GiteeParseService implements ReadDocService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String splitContent(String fileName, byte[] b) {
|
private String splitContent(
|
||||||
|
String fileName, byte[] b, long deadlineNanos)
|
||||||
|
throws InterruptedException, TimeoutException {
|
||||||
String taskId = giteeParse(fileName, b);
|
String taskId = giteeParse(fileName, b);
|
||||||
while (true) {
|
while (true) {
|
||||||
ThreadUtil.sleep(1000);
|
if (System.nanoTime() >= deadlineNanos) {
|
||||||
|
throw new TimeoutException(
|
||||||
|
"文档解析任务超时:" + taskId);
|
||||||
|
}
|
||||||
|
Thread.sleep(1_000L);
|
||||||
String result = giteeParseResult(taskId);
|
String result = giteeParseResult(taskId);
|
||||||
if (!"waiting".equals(result)) {
|
if (!"waiting".equals(result)) {
|
||||||
// 去掉 HTML 标签,的内容,提取纯文本
|
// 去掉 HTML 标签,的内容,提取纯文本
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import java.util.Map;
|
|||||||
* @since 2026-04-18
|
* @since 2026-04-18
|
||||||
*/
|
*/
|
||||||
public class MakeFileNode extends BaseNode {
|
public class MakeFileNode extends BaseNode {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
|
||||||
private String targetFormat;
|
private String targetFormat;
|
||||||
private String sourceFormat;
|
private String sourceFormat;
|
||||||
@@ -45,7 +47,8 @@ public class MakeFileNode extends BaseNode {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> execute(Chain chain) {
|
public Map<String, Object> execute(Chain chain) {
|
||||||
Map<String, Object> map = chain.getState().resolveParameters(this);
|
Map<String, Object> map =
|
||||||
|
chain.getExecutionState().resolveParameters(this);
|
||||||
Object rawContent = map.get("content");
|
Object rawContent = map.get("content");
|
||||||
if (rawContent == null) {
|
if (rawContent == null) {
|
||||||
throw new BusinessException("文件生成节点缺少 content 参数");
|
throw new BusinessException("文件生成节点缺少 content 参数");
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import java.util.Collections;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public class PluginToolNode extends BaseNode {
|
public class PluginToolNode extends BaseNode {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
|
||||||
private BigInteger pluginId;
|
private BigInteger pluginId;
|
||||||
|
|
||||||
@@ -38,7 +40,8 @@ public class PluginToolNode extends BaseNode {
|
|||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> execute(Chain chain) {
|
public Map<String, Object> execute(Chain chain) {
|
||||||
Map<String, Object> map = chain.getState().resolveParameters(this);
|
Map<String, Object> map =
|
||||||
|
chain.getExecutionState().resolveParameters(this);
|
||||||
PluginItemService bean = SpringContextUtil.getBean(PluginItemService.class);
|
PluginItemService bean = SpringContextUtil.getBean(PluginItemService.class);
|
||||||
PluginItem tool = bean.getById(pluginId);
|
PluginItem tool = bean.getById(pluginId);
|
||||||
if (tool == null) {
|
if (tool == null) {
|
||||||
@@ -49,7 +52,7 @@ public class PluginToolNode extends BaseNode {
|
|||||||
if (plugin != null && PluginType.isWorkflow(plugin.getType())) {
|
if (plugin != null && PluginType.isWorkflow(plugin.getType())) {
|
||||||
return executeWorkflowPlugin(chain, map, plugin);
|
return executeWorkflowPlugin(chain, map, plugin);
|
||||||
}
|
}
|
||||||
Tool function = tool.toFunction();
|
Tool function = tool.toFunction(plugin);
|
||||||
if (function == null) {
|
if (function == null) {
|
||||||
return Collections.emptyMap();
|
return Collections.emptyMap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,28 @@ package tech.easyflow.ai.node;
|
|||||||
import com.alibaba.fastjson2.JSONArray;
|
import com.alibaba.fastjson2.JSONArray;
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.easyagents.flow.core.chain.Chain;
|
import com.easyagents.flow.core.chain.Chain;
|
||||||
|
import com.easyagents.flow.core.chain.runtime.RetryableTriggerException;
|
||||||
import com.easyagents.flow.core.node.BaseNode;
|
import com.easyagents.flow.core.node.BaseNode;
|
||||||
|
import com.easyagents.flow.core.util.IoBulkhead;
|
||||||
import com.mybatisflex.core.tenant.TenantManager;
|
import com.mybatisflex.core.tenant.TenantManager;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import tech.easyflow.ai.utils.WorkFlowUtil;
|
import tech.easyflow.ai.utils.WorkFlowUtil;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.config.WorkflowRuntimeProperties;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.cache.RedisIdempotencyExecutor.IdempotentOperationInProgressException;
|
||||||
import tech.easyflow.common.util.SpringContextUtil;
|
import tech.easyflow.common.util.SpringContextUtil;
|
||||||
import tech.easyflow.datacenter.execution.model.DatasetRef;
|
import tech.easyflow.datacenter.execution.model.DatasetRef;
|
||||||
import tech.easyflow.datacenter.execution.service.DatacenterDatasetQueryService;
|
import tech.easyflow.datacenter.execution.service.DatacenterDatasetQueryService;
|
||||||
import tech.easyflow.datacenter.execution.service.DatacenterDatasetWriteService;
|
import tech.easyflow.datacenter.execution.service.DatacenterDatasetWriteService;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public class SaveDatasetNode extends BaseNode {
|
public class SaveDatasetNode extends BaseNode {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(SaveDatasetNode.class);
|
private static final Logger log = LoggerFactory.getLogger(SaveDatasetNode.class);
|
||||||
|
|
||||||
@@ -32,7 +39,8 @@ public class SaveDatasetNode extends BaseNode {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> execute(Chain chain) {
|
public Map<String, Object> execute(Chain chain) {
|
||||||
Map<String, Object> state = chain.getState().resolveParameters(this);
|
Map<String, Object> state =
|
||||||
|
chain.getExecutionState().resolveParameters(this);
|
||||||
JSONObject payload = new JSONObject(state);
|
JSONObject payload = new JSONObject(state);
|
||||||
JSONArray saveList = payload.getJSONArray("saveList");
|
JSONArray saveList = payload.getJSONArray("saveList");
|
||||||
if (saveList == null || saveList.isEmpty()) {
|
if (saveList == null || saveList.isEmpty()) {
|
||||||
@@ -41,22 +49,31 @@ public class SaveDatasetNode extends BaseNode {
|
|||||||
LoginAccount account = WorkFlowUtil.getOperator(chain);
|
LoginAccount account = WorkFlowUtil.getOperator(chain);
|
||||||
DatacenterDatasetWriteService writeService = SpringContextUtil.getBean(DatacenterDatasetWriteService.class);
|
DatacenterDatasetWriteService writeService = SpringContextUtil.getBean(DatacenterDatasetWriteService.class);
|
||||||
DatacenterDatasetQueryService queryService = SpringContextUtil.getBean(DatacenterDatasetQueryService.class);
|
DatacenterDatasetQueryService queryService = SpringContextUtil.getBean(DatacenterDatasetQueryService.class);
|
||||||
int successRows = 0;
|
WorkflowRuntimeProperties runtimeProperties = SpringContextUtil.getBean(WorkflowRuntimeProperties.class);
|
||||||
|
List<JSONObject> rows = new ArrayList<>(saveList.size());
|
||||||
|
for (Object item : saveList) {
|
||||||
|
rows.add(item instanceof JSONObject json ? json : JSONObject.from(item));
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
TenantManager.ignoreTenantCondition();
|
TenantManager.ignoreTenantCondition();
|
||||||
for (Object item : saveList) {
|
try (IoBulkhead.Permit ignored = IoBulkhead.dataset().acquire(resolveIoTarget())) {
|
||||||
JSONObject row = item instanceof JSONObject json ? json : JSONObject.from(item);
|
writeService.saveRowsIdempotently(
|
||||||
writeService.saveRow(datasetRef, row, account);
|
datasetRef,
|
||||||
successRows++;
|
rows,
|
||||||
|
account,
|
||||||
|
runtimeProperties.getDataWriteBatchSize(),
|
||||||
|
chain.currentExecutionIdempotencyKey(getId()));
|
||||||
|
var schema = queryService.getLocation(datasetRef);
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("successRows", rows.size());
|
||||||
|
result.put("source", schema.getSource());
|
||||||
|
result.put("catalog", schema.getCatalog());
|
||||||
|
result.put("table", schema.getTable());
|
||||||
|
result.put("version", datasetRef.getVersionId());
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
var schema = queryService.getSchema(datasetRef);
|
} catch (IdempotentOperationInProgressException conflict) {
|
||||||
Map<String, Object> result = new HashMap<>();
|
throw new RetryableTriggerException("数据集写入幂等操作仍在处理中", conflict);
|
||||||
result.put("successRows", successRows);
|
|
||||||
result.put("source", schema.getSource());
|
|
||||||
result.put("catalog", schema.getCatalog());
|
|
||||||
result.put("table", schema.getTable());
|
|
||||||
result.put("version", datasetRef.getVersionId());
|
|
||||||
return result;
|
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.error("工作流保存数据到统一数据集失败,datasetRef={}", datasetRef, ex);
|
log.error("工作流保存数据到统一数据集失败,datasetRef={}", datasetRef, ex);
|
||||||
throw ex;
|
throw ex;
|
||||||
@@ -65,6 +82,18 @@ public class SaveDatasetNode extends BaseNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据源级 I/O 隔离目标。
|
||||||
|
*
|
||||||
|
* @return 数据源目标键
|
||||||
|
*/
|
||||||
|
private String resolveIoTarget() {
|
||||||
|
return "dataset:"
|
||||||
|
+ (datasetRef == null || datasetRef.getSourceId() == null
|
||||||
|
? "unknown"
|
||||||
|
: datasetRef.getSourceId());
|
||||||
|
}
|
||||||
|
|
||||||
public DatasetRef getDatasetRef() {
|
public DatasetRef getDatasetRef() {
|
||||||
return datasetRef;
|
return datasetRef;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package tech.easyflow.ai.node;
|
|||||||
import com.easyagents.core.util.StringUtil;
|
import com.easyagents.core.util.StringUtil;
|
||||||
import com.easyagents.flow.core.chain.Chain;
|
import com.easyagents.flow.core.chain.Chain;
|
||||||
import com.easyagents.flow.core.chain.Parameter;
|
import com.easyagents.flow.core.chain.Parameter;
|
||||||
|
import com.easyagents.flow.core.chain.repository.LoopInputReference;
|
||||||
import com.easyagents.flow.core.node.BaseNode;
|
import com.easyagents.flow.core.node.BaseNode;
|
||||||
|
import com.easyagents.flow.core.util.IoBulkhead;
|
||||||
import com.mybatisflex.core.row.Row;
|
import com.mybatisflex.core.row.Row;
|
||||||
import com.mybatisflex.core.tenant.TenantManager;
|
import com.mybatisflex.core.tenant.TenantManager;
|
||||||
import tech.easyflow.common.util.SpringContextUtil;
|
import tech.easyflow.common.util.SpringContextUtil;
|
||||||
@@ -15,12 +17,20 @@ import tech.easyflow.datacenter.execution.service.DatacenterDatasetQueryService;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
public class SearchDatasetNode extends BaseNode {
|
public class SearchDatasetNode extends BaseNode {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
|
||||||
private static final Pattern PARAM_PATTERN = Pattern.compile("\\{\\{(.+?)\\}\\}");
|
private static final Pattern PARAM_PATTERN = Pattern.compile("\\{\\{(.+?)\\}\\}");
|
||||||
|
private static final int QUERY_PAGE_SIZE = Math.max(
|
||||||
|
1,
|
||||||
|
Integer.getInteger(
|
||||||
|
"easyflow.workflow.dataset.page-size",
|
||||||
|
1_000));
|
||||||
|
|
||||||
private DatasetRef datasetRef;
|
private DatasetRef datasetRef;
|
||||||
private String querySql;
|
private String querySql;
|
||||||
@@ -39,20 +49,50 @@ public class SearchDatasetNode extends BaseNode {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> execute(Chain chain) {
|
public Map<String, Object> execute(Chain chain) {
|
||||||
Map<String, Object> params = chain.getState().resolveParameters(this);
|
Map<String, Object> params =
|
||||||
|
chain.getExecutionState().resolveParameters(this);
|
||||||
DatacenterDatasetQueryService queryService = SpringContextUtil.getBean(DatacenterDatasetQueryService.class);
|
DatacenterDatasetQueryService queryService = SpringContextUtil.getBean(DatacenterDatasetQueryService.class);
|
||||||
DatacenterSqlQueryRequest request = buildRuntimeRequest(params);
|
DatacenterSqlQueryRequest request = buildRuntimeRequest(params);
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
try {
|
try {
|
||||||
TenantManager.ignoreTenantCondition();
|
TenantManager.ignoreTenantCondition();
|
||||||
List<Row> rows = queryService.queryBySql(request);
|
try (IoBulkhead.Permit ignored = IoBulkhead.dataset().acquire(resolveIoTarget())) {
|
||||||
result.put(resolveOutputKey("data"), rows);
|
String resultId = chain.getStateInstanceId()
|
||||||
return result;
|
+ ":dataset:"
|
||||||
|
+ UUID.randomUUID();
|
||||||
|
int rowCount =
|
||||||
|
chain.storeProducedLoopInputOutsideLock(
|
||||||
|
resultId,
|
||||||
|
sink -> queryService.consumeBySql(
|
||||||
|
request,
|
||||||
|
QUERY_PAGE_SIZE,
|
||||||
|
sink::accept),
|
||||||
|
0L,
|
||||||
|
chain.currentFencingClaimId(),
|
||||||
|
chain.currentClaimGeneration());
|
||||||
|
result.put(
|
||||||
|
resolveOutputKey("data"),
|
||||||
|
new LoopInputReference(
|
||||||
|
resultId, rowCount));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
TenantManager.restoreTenantCondition();
|
TenantManager.restoreTenantCondition();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据源级 I/O 隔离目标。
|
||||||
|
*
|
||||||
|
* @return 数据源目标键
|
||||||
|
*/
|
||||||
|
private String resolveIoTarget() {
|
||||||
|
return "dataset:"
|
||||||
|
+ (datasetRef == null || datasetRef.getSourceId() == null
|
||||||
|
? "unknown"
|
||||||
|
: datasetRef.getSourceId());
|
||||||
|
}
|
||||||
|
|
||||||
private DatacenterSqlQueryRequest buildRuntimeRequest(Map<String, Object> params) {
|
private DatacenterSqlQueryRequest buildRuntimeRequest(Map<String, Object> params) {
|
||||||
DatacenterSqlQueryRequest request = new DatacenterSqlQueryRequest();
|
DatacenterSqlQueryRequest request = new DatacenterSqlQueryRequest();
|
||||||
request.setDatasetRef(copyDatasetRef());
|
request.setDatasetRef(copyDatasetRef());
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package tech.easyflow.ai.node;
|
||||||
|
|
||||||
|
import org.apache.tika.Tika;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于临时文件的 MultipartFile,供工作流大文件上传路径复用文件流。
|
||||||
|
*/
|
||||||
|
public final class TemporaryFileMultipartFile implements MultipartFile {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TemporaryFileMultipartFile.class);
|
||||||
|
private static final Tika tika = new Tika();
|
||||||
|
|
||||||
|
private final String fileName;
|
||||||
|
private final Path path;
|
||||||
|
private final String contentType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建临时文件上传对象。
|
||||||
|
*
|
||||||
|
* @param fileName 上传文件名
|
||||||
|
* @param path 临时文件路径
|
||||||
|
* @param contentType 已知媒体类型,可为空
|
||||||
|
*/
|
||||||
|
public TemporaryFileMultipartFile(String fileName, Path path, String contentType) {
|
||||||
|
this.fileName = Objects.requireNonNull(fileName, "fileName 不能为空");
|
||||||
|
this.path = Objects.requireNonNull(path, "path 不能为空");
|
||||||
|
this.contentType = contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表单字段名。
|
||||||
|
*
|
||||||
|
* @return 表单字段名
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取原始文件名。
|
||||||
|
*
|
||||||
|
* @return 原始文件名
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String getOriginalFilename() {
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取媒体类型;响应未提供时从临时文件检测。
|
||||||
|
*
|
||||||
|
* @return 媒体类型,检测失败时返回空字符串
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String getContentType() {
|
||||||
|
if (contentType != null && !contentType.isBlank()) {
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return tika.detect(path);
|
||||||
|
} catch (IOException exception) {
|
||||||
|
log.warn("检测工作流临时文件媒体类型失败,path={}", path, exception);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断文件是否为空。
|
||||||
|
*
|
||||||
|
* @return 文件为空时返回 true
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return getSize() == 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件大小。
|
||||||
|
*
|
||||||
|
* @return 文件字节数
|
||||||
|
* @throws IllegalStateException 无法读取文件元数据时抛出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public long getSize() {
|
||||||
|
try {
|
||||||
|
return Files.size(path);
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new IllegalStateException("读取工作流临时文件大小失败", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取完整字节数组,兼容仅支持字节数组的存储后端。
|
||||||
|
*
|
||||||
|
* @return 文件字节
|
||||||
|
* @throws IOException 读取失败时抛出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public byte[] getBytes() throws IOException {
|
||||||
|
return Files.readAllBytes(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开文件输入流。
|
||||||
|
*
|
||||||
|
* @return 文件输入流
|
||||||
|
* @throws IOException 打开失败时抛出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public InputStream getInputStream() throws IOException {
|
||||||
|
return Files.newInputStream(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将临时文件复制到目标位置。
|
||||||
|
*
|
||||||
|
* @param destination 目标文件
|
||||||
|
* @throws IOException 复制失败时抛出
|
||||||
|
* @throws IllegalStateException 目标不可写时抛出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void transferTo(File destination) throws IOException, IllegalStateException {
|
||||||
|
Files.copy(path, destination.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,12 @@ import tech.easyflow.common.util.SpringContextUtil;
|
|||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在独立子工作流执行通道中同步执行子工作流。
|
||||||
|
*/
|
||||||
public class WorkflowNode extends BaseNode {
|
public class WorkflowNode extends BaseNode {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
|
||||||
private String workflowId;
|
private String workflowId;
|
||||||
|
|
||||||
@@ -20,17 +25,27 @@ public class WorkflowNode extends BaseNode {
|
|||||||
this.workflowId = workflowId;
|
this.workflowId = workflowId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行子流程并返回其业务结果。
|
||||||
|
*
|
||||||
|
* @param chain 父工作流
|
||||||
|
* @return 子流程完成结果,或保持当前节点运行的控制结果
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> execute(Chain chain) {
|
public Map<String, Object> execute(Chain chain) {
|
||||||
|
Map<String, Object> params =
|
||||||
Map<String, Object> params = chain.getState().resolveParameters(this);
|
chain.getExecutionState()
|
||||||
WorkflowService service = SpringContextUtil.getBean(WorkflowService.class);
|
.resolveParameters(this);
|
||||||
|
WorkflowService service =
|
||||||
|
SpringContextUtil.getBean(WorkflowService.class);
|
||||||
Workflow workflow = service.getById(workflowId);
|
Workflow workflow = service.getById(workflowId);
|
||||||
if (workflow == null) {
|
if (workflow == null) {
|
||||||
throw new RuntimeException("工作流不存在:" + workflowId);
|
throw new RuntimeException("工作流不存在:" + workflowId);
|
||||||
}
|
}
|
||||||
ChainExecutor executor = SpringContextUtil.getBean(ChainExecutor.class);
|
ChainExecutor executor =
|
||||||
return executor.execute(workflowId, params);
|
SpringContextUtil.getBean(ChainExecutor.class);
|
||||||
|
return executor.executeChild(
|
||||||
|
workflowId, params, chain, this.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getWorkflowId() {
|
public String getWorkflowId() {
|
||||||
|
|||||||
@@ -11,5 +11,19 @@ import tech.easyflow.ai.entity.WorkflowExecResult;
|
|||||||
*/
|
*/
|
||||||
public interface WorkflowExecResultService extends IService<WorkflowExecResult> {
|
public interface WorkflowExecResultService extends IService<WorkflowExecResult> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据稳定执行键查询记录。
|
||||||
|
*
|
||||||
|
* @param execKey 执行键
|
||||||
|
* @return 执行记录;不存在时为 {@code null}
|
||||||
|
*/
|
||||||
WorkflowExecResult getByExecKey(String execKey);
|
WorkflowExecResult getByExecKey(String execKey);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据稳定执行键更新非空审计字段。
|
||||||
|
*
|
||||||
|
* @param record 包含执行键和待更新字段的记录
|
||||||
|
* @return 受影响行数
|
||||||
|
*/
|
||||||
|
int updateByExecKey(WorkflowExecResult record);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,19 @@ import tech.easyflow.ai.entity.WorkflowExecStep;
|
|||||||
*/
|
*/
|
||||||
public interface WorkflowExecStepService extends IService<WorkflowExecStep> {
|
public interface WorkflowExecStepService extends IService<WorkflowExecStep> {
|
||||||
|
|
||||||
// 根据 execKey 获取记录
|
/**
|
||||||
|
* 根据稳定执行键查询步骤。
|
||||||
|
*
|
||||||
|
* @param execKey 执行键
|
||||||
|
* @return 执行步骤;不存在时为 {@code null}
|
||||||
|
*/
|
||||||
WorkflowExecStep getByExecKey(String execKey);
|
WorkflowExecStep getByExecKey(String execKey);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据稳定执行键更新非空审计字段。
|
||||||
|
*
|
||||||
|
* @param step 包含执行键和待更新字段的步骤
|
||||||
|
* @return 受影响行数
|
||||||
|
*/
|
||||||
|
int updateByExecKey(WorkflowExecStep step);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,17 @@ public class WorkflowExecResultServiceImpl extends ServiceImpl<WorkflowExecResul
|
|||||||
w.eq(WorkflowExecResult::getExecKey, execKey);
|
w.eq(WorkflowExecResult::getExecKey, execKey);
|
||||||
return getOne(w);
|
return getOne(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public int updateByExecKey(WorkflowExecResult record) {
|
||||||
|
if (record == null || record.getExecKey() == null) {
|
||||||
|
throw new IllegalArgumentException("execKey is required");
|
||||||
|
}
|
||||||
|
QueryWrapper wrapper = QueryWrapper.create()
|
||||||
|
.eq(WorkflowExecResult::getExecKey, record.getExecKey());
|
||||||
|
return getMapper().updateByQuery(record, wrapper);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,17 @@ public class WorkflowExecStepServiceImpl extends ServiceImpl<WorkflowExecStepMap
|
|||||||
w.eq(WorkflowExecStep::getExecKey, execKey);
|
w.eq(WorkflowExecStep::getExecKey, execKey);
|
||||||
return getOne(w);
|
return getOne(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public int updateByExecKey(WorkflowExecStep step) {
|
||||||
|
if (step == null || step.getExecKey() == null) {
|
||||||
|
throw new IllegalArgumentException("execKey is required");
|
||||||
|
}
|
||||||
|
QueryWrapper wrapper = QueryWrapper.create()
|
||||||
|
.eq(WorkflowExecStep::getExecKey, step.getExecKey());
|
||||||
|
return getMapper().updateByQuery(step, wrapper);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ import tech.easyflow.ai.mapper.WorkflowMapper;
|
|||||||
import tech.easyflow.ai.service.WorkflowService;
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.event.WorkflowDefinitionChangedEvent;
|
||||||
import tech.easyflow.ai.utils.RegexUtils;
|
import tech.easyflow.ai.utils.RegexUtils;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.ai.utils.CustomBeanUtils;
|
import tech.easyflow.ai.utils.CustomBeanUtils;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -25,6 +29,9 @@ import java.util.Map;
|
|||||||
@Service
|
@Service
|
||||||
public class WorkflowServiceImpl extends ServiceImpl<WorkflowMapper, Workflow> implements WorkflowService {
|
public class WorkflowServiceImpl extends ServiceImpl<WorkflowMapper, Workflow> implements WorkflowService {
|
||||||
|
|
||||||
|
@javax.annotation.Resource
|
||||||
|
private ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据别名或 id 查询详情
|
* 根据别名或 id 查询详情
|
||||||
*/
|
*/
|
||||||
@@ -127,7 +134,9 @@ public class WorkflowServiceImpl extends ServiceImpl<WorkflowMapper, Workflow> i
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return super.updateById(workFlow,false);
|
boolean updated = super.updateById(workFlow,false);
|
||||||
|
publishDefinitionChanged(updated, workFlow.getId());
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,13 +150,61 @@ public class WorkflowServiceImpl extends ServiceImpl<WorkflowMapper, Workflow> i
|
|||||||
Date modified,
|
Date modified,
|
||||||
BigInteger modifiedBy
|
BigInteger modifiedBy
|
||||||
) {
|
) {
|
||||||
return getMapper().updateContentByRevision(
|
boolean updated = getMapper().updateContentByRevision(
|
||||||
id,
|
id,
|
||||||
content,
|
content,
|
||||||
expectedRevision,
|
expectedRevision,
|
||||||
modified,
|
modified,
|
||||||
modifiedBy
|
modifiedBy
|
||||||
) == 1;
|
) == 1;
|
||||||
|
publishDefinitionChanged(updated, id);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除工作流后使编译定义缓存失效。
|
||||||
|
*
|
||||||
|
* @param id 工作流 ID
|
||||||
|
* @return 删除成功时为 true
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean removeById(Serializable id) {
|
||||||
|
boolean removed = super.removeById(id);
|
||||||
|
if (removed && id != null && eventPublisher != null) {
|
||||||
|
eventPublisher.publishEvent(new WorkflowDefinitionChangedEvent(String.valueOf(id)));
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除工作流后使对应编译定义缓存失效。
|
||||||
|
*
|
||||||
|
* @param ids 工作流 ID 集合
|
||||||
|
* @return 删除成功时为 true
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean removeByIds(Collection<? extends Serializable> ids) {
|
||||||
|
boolean removed = super.removeByIds(ids);
|
||||||
|
if (removed && ids != null && eventPublisher != null) {
|
||||||
|
for (Serializable id : ids) {
|
||||||
|
if (id != null) {
|
||||||
|
eventPublisher.publishEvent(new WorkflowDefinitionChangedEvent(String.valueOf(id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在工作流变更成功后发布定义失效事件。
|
||||||
|
*
|
||||||
|
* @param changed 是否已发生变更
|
||||||
|
* @param workflowId 工作流 ID
|
||||||
|
*/
|
||||||
|
private void publishDefinitionChanged(boolean changed, BigInteger workflowId) {
|
||||||
|
if (changed && workflowId != null && eventPublisher != null) {
|
||||||
|
eventPublisher.publishEvent(new WorkflowDefinitionChangedEvent(String.valueOf(workflowId)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import okhttp3.Call;
|
|||||||
import okhttp3.OkHttpClient;
|
import okhttp3.OkHttpClient;
|
||||||
import okhttp3.Request;
|
import okhttp3.Request;
|
||||||
import okhttp3.Response;
|
import okhttp3.Response;
|
||||||
|
import okhttp3.ResponseBody;
|
||||||
import org.apache.poi.extractor.ExtractorFactory;
|
import org.apache.poi.extractor.ExtractorFactory;
|
||||||
import org.apache.poi.extractor.POITextExtractor;
|
import org.apache.poi.extractor.POITextExtractor;
|
||||||
import org.apache.pdfbox.multipdf.Splitter;
|
import org.apache.pdfbox.multipdf.Splitter;
|
||||||
@@ -24,6 +25,10 @@ import java.io.ByteArrayInputStream;
|
|||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -52,6 +57,103 @@ public class DocUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将远程文件流式下载到临时文件,避免在工作流热路径持有整文件字节数组。
|
||||||
|
*
|
||||||
|
* @param url 远程文件地址
|
||||||
|
* @param maxBytes 最大允许字节数,小于等于 0 时不限制
|
||||||
|
* @return 可自动清理的临时下载结果
|
||||||
|
* @throws RuntimeException 下载失败或文件超过限制时抛出
|
||||||
|
*/
|
||||||
|
public static DownloadedFile downloadFileToTemp(String url, long maxBytes) {
|
||||||
|
Request request = new Request.Builder().url(url).build();
|
||||||
|
OkHttpClient client = OkHttpClientUtil.buildDefaultClient();
|
||||||
|
Path tempFile = null;
|
||||||
|
// 共享客户端拦截器覆盖完整响应生命周期,避免同一下载重复领取 I/O 许可。
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
ResponseBody body = response.body();
|
||||||
|
if (body == null) {
|
||||||
|
throw new IOException("下载内容为空");
|
||||||
|
}
|
||||||
|
long contentLength = body.contentLength();
|
||||||
|
if (maxBytes > 0 && contentLength > maxBytes) {
|
||||||
|
throw new IOException("下载文件超过限制: " + maxBytes + " bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
tempFile = Files.createTempFile("easyflow-workflow-download-", ".tmp");
|
||||||
|
long size = copyWithLimit(body.byteStream(), tempFile, maxBytes);
|
||||||
|
String contentType = body.contentType() == null ? null : body.contentType().toString();
|
||||||
|
return new DownloadedFile(tempFile, size, contentType);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
deleteTempFile(tempFile);
|
||||||
|
log.error("下载文件失败:", exception);
|
||||||
|
throw new RuntimeException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将输入流复制到临时文件,并在复制过程中执行大小保护。
|
||||||
|
*
|
||||||
|
* @param inputStream 输入流
|
||||||
|
* @param target 目标临时文件
|
||||||
|
* @param maxBytes 最大允许字节数,小于等于 0 时不限制
|
||||||
|
* @return 实际复制字节数
|
||||||
|
* @throws IOException 读写失败或超出限制时抛出
|
||||||
|
*/
|
||||||
|
private static long copyWithLimit(InputStream inputStream, Path target, long maxBytes) throws IOException {
|
||||||
|
long total = 0L;
|
||||||
|
byte[] buffer = new byte[64 * 1024];
|
||||||
|
try (InputStream input = inputStream;
|
||||||
|
OutputStream output = Files.newOutputStream(
|
||||||
|
target,
|
||||||
|
StandardOpenOption.WRITE,
|
||||||
|
StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||||
|
int read;
|
||||||
|
while ((read = input.read(buffer)) != -1) {
|
||||||
|
if (maxBytes > 0 && total > maxBytes - read) {
|
||||||
|
throw new IOException("下载文件超过限制: " + maxBytes + " bytes");
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, read);
|
||||||
|
total += read;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尽力删除下载临时文件。
|
||||||
|
*
|
||||||
|
* @param path 临时文件路径
|
||||||
|
*/
|
||||||
|
private static void deleteTempFile(Path path) {
|
||||||
|
if (path == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(path);
|
||||||
|
} catch (IOException exception) {
|
||||||
|
log.warn("清理工作流下载临时文件失败,path={}", path, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流流式下载结果。
|
||||||
|
*
|
||||||
|
* @param path 临时文件路径
|
||||||
|
* @param size 文件字节数
|
||||||
|
* @param contentType 响应媒体类型
|
||||||
|
*/
|
||||||
|
public record DownloadedFile(Path path, long size, String contentType) implements AutoCloseable {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除临时文件。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
deleteTempFile(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static String readWordFile(String suffix, InputStream is) {
|
public static String readWordFile(String suffix, InputStream is) {
|
||||||
String content = "";
|
String content = "";
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ public class WorkFlowUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static LoginAccount getOperator(Chain chain) {
|
public static LoginAccount getOperator(Chain chain) {
|
||||||
Object cache = chain.getState().getMemory().get(Constants.LOGIN_USER_KEY);
|
Object cache = chain.getExecutionState()
|
||||||
|
.getMemory()
|
||||||
|
.get(Constants.LOGIN_USER_KEY);
|
||||||
return cache == null ? defaultAccount() : (LoginAccount) cache;
|
return cache == null ? defaultAccount() : (LoginAccount) cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +46,9 @@ public class WorkFlowUtil {
|
|||||||
* @return 执行人标识
|
* @return 执行人标识
|
||||||
*/
|
*/
|
||||||
public static String getCreatedKey(Chain chain) {
|
public static String getCreatedKey(Chain chain) {
|
||||||
Object value = chain.getState().getMemory().get(CREATED_KEY_MEMORY_KEY);
|
Object value = chain.getExecutionState()
|
||||||
|
.getMemory()
|
||||||
|
.get(CREATED_KEY_MEMORY_KEY);
|
||||||
return value == null ? USER_KEY : String.valueOf(value);
|
return value == null ? USER_KEY : String.valueOf(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,747 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.event;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.easyagents.flow.core.chain.repository.InMemoryLoopResultRepository;
|
||||||
|
import com.easyagents.flow.core.chain.repository.LoopInputReference;
|
||||||
|
import com.easyagents.flow.core.chain.repository.LoopResultReference;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||||
|
import tech.easyflow.ai.entity.WorkflowExecStep;
|
||||||
|
import tech.easyflow.ai.service.WorkflowExecResultService;
|
||||||
|
import tech.easyflow.ai.service.WorkflowExecStepService;
|
||||||
|
import tech.easyflow.common.mq.config.MQProperties;
|
||||||
|
import tech.easyflow.common.mq.core.MQDeadLetterService;
|
||||||
|
import tech.easyflow.common.mq.core.MQMessage;
|
||||||
|
import tech.easyflow.common.mq.core.MQProducer;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Date;
|
||||||
|
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.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流执行审计异步持久化测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowExecutionAuditConsumerTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证存在测试构造器时 Spring 仍能选择生产构造器创建 Bean。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldCreateProducerThroughSpringContext() {
|
||||||
|
MQProducer mqProducer =
|
||||||
|
Mockito.mock(MQProducer.class);
|
||||||
|
MQDeadLetterService deadLetterService =
|
||||||
|
Mockito.mock(
|
||||||
|
MQDeadLetterService.class);
|
||||||
|
try (AnnotationConfigApplicationContext context =
|
||||||
|
new AnnotationConfigApplicationContext()) {
|
||||||
|
context.registerBean(
|
||||||
|
MQProducer.class,
|
||||||
|
() -> mqProducer);
|
||||||
|
context.registerBean(
|
||||||
|
MQDeadLetterService.class,
|
||||||
|
() -> deadLetterService);
|
||||||
|
context.registerBean(
|
||||||
|
WorkflowExecutionAuditProducer.class);
|
||||||
|
context.refresh();
|
||||||
|
|
||||||
|
Assert.assertNotNull(
|
||||||
|
context.getBean(
|
||||||
|
WorkflowExecutionAuditProducer.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证生产者固定投递到单一有序分片。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldPublishAuditEventToOrderedShard() {
|
||||||
|
MQProducer mqProducer = Mockito.mock(MQProducer.class);
|
||||||
|
MQDeadLetterService deadLetterService =
|
||||||
|
Mockito.mock(MQDeadLetterService.class);
|
||||||
|
WorkflowExecutionAuditProducer producer =
|
||||||
|
new WorkflowExecutionAuditProducer(
|
||||||
|
mqProducer, deadLetterService);
|
||||||
|
try {
|
||||||
|
WorkflowExecutionAuditEvent event = event(
|
||||||
|
WorkflowExecutionAuditEvent.Type.CHAIN_STARTED,
|
||||||
|
"instance-1:chain-started",
|
||||||
|
"instance-1",
|
||||||
|
new WorkflowExecResult(),
|
||||||
|
null);
|
||||||
|
|
||||||
|
producer.send(event);
|
||||||
|
|
||||||
|
Mockito.verify(mqProducer).send(Mockito.argThat(message ->
|
||||||
|
WorkflowExecutionAuditMqConstants.TOPIC.equals(
|
||||||
|
message.getTopic())
|
||||||
|
&& "instance-1:chain-started".equals(
|
||||||
|
message.getMessageId())
|
||||||
|
&& "instance-1".equals(message.getKey())
|
||||||
|
&& message.getBody().contains(
|
||||||
|
"CHAIN_STARTED")));
|
||||||
|
} finally {
|
||||||
|
producer.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证结束事件仅携带结束时间时仍可安全序列化投递。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldPublishEndEventsWithoutStartTime() {
|
||||||
|
MQProducer mqProducer =
|
||||||
|
Mockito.mock(MQProducer.class);
|
||||||
|
MQDeadLetterService deadLetterService =
|
||||||
|
Mockito.mock(
|
||||||
|
MQDeadLetterService.class);
|
||||||
|
WorkflowExecutionAuditProducer producer =
|
||||||
|
new WorkflowExecutionAuditProducer(
|
||||||
|
mqProducer,
|
||||||
|
deadLetterService);
|
||||||
|
Mockito.when(mqProducer.send(
|
||||||
|
Mockito.any()))
|
||||||
|
.thenReturn("message-id");
|
||||||
|
try {
|
||||||
|
WorkflowExecStep step =
|
||||||
|
new WorkflowExecStep();
|
||||||
|
step.setExecKey("step-ended");
|
||||||
|
step.setEndTime(new Date());
|
||||||
|
WorkflowExecResult result =
|
||||||
|
new WorkflowExecResult();
|
||||||
|
result.setExecKey("instance-ended");
|
||||||
|
result.setEndTime(new Date());
|
||||||
|
|
||||||
|
producer.send(event(
|
||||||
|
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
|
||||||
|
"step-ended:event",
|
||||||
|
"instance-ended",
|
||||||
|
null,
|
||||||
|
step));
|
||||||
|
producer.send(event(
|
||||||
|
WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
|
||||||
|
"instance-ended:event",
|
||||||
|
"instance-ended",
|
||||||
|
result,
|
||||||
|
null));
|
||||||
|
|
||||||
|
Mockito.verify(
|
||||||
|
mqProducer,
|
||||||
|
Mockito.times(2))
|
||||||
|
.send(Mockito.argThat(message ->
|
||||||
|
message.getBody() != null
|
||||||
|
&& (message.getBody()
|
||||||
|
.contains("NODE_ENDED")
|
||||||
|
|| message.getBody()
|
||||||
|
.contains("CHAIN_ENDED"))));
|
||||||
|
} finally {
|
||||||
|
producer.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证一个实例的毒消息退避不会阻塞其他发送 lane。
|
||||||
|
*
|
||||||
|
* @throws Exception 等待健康实例发送失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldIsolateRetryHeadBlockingAcrossLanes()
|
||||||
|
throws Exception {
|
||||||
|
MQProducer mqProducer =
|
||||||
|
Mockito.mock(MQProducer.class);
|
||||||
|
MQDeadLetterService deadLetterService =
|
||||||
|
Mockito.mock(
|
||||||
|
MQDeadLetterService.class);
|
||||||
|
CountDownLatch healthySent =
|
||||||
|
new CountDownLatch(1);
|
||||||
|
Mockito.when(mqProducer.send(
|
||||||
|
Mockito.any()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
MQMessage message =
|
||||||
|
invocation.getArgument(0);
|
||||||
|
if ("instance-0".equals(
|
||||||
|
message.getKey())) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"poison");
|
||||||
|
}
|
||||||
|
healthySent.countDown();
|
||||||
|
return "sent";
|
||||||
|
});
|
||||||
|
WorkflowExecutionAuditProducer producer =
|
||||||
|
new WorkflowExecutionAuditProducer(
|
||||||
|
mqProducer,
|
||||||
|
deadLetterService,
|
||||||
|
8,
|
||||||
|
100,
|
||||||
|
1024L * 1024L,
|
||||||
|
8L * 1024L * 1024L,
|
||||||
|
0L);
|
||||||
|
try {
|
||||||
|
producer.send(event(
|
||||||
|
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
|
||||||
|
"poison",
|
||||||
|
"instance-0",
|
||||||
|
null,
|
||||||
|
new WorkflowExecStep()));
|
||||||
|
producer.send(event(
|
||||||
|
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
|
||||||
|
"healthy",
|
||||||
|
"instance-1",
|
||||||
|
null,
|
||||||
|
new WorkflowExecStep()));
|
||||||
|
|
||||||
|
Assert.assertTrue(
|
||||||
|
healthySent.await(
|
||||||
|
1L,
|
||||||
|
TimeUnit.SECONDS));
|
||||||
|
} finally {
|
||||||
|
producer.close();
|
||||||
|
}
|
||||||
|
Mockito.verify(deadLetterService)
|
||||||
|
.deadLetter(
|
||||||
|
Mockito.argThat(message ->
|
||||||
|
"poison".equals(
|
||||||
|
message.getMessageId())),
|
||||||
|
Mockito.contains("shutdown"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证同一实例失败恢复后仍按原事件顺序发送。
|
||||||
|
*
|
||||||
|
* @throws Exception 等待重试发送失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldPreserveOrderWithinAuditLane()
|
||||||
|
throws Exception {
|
||||||
|
MQProducer mqProducer =
|
||||||
|
Mockito.mock(MQProducer.class);
|
||||||
|
MQDeadLetterService deadLetterService =
|
||||||
|
Mockito.mock(
|
||||||
|
MQDeadLetterService.class);
|
||||||
|
AtomicInteger firstAttempts =
|
||||||
|
new AtomicInteger();
|
||||||
|
CountDownLatch sent =
|
||||||
|
new CountDownLatch(2);
|
||||||
|
List<String> order =
|
||||||
|
Collections.synchronizedList(
|
||||||
|
new ArrayList<>());
|
||||||
|
Mockito.when(mqProducer.send(
|
||||||
|
Mockito.any()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
MQMessage message =
|
||||||
|
invocation.getArgument(0);
|
||||||
|
if ("first".equals(
|
||||||
|
message.getMessageId())
|
||||||
|
&& firstAttempts
|
||||||
|
.getAndIncrement() == 0) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"temporary");
|
||||||
|
}
|
||||||
|
order.add(
|
||||||
|
message.getMessageId());
|
||||||
|
sent.countDown();
|
||||||
|
return "sent";
|
||||||
|
});
|
||||||
|
WorkflowExecutionAuditProducer producer =
|
||||||
|
new WorkflowExecutionAuditProducer(
|
||||||
|
mqProducer,
|
||||||
|
deadLetterService,
|
||||||
|
2,
|
||||||
|
100,
|
||||||
|
1024L * 1024L,
|
||||||
|
8L * 1024L * 1024L,
|
||||||
|
1000L);
|
||||||
|
try {
|
||||||
|
producer.send(event(
|
||||||
|
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
|
||||||
|
"first",
|
||||||
|
"same-instance",
|
||||||
|
null,
|
||||||
|
new WorkflowExecStep()));
|
||||||
|
producer.send(event(
|
||||||
|
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
|
||||||
|
"second",
|
||||||
|
"same-instance",
|
||||||
|
null,
|
||||||
|
new WorkflowExecStep()));
|
||||||
|
|
||||||
|
Assert.assertTrue(
|
||||||
|
sent.await(
|
||||||
|
2L,
|
||||||
|
TimeUnit.SECONDS));
|
||||||
|
Assert.assertEquals(
|
||||||
|
List.of("first", "second"),
|
||||||
|
order);
|
||||||
|
} finally {
|
||||||
|
producer.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证超出单条字节预算的审计消息直接进入死信并显式失败。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectOversizedAuditMessage() {
|
||||||
|
MQProducer mqProducer =
|
||||||
|
Mockito.mock(MQProducer.class);
|
||||||
|
MQDeadLetterService deadLetterService =
|
||||||
|
Mockito.mock(
|
||||||
|
MQDeadLetterService.class);
|
||||||
|
WorkflowExecutionAuditProducer producer =
|
||||||
|
new WorkflowExecutionAuditProducer(
|
||||||
|
mqProducer,
|
||||||
|
deadLetterService,
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
512L,
|
||||||
|
2048L,
|
||||||
|
0L);
|
||||||
|
WorkflowExecResult result =
|
||||||
|
new WorkflowExecResult();
|
||||||
|
result.setOutput(
|
||||||
|
"x".repeat(1024));
|
||||||
|
try {
|
||||||
|
producer.send(event(
|
||||||
|
WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
|
||||||
|
"oversized",
|
||||||
|
"instance",
|
||||||
|
result,
|
||||||
|
null));
|
||||||
|
Assert.fail(
|
||||||
|
"oversized audit message should fail");
|
||||||
|
} catch (IllegalArgumentException expected) {
|
||||||
|
Assert.assertTrue(
|
||||||
|
expected.getMessage()
|
||||||
|
.contains("byte limit"));
|
||||||
|
} finally {
|
||||||
|
producer.close();
|
||||||
|
}
|
||||||
|
Mockito.verify(
|
||||||
|
deadLetterService)
|
||||||
|
.deadLetter(
|
||||||
|
Mockito.argThat(message ->
|
||||||
|
"oversized".equals(
|
||||||
|
message.getMessageId())),
|
||||||
|
Mockito.contains("byte limit"));
|
||||||
|
Mockito.verifyNoInteractions(
|
||||||
|
mqProducer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证关闭期间仍在直发的失败消息会转入死信且不会重新形成孤儿积压。
|
||||||
|
*
|
||||||
|
* @throws Exception 并发关闭、等待或反射读取失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldNotEnqueueAfterConcurrentClose()
|
||||||
|
throws Exception {
|
||||||
|
MQProducer mqProducer =
|
||||||
|
Mockito.mock(MQProducer.class);
|
||||||
|
MQDeadLetterService deadLetterService =
|
||||||
|
Mockito.mock(
|
||||||
|
MQDeadLetterService.class);
|
||||||
|
CountDownLatch sending =
|
||||||
|
new CountDownLatch(1);
|
||||||
|
CountDownLatch releaseSend =
|
||||||
|
new CountDownLatch(1);
|
||||||
|
Mockito.when(mqProducer.send(
|
||||||
|
Mockito.any()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
sending.countDown();
|
||||||
|
releaseSend.await(
|
||||||
|
2L,
|
||||||
|
TimeUnit.SECONDS);
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"send failed during close");
|
||||||
|
});
|
||||||
|
WorkflowExecutionAuditProducer producer =
|
||||||
|
new WorkflowExecutionAuditProducer(
|
||||||
|
mqProducer,
|
||||||
|
deadLetterService,
|
||||||
|
1,
|
||||||
|
100,
|
||||||
|
1024L * 1024L,
|
||||||
|
8L * 1024L * 1024L,
|
||||||
|
0L);
|
||||||
|
ExecutorService callers =
|
||||||
|
Executors.newFixedThreadPool(2);
|
||||||
|
try {
|
||||||
|
Future<?> sender =
|
||||||
|
callers.submit(() -> {
|
||||||
|
try {
|
||||||
|
producer.send(event(
|
||||||
|
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
|
||||||
|
"closing",
|
||||||
|
"instance",
|
||||||
|
null,
|
||||||
|
new WorkflowExecStep()));
|
||||||
|
Assert.fail(
|
||||||
|
"send should report concurrent close");
|
||||||
|
} catch (IllegalStateException expected) {
|
||||||
|
Assert.assertTrue(
|
||||||
|
expected.getMessage()
|
||||||
|
.contains("closed"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Assert.assertTrue(
|
||||||
|
sending.await(
|
||||||
|
1L,
|
||||||
|
TimeUnit.SECONDS));
|
||||||
|
Future<?> closer =
|
||||||
|
callers.submit(
|
||||||
|
producer::close);
|
||||||
|
closer.get(
|
||||||
|
1L,
|
||||||
|
TimeUnit.SECONDS);
|
||||||
|
releaseSend.countDown();
|
||||||
|
sender.get(
|
||||||
|
2L,
|
||||||
|
TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
Field backlogCountField =
|
||||||
|
WorkflowExecutionAuditProducer.class
|
||||||
|
.getDeclaredField(
|
||||||
|
"backlogCount");
|
||||||
|
backlogCountField.setAccessible(true);
|
||||||
|
Assert.assertEquals(
|
||||||
|
0,
|
||||||
|
backlogCountField.getInt(
|
||||||
|
producer));
|
||||||
|
Mockito.verify(deadLetterService)
|
||||||
|
.deadLetter(
|
||||||
|
Mockito.argThat(message ->
|
||||||
|
"closing".equals(
|
||||||
|
message.getMessageId())),
|
||||||
|
Mockito.contains(
|
||||||
|
"closed during send"));
|
||||||
|
} finally {
|
||||||
|
releaseSend.countDown();
|
||||||
|
producer.close();
|
||||||
|
callers.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证启动、节点开始、节点结束和流程结束事件按顺序幂等落库。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldApplyOrderedExecutionAuditEvents() {
|
||||||
|
WorkflowExecResultService resultService =
|
||||||
|
Mockito.mock(WorkflowExecResultService.class);
|
||||||
|
WorkflowExecStepService stepService =
|
||||||
|
Mockito.mock(WorkflowExecStepService.class);
|
||||||
|
InMemoryLoopResultRepository loopRepository =
|
||||||
|
new InMemoryLoopResultRepository();
|
||||||
|
WorkflowExecutionAuditConsumer consumer =
|
||||||
|
new WorkflowExecutionAuditConsumer(
|
||||||
|
resultService,
|
||||||
|
stepService,
|
||||||
|
new MQProperties(),
|
||||||
|
loopRepository);
|
||||||
|
|
||||||
|
WorkflowExecResult persistedResult = new WorkflowExecResult();
|
||||||
|
persistedResult.setId(BigInteger.ONE);
|
||||||
|
persistedResult.setExecKey("instance-1");
|
||||||
|
Mockito.when(resultService.getByExecKey("instance-1"))
|
||||||
|
.thenReturn(persistedResult);
|
||||||
|
Mockito.when(resultService.updateByExecKey(Mockito.any()))
|
||||||
|
.thenReturn(1);
|
||||||
|
Mockito.when(stepService.updateByExecKey(Mockito.any()))
|
||||||
|
.thenReturn(1);
|
||||||
|
|
||||||
|
WorkflowExecResult startRecord = new WorkflowExecResult();
|
||||||
|
startRecord.setExecKey("instance-1");
|
||||||
|
startRecord.setStatus(1);
|
||||||
|
WorkflowExecStep startStep = new WorkflowExecStep();
|
||||||
|
startStep.setExecKey("step-1");
|
||||||
|
startStep.setNodeId("node-1");
|
||||||
|
startStep.setNodeName("node");
|
||||||
|
startStep.setStatus(1);
|
||||||
|
WorkflowExecStep endStep = new WorkflowExecStep();
|
||||||
|
endStep.setExecKey("step-1");
|
||||||
|
endStep.setStatus(2);
|
||||||
|
endStep.setOutput("{\"value\":1}");
|
||||||
|
WorkflowExecResult endRecord = new WorkflowExecResult();
|
||||||
|
endRecord.setExecKey("instance-1");
|
||||||
|
endRecord.setStatus(2);
|
||||||
|
endRecord.setOutput("{\"value\":1}");
|
||||||
|
|
||||||
|
consumer.handle(List.of(
|
||||||
|
message(event(WorkflowExecutionAuditEvent.Type.CHAIN_STARTED,
|
||||||
|
"start", "instance-1", startRecord, null)),
|
||||||
|
message(event(WorkflowExecutionAuditEvent.Type.NODE_STARTED,
|
||||||
|
"node-start", "instance-1", null, startStep)),
|
||||||
|
message(event(WorkflowExecutionAuditEvent.Type.NODE_ENDED,
|
||||||
|
"node-end", "instance-1", null, endStep)),
|
||||||
|
message(event(WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
|
||||||
|
"end", "instance-1", endRecord, null))
|
||||||
|
));
|
||||||
|
|
||||||
|
Mockito.verify(resultService).save(Mockito.argThat(record ->
|
||||||
|
"instance-1".equals(record.getExecKey())
|
||||||
|
&& Integer.valueOf(1).equals(record.getStatus())));
|
||||||
|
Mockito.verify(stepService).save(Mockito.argThat(step ->
|
||||||
|
"step-1".equals(step.getExecKey())
|
||||||
|
&& BigInteger.ONE.equals(step.getRecordId())));
|
||||||
|
Mockito.verify(stepService).updateByExecKey(
|
||||||
|
Mockito.argThat(step ->
|
||||||
|
"step-1".equals(step.getExecKey())
|
||||||
|
&& "{\"value\":1}".equals(
|
||||||
|
step.getOutput())));
|
||||||
|
Mockito.verify(resultService).updateByExecKey(
|
||||||
|
Mockito.argThat(record ->
|
||||||
|
"instance-1".equals(record.getExecKey())
|
||||||
|
&& "{\"value\":1}".equals(
|
||||||
|
record.getOutput())));
|
||||||
|
Mockito.verify(stepService, Mockito.never())
|
||||||
|
.getByExecKey(Mockito.anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证大型结果引用在审计消费线程还原,持久记录仍保持完整 JSON。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldResolveLargeReferenceInAuditConsumer() {
|
||||||
|
WorkflowExecResultService resultService =
|
||||||
|
Mockito.mock(
|
||||||
|
WorkflowExecResultService.class);
|
||||||
|
WorkflowExecStepService stepService =
|
||||||
|
Mockito.mock(
|
||||||
|
WorkflowExecStepService.class);
|
||||||
|
InMemoryLoopResultRepository loopRepository =
|
||||||
|
new InMemoryLoopResultRepository();
|
||||||
|
String resultId = "instance:dataset:rows";
|
||||||
|
loopRepository.storeInput(
|
||||||
|
resultId, List.of(1, 2, 3));
|
||||||
|
WorkflowExecutionAuditConsumer consumer =
|
||||||
|
new WorkflowExecutionAuditConsumer(
|
||||||
|
resultService,
|
||||||
|
stepService,
|
||||||
|
new MQProperties(),
|
||||||
|
loopRepository);
|
||||||
|
Mockito.when(stepService.updateByExecKey(
|
||||||
|
Mockito.any()))
|
||||||
|
.thenReturn(1);
|
||||||
|
WorkflowExecStep incoming =
|
||||||
|
new WorkflowExecStep();
|
||||||
|
incoming.setExecKey("step-reference");
|
||||||
|
incoming.setOutput(JSON.toJSONString(
|
||||||
|
Map.of(
|
||||||
|
"data",
|
||||||
|
new LoopInputReference(
|
||||||
|
resultId, 3))));
|
||||||
|
|
||||||
|
consumer.handle(List.of(message(event(
|
||||||
|
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
|
||||||
|
"node-reference",
|
||||||
|
"instance",
|
||||||
|
null,
|
||||||
|
incoming))));
|
||||||
|
|
||||||
|
Mockito.verify(stepService).updateByExecKey(
|
||||||
|
Mockito.argThat(step ->
|
||||||
|
"{\"data\":[1,2,3]}"
|
||||||
|
.equals(step.getOutput())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证节点启动输入引用在审计消费者中还原后再保存。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldResolveLargeInputReferenceWhenCreatingStep() {
|
||||||
|
WorkflowExecResultService resultService =
|
||||||
|
Mockito.mock(
|
||||||
|
WorkflowExecResultService.class);
|
||||||
|
WorkflowExecStepService stepService =
|
||||||
|
Mockito.mock(
|
||||||
|
WorkflowExecStepService.class);
|
||||||
|
InMemoryLoopResultRepository loopRepository =
|
||||||
|
new InMemoryLoopResultRepository();
|
||||||
|
String resultId = "instance:dataset:input";
|
||||||
|
loopRepository.storeInput(
|
||||||
|
resultId, List.of(1, 2, 3));
|
||||||
|
WorkflowExecutionAuditConsumer consumer =
|
||||||
|
new WorkflowExecutionAuditConsumer(
|
||||||
|
resultService,
|
||||||
|
stepService,
|
||||||
|
new MQProperties(),
|
||||||
|
loopRepository);
|
||||||
|
WorkflowExecResult record =
|
||||||
|
new WorkflowExecResult();
|
||||||
|
record.setId(BigInteger.ONE);
|
||||||
|
Mockito.when(resultService.getByExecKey(
|
||||||
|
"instance"))
|
||||||
|
.thenReturn(record);
|
||||||
|
WorkflowExecStep incoming =
|
||||||
|
new WorkflowExecStep();
|
||||||
|
incoming.setExecKey("step-input");
|
||||||
|
incoming.setInput(JSON.toJSONString(
|
||||||
|
Map.of(
|
||||||
|
"items",
|
||||||
|
new LoopInputReference(
|
||||||
|
resultId, 3))));
|
||||||
|
|
||||||
|
consumer.handle(List.of(message(event(
|
||||||
|
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
|
||||||
|
"node-input",
|
||||||
|
"instance",
|
||||||
|
null,
|
||||||
|
incoming))));
|
||||||
|
|
||||||
|
Mockito.verify(stepService).save(
|
||||||
|
Mockito.argThat(step ->
|
||||||
|
BigInteger.ONE.equals(
|
||||||
|
step.getRecordId())
|
||||||
|
&& "{\"items\":[1,2,3]}"
|
||||||
|
.equals(step.getInput())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证节点结束与流程结束审计均还原循环累计输出。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public void shouldResolveLoopResultReferenceForEndedAudits() {
|
||||||
|
WorkflowExecResultService resultService =
|
||||||
|
Mockito.mock(
|
||||||
|
WorkflowExecResultService.class);
|
||||||
|
WorkflowExecStepService stepService =
|
||||||
|
Mockito.mock(
|
||||||
|
WorkflowExecStepService.class);
|
||||||
|
InMemoryLoopResultRepository loopRepository =
|
||||||
|
new InMemoryLoopResultRepository();
|
||||||
|
String resultId = "instance:loop:result";
|
||||||
|
loopRepository.append(
|
||||||
|
resultId,
|
||||||
|
0,
|
||||||
|
Map.of("answer", "first"));
|
||||||
|
loopRepository.append(
|
||||||
|
resultId,
|
||||||
|
1,
|
||||||
|
Map.of("answer", "second"));
|
||||||
|
WorkflowExecutionAuditConsumer consumer =
|
||||||
|
new WorkflowExecutionAuditConsumer(
|
||||||
|
resultService,
|
||||||
|
stepService,
|
||||||
|
new MQProperties(),
|
||||||
|
loopRepository);
|
||||||
|
Mockito.when(stepService.updateByExecKey(
|
||||||
|
Mockito.any()))
|
||||||
|
.thenReturn(1);
|
||||||
|
Mockito.when(resultService.updateByExecKey(
|
||||||
|
Mockito.any()))
|
||||||
|
.thenReturn(1);
|
||||||
|
Map<String, Object> referenceOutput =
|
||||||
|
Map.of(
|
||||||
|
"answers",
|
||||||
|
new LoopResultReference(
|
||||||
|
resultId,
|
||||||
|
2,
|
||||||
|
"answer"));
|
||||||
|
WorkflowExecStep incomingStep =
|
||||||
|
new WorkflowExecStep();
|
||||||
|
incomingStep.setExecKey("step-loop");
|
||||||
|
incomingStep.setOutput(
|
||||||
|
JSON.toJSONString(
|
||||||
|
referenceOutput));
|
||||||
|
WorkflowExecResult incomingResult =
|
||||||
|
new WorkflowExecResult();
|
||||||
|
incomingResult.setExecKey(
|
||||||
|
"instance-loop");
|
||||||
|
incomingResult.setOutput(
|
||||||
|
JSON.toJSONString(
|
||||||
|
referenceOutput));
|
||||||
|
|
||||||
|
consumer.handle(List.of(
|
||||||
|
message(event(
|
||||||
|
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
|
||||||
|
"node-loop-ended",
|
||||||
|
"instance-loop",
|
||||||
|
null,
|
||||||
|
incomingStep)),
|
||||||
|
message(event(
|
||||||
|
WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
|
||||||
|
"chain-loop-ended",
|
||||||
|
"instance-loop",
|
||||||
|
incomingResult,
|
||||||
|
null))));
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<WorkflowExecStep>
|
||||||
|
stepCaptor =
|
||||||
|
org.mockito.ArgumentCaptor.forClass(
|
||||||
|
WorkflowExecStep.class);
|
||||||
|
org.mockito.ArgumentCaptor<WorkflowExecResult>
|
||||||
|
resultCaptor =
|
||||||
|
org.mockito.ArgumentCaptor.forClass(
|
||||||
|
WorkflowExecResult.class);
|
||||||
|
Mockito.verify(stepService)
|
||||||
|
.updateByExecKey(stepCaptor.capture());
|
||||||
|
Mockito.verify(resultService)
|
||||||
|
.updateByExecKey(resultCaptor.capture());
|
||||||
|
Map<String, Object> stepOutput =
|
||||||
|
JSON.parseObject(
|
||||||
|
stepCaptor.getValue().getOutput(),
|
||||||
|
Map.class);
|
||||||
|
Map<String, Object> resultOutput =
|
||||||
|
JSON.parseObject(
|
||||||
|
resultCaptor.getValue().getOutput(),
|
||||||
|
Map.class);
|
||||||
|
Assert.assertEquals(
|
||||||
|
List.of("first", "second"),
|
||||||
|
stepOutput.get("answers"));
|
||||||
|
Assert.assertEquals(
|
||||||
|
List.of("first", "second"),
|
||||||
|
resultOutput.get("answers"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造审计事件。
|
||||||
|
*
|
||||||
|
* @param type 事件类型
|
||||||
|
* @param eventId 事件 ID
|
||||||
|
* @param instanceId 实例 ID
|
||||||
|
* @param result 工作流记录
|
||||||
|
* @param step 节点步骤
|
||||||
|
* @return 审计事件
|
||||||
|
*/
|
||||||
|
private WorkflowExecutionAuditEvent event(WorkflowExecutionAuditEvent.Type type,
|
||||||
|
String eventId,
|
||||||
|
String instanceId,
|
||||||
|
WorkflowExecResult result,
|
||||||
|
WorkflowExecStep step) {
|
||||||
|
WorkflowExecutionAuditEvent event = new WorkflowExecutionAuditEvent();
|
||||||
|
event.setType(type);
|
||||||
|
event.setEventId(eventId);
|
||||||
|
event.setInstanceId(instanceId);
|
||||||
|
event.setOccurredAt(new Date());
|
||||||
|
event.setResult(result);
|
||||||
|
event.setStep(step);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将审计事件包装为通用 MQ 消息。
|
||||||
|
*
|
||||||
|
* @param event 审计事件
|
||||||
|
* @return MQ 消息
|
||||||
|
*/
|
||||||
|
private MQMessage message(WorkflowExecutionAuditEvent event) {
|
||||||
|
MQMessage message = new MQMessage();
|
||||||
|
message.setMessageId(event.getEventId());
|
||||||
|
message.setBody(JSON.toJSONString(event));
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.listener;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.Chain;
|
||||||
|
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||||
|
import com.easyagents.flow.core.chain.ChainState;
|
||||||
|
import com.easyagents.flow.core.chain.NodeStatus;
|
||||||
|
import com.easyagents.flow.core.chain.event.NodeEndEvent;
|
||||||
|
import com.easyagents.flow.core.chain.event.NodeStartEvent;
|
||||||
|
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
|
||||||
|
import com.easyagents.flow.core.node.StartNode;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditEvent;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditProducer;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link ChainEventListenerForSave} 节点审计归属回归测试。
|
||||||
|
*/
|
||||||
|
public class ChainEventListenerForSaveTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 parent-linked 节点开始与结束事件使用同一顶级实例顺序键。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldUseSameRootInstanceForNodeStartAndEnd()
|
||||||
|
throws Exception {
|
||||||
|
String suffix =
|
||||||
|
UUID.randomUUID().toString();
|
||||||
|
String rootId =
|
||||||
|
"root-" + suffix;
|
||||||
|
String childId =
|
||||||
|
"child-" + suffix;
|
||||||
|
InMemoryChainStateRepository repository =
|
||||||
|
new InMemoryChainStateRepository();
|
||||||
|
ChainState root =
|
||||||
|
repository.create(rootId);
|
||||||
|
ChainState child =
|
||||||
|
repository.create(childId);
|
||||||
|
child.setParentInstanceId(rootId);
|
||||||
|
child.setAuditInstanceId(null);
|
||||||
|
|
||||||
|
ChainDefinition definition =
|
||||||
|
new ChainDefinition();
|
||||||
|
definition.setId("1");
|
||||||
|
StartNode node =
|
||||||
|
new StartNode();
|
||||||
|
node.setId("node");
|
||||||
|
definition.addNode(node);
|
||||||
|
Chain chain =
|
||||||
|
new Chain(definition, childId);
|
||||||
|
chain.setChainStateRepository(repository);
|
||||||
|
WorkflowExecutionAuditProducer producer =
|
||||||
|
Mockito.mock(
|
||||||
|
WorkflowExecutionAuditProducer.class);
|
||||||
|
ChainEventListenerForSave listener =
|
||||||
|
new ChainEventListenerForSave();
|
||||||
|
Field producerField =
|
||||||
|
ChainEventListenerForSave.class
|
||||||
|
.getDeclaredField("auditProducer");
|
||||||
|
producerField.setAccessible(true);
|
||||||
|
producerField.set(listener, producer);
|
||||||
|
|
||||||
|
listener.onEvent(
|
||||||
|
new NodeStartEvent(
|
||||||
|
chain,
|
||||||
|
node,
|
||||||
|
"attempt",
|
||||||
|
NodeStatus.RUNNING,
|
||||||
|
chain.getAuditInstanceId()),
|
||||||
|
chain);
|
||||||
|
listener.onEvent(
|
||||||
|
new NodeEndEvent(
|
||||||
|
chain,
|
||||||
|
node,
|
||||||
|
Map.of("value", "ok"),
|
||||||
|
null,
|
||||||
|
NodeStatus.SUCCEEDED,
|
||||||
|
"attempt"),
|
||||||
|
chain);
|
||||||
|
|
||||||
|
ArgumentCaptor<WorkflowExecutionAuditEvent> captor =
|
||||||
|
ArgumentCaptor.forClass(
|
||||||
|
WorkflowExecutionAuditEvent.class);
|
||||||
|
Mockito.verify(
|
||||||
|
producer,
|
||||||
|
Mockito.times(2))
|
||||||
|
.send(captor.capture());
|
||||||
|
List<WorkflowExecutionAuditEvent> events =
|
||||||
|
captor.getAllValues();
|
||||||
|
Assert.assertEquals(
|
||||||
|
WorkflowExecutionAuditEvent.Type.NODE_STARTED,
|
||||||
|
events.get(0).getType());
|
||||||
|
Assert.assertEquals(
|
||||||
|
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
|
||||||
|
events.get(1).getType());
|
||||||
|
Assert.assertEquals(
|
||||||
|
rootId,
|
||||||
|
events.get(0).getInstanceId());
|
||||||
|
Assert.assertEquals(
|
||||||
|
rootId,
|
||||||
|
events.get(1).getInstanceId());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.repository;
|
||||||
|
|
||||||
|
import com.alicp.jetcache.Cache;
|
||||||
|
import com.alicp.jetcache.CacheException;
|
||||||
|
import com.alicp.jetcache.CacheResult;
|
||||||
|
import com.alicp.jetcache.CacheResultCode;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link BaseRepository} 缓存操作语义回归测试。
|
||||||
|
*/
|
||||||
|
public class BaseRepositoryTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证删除不存在的缓存键按幂等成功处理。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void removeCacheShouldAcceptMissingKey() throws Exception {
|
||||||
|
TestRepository repository = repository(
|
||||||
|
new CacheResult(CacheResultCode.NOT_EXISTS, null));
|
||||||
|
|
||||||
|
repository.remove("missing-key");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证真实删除错误仍会向上抛出。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void removeCacheShouldRejectOperationFailure() throws Exception {
|
||||||
|
TestRepository repository = repository(
|
||||||
|
new CacheResult(CacheResultCode.FAIL, "redis unavailable"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
repository.remove("failed-key");
|
||||||
|
Assert.fail("cache failure should be propagated");
|
||||||
|
} catch (CacheException expected) {
|
||||||
|
Assert.assertTrue(expected.getMessage().contains("failed-key"));
|
||||||
|
Assert.assertTrue(expected.getMessage().contains("redis unavailable"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建注入指定删除结果的测试仓储。
|
||||||
|
*
|
||||||
|
* @param removeResult 删除操作结果
|
||||||
|
* @return 测试仓储
|
||||||
|
* @throws Exception 反射注入失败时抛出
|
||||||
|
*/
|
||||||
|
private TestRepository repository(CacheResult removeResult) throws Exception {
|
||||||
|
Cache<String, Object> cache = cache(removeResult);
|
||||||
|
TestRepository repository = new TestRepository();
|
||||||
|
Field field = BaseRepository.class.getDeclaredField("cache");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(repository, cache);
|
||||||
|
return repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建只支持删除操作的 JetCache 代理。
|
||||||
|
*
|
||||||
|
* @param removeResult 删除操作结果
|
||||||
|
* @return JetCache 测试代理
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Cache<String, Object> cache(CacheResult removeResult) {
|
||||||
|
return (Cache<String, Object>) Proxy.newProxyInstance(
|
||||||
|
Cache.class.getClassLoader(),
|
||||||
|
new Class<?>[]{Cache.class},
|
||||||
|
(proxy, method, args) -> {
|
||||||
|
if ("REMOVE".equals(method.getName())) {
|
||||||
|
return removeResult;
|
||||||
|
}
|
||||||
|
throw new UnsupportedOperationException(
|
||||||
|
"unsupported cache method: " + method.getName());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 暴露受保护缓存删除能力的测试仓储。
|
||||||
|
*/
|
||||||
|
private static final class TestRepository extends BaseRepository {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定缓存键。
|
||||||
|
*
|
||||||
|
* @param key 缓存键
|
||||||
|
*/
|
||||||
|
private void remove(String key) {
|
||||||
|
removeCache(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.repository;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||||
|
import com.easyagents.flow.core.chain.Node;
|
||||||
|
import com.easyagents.flow.core.chain.Parameter;
|
||||||
|
import com.easyagents.flow.core.node.BaseNode;
|
||||||
|
import com.easyagents.flow.core.node.EndNode;
|
||||||
|
import com.easyagents.flow.core.node.HttpNode;
|
||||||
|
import com.easyagents.flow.core.node.LlmNode;
|
||||||
|
import com.easyagents.flow.core.node.LoopNode;
|
||||||
|
import com.easyagents.flow.core.node.StartNode;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.ai.node.ConditionNode;
|
||||||
|
import tech.easyflow.ai.node.DocNode;
|
||||||
|
import tech.easyflow.ai.node.DownloadNode;
|
||||||
|
import tech.easyflow.ai.node.MakeFileNode;
|
||||||
|
import tech.easyflow.ai.node.PluginToolNode;
|
||||||
|
import tech.easyflow.ai.node.SaveDatasetNode;
|
||||||
|
import tech.easyflow.ai.node.SearchDatasetNode;
|
||||||
|
import tech.easyflow.ai.node.WorkflowNode;
|
||||||
|
import tech.easyflow.datacenter.execution.model.DatasetRef;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.ObjectInputStream;
|
||||||
|
import java.io.ObjectOutputStream;
|
||||||
|
import java.io.ObjectStreamClass;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流定义快照 Java 序列化兼容约束测试。
|
||||||
|
*/
|
||||||
|
public class ChainDefinitionSnapshotSerializationTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证全部运行时业务节点对象图可完成定义快照往返。
|
||||||
|
*
|
||||||
|
* @throws Exception 序列化失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRoundTripAllRuntimeNodeTypes()
|
||||||
|
throws Exception {
|
||||||
|
ChainDefinition definition = new ChainDefinition();
|
||||||
|
definition.setId("snapshot-all-node-types");
|
||||||
|
List<Node> nodes = List.of(
|
||||||
|
node(new StartNode(), "start"),
|
||||||
|
node(new EndNode(), "end"),
|
||||||
|
node(new HttpNode(), "http"),
|
||||||
|
node(new LlmNode(), "llm"),
|
||||||
|
node(new LoopNode(), "loop"),
|
||||||
|
node(new PluginToolNode(), "plugin"),
|
||||||
|
node(new DownloadNode(), "download"),
|
||||||
|
node(new WorkflowNode(), "workflow"),
|
||||||
|
node(new DocNode(), "doc"),
|
||||||
|
datasetNode(new SearchDatasetNode(), "search"),
|
||||||
|
datasetNode(new SaveDatasetNode(), "save"),
|
||||||
|
conditionNode(),
|
||||||
|
node(new MakeFileNode(), "make-file"));
|
||||||
|
nodes.forEach(definition::addNode);
|
||||||
|
|
||||||
|
byte[] bytes;
|
||||||
|
try (ByteArrayOutputStream output =
|
||||||
|
new ByteArrayOutputStream();
|
||||||
|
ObjectOutputStream objectOutput =
|
||||||
|
new ObjectOutputStream(output)) {
|
||||||
|
objectOutput.writeObject(definition);
|
||||||
|
objectOutput.flush();
|
||||||
|
bytes = output.toByteArray();
|
||||||
|
}
|
||||||
|
ChainDefinition restored;
|
||||||
|
try (ObjectInputStream input =
|
||||||
|
new ObjectInputStream(
|
||||||
|
new ByteArrayInputStream(bytes))) {
|
||||||
|
restored = (ChainDefinition) input.readObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
definition.getId(), restored.getId());
|
||||||
|
Assert.assertEquals(
|
||||||
|
nodes.size(), restored.getNodes().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证定义对象图关键类使用显式稳定 UID,防止新增方法导致默认 UID 漂移。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldKeepStableSerialVersionUids() {
|
||||||
|
List<Class<?>> stableTypes = List.of(
|
||||||
|
Node.class,
|
||||||
|
BaseNode.class,
|
||||||
|
Parameter.class,
|
||||||
|
StartNode.class,
|
||||||
|
EndNode.class,
|
||||||
|
HttpNode.class,
|
||||||
|
LlmNode.class,
|
||||||
|
LoopNode.class,
|
||||||
|
PluginToolNode.class,
|
||||||
|
DownloadNode.class,
|
||||||
|
WorkflowNode.class,
|
||||||
|
DocNode.class,
|
||||||
|
SearchDatasetNode.class,
|
||||||
|
SaveDatasetNode.class,
|
||||||
|
ConditionNode.class,
|
||||||
|
ConditionNode.ConditionBranch.class,
|
||||||
|
ConditionNode.ConditionRule.class,
|
||||||
|
MakeFileNode.class,
|
||||||
|
DatasetRef.class);
|
||||||
|
for (Class<?> type : stableTypes) {
|
||||||
|
Assert.assertEquals(
|
||||||
|
"unstable serialVersionUID: "
|
||||||
|
+ type.getName(),
|
||||||
|
1L,
|
||||||
|
ObjectStreamClass.lookup(type)
|
||||||
|
.getSerialVersionUID());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置测试节点 ID。
|
||||||
|
*
|
||||||
|
* @param node 节点
|
||||||
|
* @param id 节点 ID
|
||||||
|
* @return 原节点
|
||||||
|
*/
|
||||||
|
private <T extends Node> T node(T node, String id) {
|
||||||
|
node.setId(id);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置带数据集引用的节点。
|
||||||
|
*
|
||||||
|
* @param node 数据集节点
|
||||||
|
* @param id 节点 ID
|
||||||
|
* @return 原节点
|
||||||
|
*/
|
||||||
|
private <T extends Node> T datasetNode(
|
||||||
|
T node, String id) {
|
||||||
|
DatasetRef ref = new DatasetRef();
|
||||||
|
ref.setTableName("dataset_table");
|
||||||
|
if (node instanceof SearchDatasetNode) {
|
||||||
|
((SearchDatasetNode) node).setDatasetRef(ref);
|
||||||
|
} else {
|
||||||
|
((SaveDatasetNode) node).setDatasetRef(ref);
|
||||||
|
}
|
||||||
|
return node(node, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带完整嵌套规则对象图的条件节点。
|
||||||
|
*
|
||||||
|
* @return 条件节点
|
||||||
|
*/
|
||||||
|
private ConditionNode conditionNode() {
|
||||||
|
ConditionNode.ConditionRule rule =
|
||||||
|
new ConditionNode.ConditionRule();
|
||||||
|
rule.setId("rule");
|
||||||
|
ConditionNode.ConditionBranch branch =
|
||||||
|
new ConditionNode.ConditionBranch();
|
||||||
|
branch.setId("branch");
|
||||||
|
branch.setRules(List.of(rule));
|
||||||
|
ConditionNode node = node(
|
||||||
|
new ConditionNode(), "condition");
|
||||||
|
node.setBranches(List.of(branch));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,36 +5,52 @@ import com.alicp.jetcache.CacheException;
|
|||||||
import com.alicp.jetcache.CacheGetResult;
|
import com.alicp.jetcache.CacheGetResult;
|
||||||
import com.alicp.jetcache.CacheResult;
|
import com.alicp.jetcache.CacheResult;
|
||||||
import com.alicp.jetcache.CacheResultCode;
|
import com.alicp.jetcache.CacheResultCode;
|
||||||
|
import com.alicp.jetcache.CacheValueHolder;
|
||||||
import com.alicp.jetcache.support.CacheEncodeException;
|
import com.alicp.jetcache.support.CacheEncodeException;
|
||||||
import com.easyagents.flow.core.chain.ChainState;
|
import com.easyagents.flow.core.chain.ChainState;
|
||||||
|
import com.easyagents.flow.core.chain.ChainStatus;
|
||||||
|
import com.easyagents.flow.core.chain.repository.ChainLock;
|
||||||
|
import com.easyagents.flow.core.chain.repository.ChainStateField;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentMatchers;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||||
|
import tech.easyflow.common.cache.VersionedObjectStore;
|
||||||
|
import tech.easyflow.common.cache.VersionedFields;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
import java.lang.reflect.InvocationHandler;
|
import java.lang.reflect.InvocationHandler;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.lang.reflect.Proxy;
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link ChainStateRepositoryImpl} 缓存异常处理回归测试。
|
* {@link ChainStateRepositoryImpl} 缓存迁移和版本提交回归测试。
|
||||||
*/
|
*/
|
||||||
public class ChainStateRepositoryImplTest {
|
public class ChainStateRepositoryImplTest {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证缓存解码失败时抛出异常且不创建空工作流状态。
|
* 验证旧缓存解码失败时抛出异常且不创建空工作流状态。
|
||||||
*
|
*
|
||||||
* @throws Exception 缓存依赖注入失败时抛出
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
public void loadShouldFailWithoutOverwritingStateWhenCacheDecodeFails() throws Exception {
|
public void loadShouldFailWithoutOverwritingStateWhenCacheDecodeFails() throws Exception {
|
||||||
String instanceId = "decode-failed-instance";
|
String instanceId = "decode-failed-instance";
|
||||||
CacheGetResult<Object> failure = new CacheGetResult<>(new CacheEncodeException(
|
CacheGetResult<Object> failure = new CacheGetResult<>(new CacheEncodeException(
|
||||||
"decode error",
|
"decode error",
|
||||||
new ClassNotFoundException("com.alicp.jetcache.CacheValueHolder")
|
new ClassNotFoundException("com.alicp.jetcache.CacheValueHolder")
|
||||||
));
|
));
|
||||||
RecordingCache cache = new RecordingCache(failure, CacheResult.SUCCESS_WITHOUT_MSG);
|
RecordingCache cache = new RecordingCache(failure);
|
||||||
ChainStateRepositoryImpl repository = repository(cache.asCache());
|
RecordingVersionedObjectStore stateStore = new RecordingVersionedObjectStore();
|
||||||
|
ChainStateRepositoryImpl repository = repository(cache.asCache(), stateStore);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
repository.load(instanceId);
|
repository.load(instanceId);
|
||||||
@@ -44,142 +60,448 @@ public class ChainStateRepositoryImplTest {
|
|||||||
Assert.assertTrue(expected.getMessage().contains(instanceId));
|
Assert.assertTrue(expected.getMessage().contains(instanceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
Assert.assertEquals(0, cache.getPutCount());
|
Assert.assertEquals(0, stateStore.getCreateCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证缓存未命中时创建并持久化新的工作流状态。
|
* 验证新实例通过版本对象存储显式创建。
|
||||||
*
|
*
|
||||||
* @throws Exception 缓存依赖注入失败时抛出
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
public void loadShouldCreateStateWhenCacheDoesNotExist() throws Exception {
|
public void createShouldPersistStateWhenStateDoesNotExist() throws Exception {
|
||||||
String instanceId = "new-instance";
|
String instanceId = "new-instance";
|
||||||
RecordingCache cache = new RecordingCache(
|
RecordingCache cache = new RecordingCache(
|
||||||
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null),
|
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null));
|
||||||
CacheResult.SUCCESS_WITHOUT_MSG
|
RecordingVersionedObjectStore stateStore = new RecordingVersionedObjectStore();
|
||||||
);
|
ChainStateRepositoryImpl repository = repository(cache.asCache(), stateStore);
|
||||||
ChainStateRepositoryImpl repository = repository(cache.asCache());
|
|
||||||
|
|
||||||
ChainState state = repository.load(instanceId);
|
ChainState state = repository.create(instanceId);
|
||||||
|
|
||||||
Assert.assertEquals(instanceId, state.getInstanceId());
|
Assert.assertEquals(instanceId, state.getInstanceId());
|
||||||
Assert.assertEquals(1, cache.getPutCount());
|
Assert.assertEquals(1, stateStore.getCreateCount());
|
||||||
Assert.assertSame(state, cache.getLastPutValue());
|
Assert.assertEquals(
|
||||||
|
instanceId,
|
||||||
|
stateStore.getLastCreatedFields().get(ChainStateField.INSTANCE_ID.name()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证缓存写入失败时不会返回未持久化的工作流状态。
|
* 验证旧 JetCache 状态首次读取后迁移到版本对象存储。
|
||||||
*
|
*
|
||||||
* @throws Exception 缓存依赖注入失败时抛出
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
public void loadShouldFailWhenNewStateCannotBePersisted() throws Exception {
|
public void loadShouldMigrateLegacyStateOnce() throws Exception {
|
||||||
|
ChainState legacy = new ChainState();
|
||||||
|
legacy.setInstanceId("legacy-instance");
|
||||||
|
legacy.setVersion(7L);
|
||||||
|
legacy.setStatus(ChainStatus.SUCCEEDED);
|
||||||
RecordingCache cache = new RecordingCache(
|
RecordingCache cache = new RecordingCache(
|
||||||
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null),
|
new CacheGetResult<>(
|
||||||
new CacheResult(new IllegalStateException("redis unavailable"))
|
CacheResultCode.SUCCESS,
|
||||||
);
|
null,
|
||||||
ChainStateRepositoryImpl repository = repository(cache.asCache());
|
new CacheValueHolder<>(legacy, Long.MAX_VALUE)));
|
||||||
|
RecordingVersionedObjectStore stateStore = new RecordingVersionedObjectStore();
|
||||||
|
ChainStateRepositoryImpl repository = repository(cache.asCache(), stateStore);
|
||||||
|
|
||||||
try {
|
ChainState loaded = repository.load(legacy.getInstanceId());
|
||||||
repository.load("write-failed-instance");
|
|
||||||
Assert.fail("cache write failure should be propagated");
|
|
||||||
} catch (CacheException expected) {
|
|
||||||
Assert.assertTrue(expected.getMessage().contains("工作流状态缓存写入失败"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert.assertEquals(1, cache.getPutCount());
|
Assert.assertNotSame(legacy, loaded);
|
||||||
|
Assert.assertEquals(legacy.getInstanceId(), loaded.getInstanceId());
|
||||||
|
Assert.assertEquals(legacy.getStatus(), loaded.getStatus());
|
||||||
|
Assert.assertEquals(legacy.getVersion(), loaded.getVersion());
|
||||||
|
Assert.assertEquals(1, stateStore.getCreateCount());
|
||||||
|
Assert.assertEquals(7L, stateStore.getVersionForLastKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建工作流状态仓储并注入缓存。
|
* 验证过期版本不能覆盖已经成功提交的新状态。
|
||||||
*
|
*
|
||||||
* @param cache 测试缓存
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
* @return 已完成依赖注入的工作流状态仓储
|
*/
|
||||||
|
@Test
|
||||||
|
public void tryUpdateShouldRejectStaleVersion() throws Exception {
|
||||||
|
RecordingCache cache = new RecordingCache(
|
||||||
|
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null));
|
||||||
|
RecordingVersionedObjectStore stateStore = new RecordingVersionedObjectStore();
|
||||||
|
ChainStateRepositoryImpl repository = repository(cache.asCache(), stateStore);
|
||||||
|
ChainState created = repository.create("cas-instance");
|
||||||
|
|
||||||
|
ChainState firstUpdate = new ChainState();
|
||||||
|
firstUpdate.setInstanceId(created.getInstanceId());
|
||||||
|
firstUpdate.setVersion(1L);
|
||||||
|
Assert.assertTrue(repository.tryUpdate(
|
||||||
|
firstUpdate, EnumSet.of(ChainStateField.VERSION)));
|
||||||
|
|
||||||
|
ChainState staleUpdate = new ChainState();
|
||||||
|
staleUpdate.setInstanceId(created.getInstanceId());
|
||||||
|
staleUpdate.setVersion(1L);
|
||||||
|
Assert.assertFalse(repository.tryUpdate(
|
||||||
|
staleUpdate, EnumSet.of(ChainStateField.VERSION)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证实例锁成功获取后分配独立的实例 fencing token。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void getLockShouldAllocateInstanceFencingToken() throws Exception {
|
||||||
|
RecordingCache cache = new RecordingCache(
|
||||||
|
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null));
|
||||||
|
ChainStateRepositoryImpl repository = repository(
|
||||||
|
cache.asCache(), new RecordingVersionedObjectStore());
|
||||||
|
RedisLockExecutor lockExecutor = Mockito.mock(RedisLockExecutor.class);
|
||||||
|
RedisLockExecutor.LockHandle handle =
|
||||||
|
Mockito.mock(RedisLockExecutor.LockHandle.class);
|
||||||
|
Mockito.when(lockExecutor.tryAcquireFenced(
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.any(Duration.class),
|
||||||
|
ArgumentMatchers.any(Duration.class),
|
||||||
|
ArgumentMatchers.any(Duration.class)
|
||||||
|
)).thenReturn(handle);
|
||||||
|
Mockito.when(handle.getFencingToken()).thenReturn(17L);
|
||||||
|
setField(repository, "redisLockExecutor", lockExecutor);
|
||||||
|
|
||||||
|
ChainLock lock = repository.getLock("fenced-instance", 10L, TimeUnit.SECONDS);
|
||||||
|
try {
|
||||||
|
Assert.assertTrue(lock.isAcquired());
|
||||||
|
Assert.assertEquals(17L, lock.getFencingToken());
|
||||||
|
} finally {
|
||||||
|
lock.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Mockito.verify(lockExecutor).tryAcquireFenced(
|
||||||
|
ArgumentMatchers.eq("chainLock:{fenced-instance}"),
|
||||||
|
ArgumentMatchers.eq("workflowState:{fenced-instance}:fence"),
|
||||||
|
ArgumentMatchers.any(Duration.class),
|
||||||
|
ArgumentMatchers.any(Duration.class),
|
||||||
|
ArgumentMatchers.eq(Duration.ofDays(4)));
|
||||||
|
Mockito.verify(handle).release();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证状态 CAS 同时校验实例锁和 trigger claim 守卫。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void tryUpdateShouldGuardLockAndSpecificTriggerClaim() throws Exception {
|
||||||
|
RecordingCache cache = new RecordingCache(
|
||||||
|
new CacheGetResult<>(CacheResultCode.NOT_EXISTS, null, null));
|
||||||
|
VersionedObjectStore stateStore = Mockito.mock(VersionedObjectStore.class);
|
||||||
|
Mockito.when(stateStore.compareAndSetFieldsAndRefresh(
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyLong(),
|
||||||
|
ArgumentMatchers.anyMap(),
|
||||||
|
ArgumentMatchers.anyLong(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyLong(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyLong(),
|
||||||
|
ArgumentMatchers.any(Duration.class),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.any(Duration.class)
|
||||||
|
)).thenReturn(true);
|
||||||
|
ChainStateRepositoryImpl repository = repository(cache.asCache(), stateStore);
|
||||||
|
ChainState update = new ChainState();
|
||||||
|
update.setInstanceId("claim-guard-instance");
|
||||||
|
update.setVersion(1L);
|
||||||
|
|
||||||
|
Assert.assertTrue(repository.tryUpdate(
|
||||||
|
update,
|
||||||
|
EnumSet.of(ChainStateField.VERSION),
|
||||||
|
17L,
|
||||||
|
"trigger-1",
|
||||||
|
42L));
|
||||||
|
|
||||||
|
Mockito.verify(stateStore).compareAndSetFieldsAndRefresh(
|
||||||
|
ArgumentMatchers.eq("workflowState:{claim-guard-instance}:chain"),
|
||||||
|
ArgumentMatchers.eq(0L),
|
||||||
|
ArgumentMatchers.anyMap(),
|
||||||
|
ArgumentMatchers.eq(1L),
|
||||||
|
ArgumentMatchers.eq("workflowState:{claim-guard-instance}:fence"),
|
||||||
|
ArgumentMatchers.eq(17L),
|
||||||
|
ArgumentMatchers.eq(
|
||||||
|
"workflowState:{claim-guard-instance}:claim:trigger-1"),
|
||||||
|
ArgumentMatchers.eq(42L),
|
||||||
|
ArgumentMatchers.eq(Duration.ofDays(3)),
|
||||||
|
ArgumentMatchers.eq("workflowState:{claim-guard-instance}:format"),
|
||||||
|
ArgumentMatchers.eq(Duration.ofDays(4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建工作流状态仓储并注入测试依赖。
|
||||||
|
*
|
||||||
|
* @param cache 旧 JetCache 测试代理
|
||||||
|
* @param stateStore 版本对象存储
|
||||||
|
* @return 已完成依赖注入的仓储
|
||||||
* @throws Exception 反射注入失败时抛出
|
* @throws Exception 反射注入失败时抛出
|
||||||
*/
|
*/
|
||||||
private ChainStateRepositoryImpl repository(Cache<String, Object> cache) throws Exception {
|
private ChainStateRepositoryImpl repository(Cache<String, Object> cache,
|
||||||
|
VersionedObjectStore stateStore) throws Exception {
|
||||||
ChainStateRepositoryImpl repository = new ChainStateRepositoryImpl();
|
ChainStateRepositoryImpl repository = new ChainStateRepositoryImpl();
|
||||||
Field field = BaseRepository.class.getDeclaredField("cache");
|
Field cacheField = BaseRepository.class.getDeclaredField("cache");
|
||||||
field.setAccessible(true);
|
cacheField.setAccessible(true);
|
||||||
field.set(repository, cache);
|
cacheField.set(repository, cache);
|
||||||
|
Field storeField = ChainStateRepositoryImpl.class.getDeclaredField("versionedObjectStore");
|
||||||
|
storeField.setAccessible(true);
|
||||||
|
storeField.set(repository, stateStore);
|
||||||
return repository;
|
return repository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仅实现当前仓储测试所需操作的 JetCache 调用记录器。
|
* 反射注入测试依赖。
|
||||||
|
*
|
||||||
|
* @param target 目标对象
|
||||||
|
* @param name 字段名
|
||||||
|
* @param value 字段值
|
||||||
|
* @throws Exception 字段访问失败时抛出
|
||||||
|
*/
|
||||||
|
private void setField(Object target, String name, Object value) throws Exception {
|
||||||
|
Field field = target.getClass().getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(target, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅实现当前仓储测试所需读取操作的 JetCache 代理。
|
||||||
*/
|
*/
|
||||||
private static final class RecordingCache implements InvocationHandler {
|
private static final class RecordingCache implements InvocationHandler {
|
||||||
|
|
||||||
private final CacheGetResult<Object> getResult;
|
private final CacheGetResult<Object> getResult;
|
||||||
private final CacheResult putResult;
|
|
||||||
private int putCount;
|
|
||||||
private Object lastPutValue;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建缓存调用记录器。
|
* 创建 JetCache 调用记录器。
|
||||||
*
|
*
|
||||||
* @param getResult 读取操作结果
|
* @param getResult 读取操作结果
|
||||||
* @param putResult 写入操作结果
|
|
||||||
*/
|
*/
|
||||||
private RecordingCache(CacheGetResult<Object> getResult, CacheResult putResult) {
|
private RecordingCache(CacheGetResult<Object> getResult) {
|
||||||
this.getResult = getResult;
|
this.getResult = getResult;
|
||||||
this.putResult = putResult;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建实现 JetCache 接口的 JDK 动态代理。
|
* 创建实现 JetCache 接口的动态代理。
|
||||||
*
|
*
|
||||||
* @return JetCache 测试代理
|
* @return JetCache 测试代理
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private Cache<String, Object> asCache() {
|
private Cache<String, Object> asCache() {
|
||||||
return (Cache<String, Object>) Proxy.newProxyInstance(
|
return (Cache<String, Object>) Proxy.newProxyInstance(
|
||||||
Cache.class.getClassLoader(),
|
Cache.class.getClassLoader(),
|
||||||
new Class<?>[]{Cache.class},
|
new Class<?>[]{Cache.class},
|
||||||
this
|
this
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理仓储发起的缓存读写操作。
|
* 处理仓储发起的缓存调用。
|
||||||
*
|
*
|
||||||
* @param proxy 代理对象
|
* @param proxy 代理对象
|
||||||
* @param method 被调用方法
|
* @param method 被调用方法
|
||||||
* @param args 调用参数
|
* @param args 调用参数
|
||||||
* @return 预设的缓存操作结果
|
* @return 预设结果
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Object invoke(Object proxy, Method method, Object[] args) {
|
public Object invoke(Object proxy, Method method, Object[] args) {
|
||||||
if ("GET".equals(method.getName())) {
|
if ("GET".equals(method.getName())) {
|
||||||
return getResult;
|
return getResult;
|
||||||
}
|
}
|
||||||
if ("PUT".equals(method.getName()) && args != null && args.length == 4) {
|
if ("REMOVE".equals(method.getName())) {
|
||||||
putCount++;
|
return CacheResult.SUCCESS_WITHOUT_MSG;
|
||||||
lastPutValue = args[1];
|
|
||||||
Assert.assertEquals(3L, args[2]);
|
|
||||||
Assert.assertEquals(TimeUnit.DAYS, args[3]);
|
|
||||||
return putResult;
|
|
||||||
}
|
}
|
||||||
throw new UnsupportedOperationException("unsupported cache method: " + method.getName());
|
throw new UnsupportedOperationException(
|
||||||
|
"unsupported cache method: " + method.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以进程内 Map 模拟原子版本对象存储。
|
||||||
|
*/
|
||||||
|
private static final class RecordingVersionedObjectStore implements VersionedObjectStore {
|
||||||
|
|
||||||
|
private final Map<String, Serializable> values = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Map<String, Object>> fieldValues = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Long> versions = new ConcurrentHashMap<>();
|
||||||
|
private int createCount;
|
||||||
|
private Map<String, Object> lastCreatedFields;
|
||||||
|
private String lastKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public <T> T load(String key, Class<T> type) {
|
||||||
|
Serializable value = values.get(key);
|
||||||
|
return value == null ? null : type.cast(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取写入调用次数。
|
* {@inheritDoc}
|
||||||
*
|
|
||||||
* @return 写入调用次数
|
|
||||||
*/
|
*/
|
||||||
private int getPutCount() {
|
@Override
|
||||||
return putCount;
|
public VersionedFields loadFields(String key) {
|
||||||
|
Map<String, Object> fields = fieldValues.get(key);
|
||||||
|
Long version = versions.get(key);
|
||||||
|
return fields == null || version == null
|
||||||
|
? null
|
||||||
|
: new VersionedFields(version, fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取最后一次写入的缓存值。
|
* {@inheritDoc}
|
||||||
*
|
|
||||||
* @return 最后一次写入的缓存值
|
|
||||||
*/
|
*/
|
||||||
private Object getLastPutValue() {
|
@Override
|
||||||
return lastPutValue;
|
public synchronized boolean createFieldsIfAbsent(
|
||||||
|
String key,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long version,
|
||||||
|
Duration ttl) {
|
||||||
|
if (fieldValues.containsKey(key) || values.containsKey(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fieldValues.put(key, new LinkedHashMap<>(fields));
|
||||||
|
versions.put(key, version);
|
||||||
|
if (!key.endsWith(":format")) {
|
||||||
|
createCount++;
|
||||||
|
lastCreatedFields = new LinkedHashMap<>(fields);
|
||||||
|
lastKey = key;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public synchronized boolean compareAndSetFields(
|
||||||
|
String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
long newVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long currentVersion = versions.get(key);
|
||||||
|
if (currentVersion == null || currentVersion != expectedVersion) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fieldValues.computeIfAbsent(key, ignored -> new LinkedHashMap<>()).putAll(fields);
|
||||||
|
versions.put(key, newVersion);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public synchronized boolean rewriteAsFields(
|
||||||
|
String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Map<String, ? extends Serializable> fields,
|
||||||
|
Duration ttl) {
|
||||||
|
Long currentVersion = versions.get(key);
|
||||||
|
if (currentVersion == null || currentVersion != expectedVersion) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
values.remove(key);
|
||||||
|
fieldValues.put(key, new LinkedHashMap<>(fields));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public synchronized boolean createIfAbsent(String key,
|
||||||
|
Serializable value,
|
||||||
|
long version,
|
||||||
|
Duration ttl) {
|
||||||
|
if (values.containsKey(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
values.put(key, value);
|
||||||
|
versions.put(key, version);
|
||||||
|
createCount++;
|
||||||
|
lastKey = key;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean createIfAbsent(String key,
|
||||||
|
Serializable value,
|
||||||
|
long version,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long currentGuard = versions.get(guardKey);
|
||||||
|
return currentGuard != null
|
||||||
|
&& currentGuard == guardVersion
|
||||||
|
&& createIfAbsent(key, value, version, ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public synchronized boolean compareAndSet(String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Serializable value,
|
||||||
|
long newVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long currentVersion = versions.get(key);
|
||||||
|
if (currentVersion == null || currentVersion != expectedVersion) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
values.put(key, value);
|
||||||
|
versions.put(key, newVersion);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean compareAndSet(String key,
|
||||||
|
long expectedVersion,
|
||||||
|
Serializable value,
|
||||||
|
long newVersion,
|
||||||
|
String guardKey,
|
||||||
|
long guardVersion,
|
||||||
|
Duration ttl) {
|
||||||
|
Long currentGuard = versions.get(guardKey);
|
||||||
|
return currentGuard != null
|
||||||
|
&& currentGuard == guardVersion
|
||||||
|
&& compareAndSet(key, expectedVersion, value, newVersion, ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取创建次数。
|
||||||
|
*
|
||||||
|
* @return 创建次数
|
||||||
|
*/
|
||||||
|
private int getCreateCount() {
|
||||||
|
return createCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最后创建的对象。
|
||||||
|
*
|
||||||
|
* @return 最后创建的对象
|
||||||
|
*/
|
||||||
|
private Map<String, Object> getLastCreatedFields() {
|
||||||
|
return lastCreatedFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最后写入键的版本。
|
||||||
|
*
|
||||||
|
* @return 最后写入版本
|
||||||
|
*/
|
||||||
|
private long getVersionForLastKey() {
|
||||||
|
return versions.get(lastKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,621 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.repository;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.repository.LoopResultReference;
|
||||||
|
import com.easyagents.flow.core.chain.repository.LoopInputReference;
|
||||||
|
import com.easyagents.flow.core.chain.runtime.TriggerClaimLostException;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.common.cache.VersionedObjectStore;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 循环结果分块仓储测试。
|
||||||
|
*/
|
||||||
|
public class LoopResultRepositoryImplTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证跨多个分块的结果顺序、完整性及幂等重放。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldPreserveOrderingAcrossChunkBoundaries() {
|
||||||
|
InMemoryLoopResultRepository repository = new InMemoryLoopResultRepository();
|
||||||
|
String resultId = "loop-result";
|
||||||
|
int iterations = LoopResultRepositoryImpl.CHUNK_SIZE * 2 + 1;
|
||||||
|
|
||||||
|
for (int index = 0; index < iterations; index++) {
|
||||||
|
repository.append(resultId, index, Map.of(
|
||||||
|
"index", index,
|
||||||
|
"value", "value-" + index));
|
||||||
|
}
|
||||||
|
repository.append(resultId, iterations - 1, Map.of(
|
||||||
|
"index", iterations - 1,
|
||||||
|
"value", "value-" + (iterations - 1)));
|
||||||
|
|
||||||
|
Map<String, Object> result =
|
||||||
|
repository.load(resultId, iterations, List.of("index", "value"));
|
||||||
|
Assert.assertEquals(iterations, ((List<?>) result.get("index")).size());
|
||||||
|
Assert.assertEquals(0, ((List<?>) result.get("index")).get(0));
|
||||||
|
Assert.assertEquals(iterations - 1, ((List<?>) result.get("index")).get(iterations - 1));
|
||||||
|
Assert.assertEquals("value-128", ((List<?>) result.get("value")).get(128));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证同一轮次写入不同结果时拒绝覆盖。
|
||||||
|
*/
|
||||||
|
@Test(expected = IllegalStateException.class)
|
||||||
|
public void shouldRejectConflictingReplay() {
|
||||||
|
InMemoryLoopResultRepository repository = new InMemoryLoopResultRepository();
|
||||||
|
repository.append("loop-result", 0, Map.of("value", "first"));
|
||||||
|
repository.append("loop-result", 0, Map.of("value", "changed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证热状态只保存轻量引用,业务读取边界仍还原为原有列表结构。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldResolveLightweightReferenceAtReadBoundary() {
|
||||||
|
InMemoryLoopResultRepository repository = new InMemoryLoopResultRepository();
|
||||||
|
String resultId = "instance:loop-result";
|
||||||
|
repository.append(resultId, 0, Map.of("value", "first"));
|
||||||
|
repository.append(resultId, 1, Map.of("value", "second"));
|
||||||
|
|
||||||
|
Map<String, Object> references = repository.references(
|
||||||
|
resultId, 2, List.of("value"));
|
||||||
|
Assert.assertTrue(references.get("value") instanceof LoopResultReference);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> resolved =
|
||||||
|
(Map<String, Object>) repository.resolveReferences(references);
|
||||||
|
Assert.assertEquals(List.of("first", "second"), resolved.get("value"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证没有声明输出的长循环跨分块时仍会续期输入生命周期。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRefreshInputChunksWhenLoopHasNoOutputs() throws Exception {
|
||||||
|
LoopResultRepositoryImpl repository = new LoopResultRepositoryImpl();
|
||||||
|
VersionedObjectStore store = mock(VersionedObjectStore.class);
|
||||||
|
Field field = LoopResultRepositoryImpl.class.getDeclaredField(
|
||||||
|
"versionedObjectStore");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(repository, store);
|
||||||
|
when(store.load(any(String.class), eq(Integer.class))).thenReturn(256);
|
||||||
|
when(store.createIfAbsent(
|
||||||
|
anyString(),
|
||||||
|
any(Serializable.class),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
any(Duration.class))).thenReturn(true);
|
||||||
|
|
||||||
|
repository.append(
|
||||||
|
"instance",
|
||||||
|
1L,
|
||||||
|
"claim",
|
||||||
|
1L,
|
||||||
|
"instance:loop",
|
||||||
|
LoopResultRepositoryImpl.CHUNK_SIZE,
|
||||||
|
Map.of());
|
||||||
|
|
||||||
|
verify(store).refreshExpirations(anyList(), any(Duration.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证同一输入分块内的多轮读取只访问一次底层对象存储。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldLoadEachInputChunkOnlyOnce() throws Exception {
|
||||||
|
LoopResultRepositoryImpl repository = new LoopResultRepositoryImpl();
|
||||||
|
VersionedObjectStore store = mock(VersionedObjectStore.class);
|
||||||
|
Field field = LoopResultRepositoryImpl.class.getDeclaredField(
|
||||||
|
"versionedObjectStore");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(repository, store);
|
||||||
|
List<Object> values = java.util.stream.IntStream.range(0, 128)
|
||||||
|
.boxed()
|
||||||
|
.map(value -> (Object) value)
|
||||||
|
.toList();
|
||||||
|
when(store.load(anyString(), eq(List.class))).thenReturn(values);
|
||||||
|
|
||||||
|
Assert.assertEquals(0, repository.loadInputItem("instance:loop", 0));
|
||||||
|
Assert.assertEquals(127, repository.loadInputItem("instance:loop", 127));
|
||||||
|
|
||||||
|
verify(store, times(1)).load(anyString(), eq(List.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证调用方修改已读取的可变输入时不会污染活动分块缓存。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldIsolateMutableInputValuesFromActiveCache()
|
||||||
|
throws Exception {
|
||||||
|
LoopResultRepositoryImpl repository =
|
||||||
|
new LoopResultRepositoryImpl();
|
||||||
|
VersionedObjectStore store =
|
||||||
|
mock(VersionedObjectStore.class);
|
||||||
|
Field field = LoopResultRepositoryImpl.class
|
||||||
|
.getDeclaredField("versionedObjectStore");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(repository, store);
|
||||||
|
Map<String, Object> persisted =
|
||||||
|
new LinkedHashMap<>();
|
||||||
|
persisted.put("name", "original");
|
||||||
|
when(store.load(anyString(), eq(List.class)))
|
||||||
|
.thenReturn(List.of(persisted));
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> first =
|
||||||
|
(Map<String, Object>)
|
||||||
|
repository.loadInputItem(
|
||||||
|
"instance:mutable-input",
|
||||||
|
0);
|
||||||
|
first.put("name", "changed");
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> second =
|
||||||
|
(Map<String, Object>)
|
||||||
|
repository.loadInputItem(
|
||||||
|
"instance:mutable-input",
|
||||||
|
0);
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
"original", second.get("name"));
|
||||||
|
verify(store, times(1)).load(
|
||||||
|
anyString(), eq(List.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证完整输入还原按分块批量读取,并保持不同调用方的可变值隔离。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public void shouldBulkLoadMutableInputChunks()
|
||||||
|
throws Exception {
|
||||||
|
LoopResultRepositoryImpl repository =
|
||||||
|
new LoopResultRepositoryImpl();
|
||||||
|
VersionedObjectStore store =
|
||||||
|
mock(VersionedObjectStore.class);
|
||||||
|
Field field = LoopResultRepositoryImpl.class
|
||||||
|
.getDeclaredField("versionedObjectStore");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(repository, store);
|
||||||
|
int itemCount =
|
||||||
|
LoopResultRepositoryImpl.CHUNK_SIZE
|
||||||
|
* 2 + 1;
|
||||||
|
when(store.loadAll(
|
||||||
|
anyList(), eq(List.class)))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
List<List<Object>> chunks =
|
||||||
|
new java.util.ArrayList<>();
|
||||||
|
for (int chunkIndex = 0;
|
||||||
|
chunkIndex < 3;
|
||||||
|
chunkIndex++) {
|
||||||
|
int chunkSize = chunkIndex < 2
|
||||||
|
? LoopResultRepositoryImpl.CHUNK_SIZE
|
||||||
|
: 1;
|
||||||
|
List<Object> chunk =
|
||||||
|
new java.util.ArrayList<>();
|
||||||
|
for (int offset = 0;
|
||||||
|
offset < chunkSize;
|
||||||
|
offset++) {
|
||||||
|
Map<String, Object> value =
|
||||||
|
new LinkedHashMap<>();
|
||||||
|
value.put(
|
||||||
|
"index",
|
||||||
|
chunkIndex
|
||||||
|
* LoopResultRepositoryImpl.CHUNK_SIZE
|
||||||
|
+ offset);
|
||||||
|
chunk.add(value);
|
||||||
|
}
|
||||||
|
chunks.add(chunk);
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
});
|
||||||
|
LoopInputReference reference =
|
||||||
|
new LoopInputReference(
|
||||||
|
"instance:bulk-input",
|
||||||
|
itemCount);
|
||||||
|
|
||||||
|
List<Object> first =
|
||||||
|
repository.loadInput(reference);
|
||||||
|
((Map<String, Object>) first.get(0))
|
||||||
|
.put("index", -1);
|
||||||
|
List<Object> second =
|
||||||
|
repository.loadInput(reference);
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
itemCount, second.size());
|
||||||
|
Assert.assertEquals(
|
||||||
|
0,
|
||||||
|
((Map<String, Object>) second.get(0))
|
||||||
|
.get("index"));
|
||||||
|
verify(store, times(2)).loadAll(
|
||||||
|
anyList(), eq(List.class));
|
||||||
|
verify(store, times(0)).load(
|
||||||
|
anyString(), eq(List.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证连续循环输出命中活动分块缓存时不重复读取 Redis 对象。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReuseActiveOutputChunkAfterSuccessfulCommit()
|
||||||
|
throws Exception {
|
||||||
|
LoopResultRepositoryImpl repository =
|
||||||
|
new LoopResultRepositoryImpl();
|
||||||
|
VersionedObjectStore store =
|
||||||
|
mock(VersionedObjectStore.class);
|
||||||
|
Field field = LoopResultRepositoryImpl.class
|
||||||
|
.getDeclaredField("versionedObjectStore");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(repository, store);
|
||||||
|
when(store.load(
|
||||||
|
anyString(),
|
||||||
|
eq(LoopResultRepositoryImpl
|
||||||
|
.LoopResultChunk.class)))
|
||||||
|
.thenReturn(null);
|
||||||
|
when(store.createIfAbsent(
|
||||||
|
anyString(),
|
||||||
|
any(Serializable.class),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
any(Duration.class)))
|
||||||
|
.thenReturn(true);
|
||||||
|
when(store.compareAndSet(
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
any(Serializable.class),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
any(Duration.class)))
|
||||||
|
.thenReturn(true);
|
||||||
|
|
||||||
|
repository.append(
|
||||||
|
"instance",
|
||||||
|
1L,
|
||||||
|
"claim",
|
||||||
|
1L,
|
||||||
|
"instance:cached-output",
|
||||||
|
0,
|
||||||
|
Map.of("value", "first"));
|
||||||
|
repository.append(
|
||||||
|
"instance",
|
||||||
|
1L,
|
||||||
|
"claim",
|
||||||
|
1L,
|
||||||
|
"instance:cached-output",
|
||||||
|
1,
|
||||||
|
Map.of("value", "second"));
|
||||||
|
|
||||||
|
verify(store, times(1)).load(
|
||||||
|
anyString(),
|
||||||
|
eq(LoopResultRepositoryImpl
|
||||||
|
.LoopResultChunk.class));
|
||||||
|
verify(store, times(1)).compareAndSet(
|
||||||
|
anyString(),
|
||||||
|
eq(0L),
|
||||||
|
any(Serializable.class),
|
||||||
|
eq(1L),
|
||||||
|
anyString(),
|
||||||
|
eq(1L),
|
||||||
|
anyString(),
|
||||||
|
eq(1L),
|
||||||
|
any(Duration.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证调用方修改已提交的可变输出时不会污染下一轮活动分块写入。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldIsolateMutableOutputValuesFromActiveCache()
|
||||||
|
throws Exception {
|
||||||
|
LoopResultRepositoryImpl repository =
|
||||||
|
new LoopResultRepositoryImpl();
|
||||||
|
VersionedObjectStore store =
|
||||||
|
mock(VersionedObjectStore.class);
|
||||||
|
Field field = LoopResultRepositoryImpl.class
|
||||||
|
.getDeclaredField("versionedObjectStore");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(repository, store);
|
||||||
|
when(store.load(
|
||||||
|
anyString(),
|
||||||
|
eq(LoopResultRepositoryImpl
|
||||||
|
.LoopResultChunk.class)))
|
||||||
|
.thenReturn(null);
|
||||||
|
when(store.createIfAbsent(
|
||||||
|
anyString(),
|
||||||
|
any(Serializable.class),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
any(Duration.class)))
|
||||||
|
.thenReturn(true);
|
||||||
|
when(store.compareAndSet(
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
any(Serializable.class),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
any(Duration.class)))
|
||||||
|
.thenReturn(true);
|
||||||
|
Map<String, Object> firstValue =
|
||||||
|
new LinkedHashMap<>();
|
||||||
|
firstValue.put("name", "original");
|
||||||
|
|
||||||
|
repository.append(
|
||||||
|
"instance",
|
||||||
|
1L,
|
||||||
|
"claim",
|
||||||
|
1L,
|
||||||
|
"instance:mutable-output",
|
||||||
|
0,
|
||||||
|
Map.of("value", firstValue));
|
||||||
|
firstValue.put("name", "changed");
|
||||||
|
repository.append(
|
||||||
|
"instance",
|
||||||
|
1L,
|
||||||
|
"claim",
|
||||||
|
1L,
|
||||||
|
"instance:mutable-output",
|
||||||
|
1,
|
||||||
|
Map.of("value", Map.of(
|
||||||
|
"name", "second")));
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<Serializable>
|
||||||
|
chunkCaptor =
|
||||||
|
org.mockito.ArgumentCaptor.forClass(
|
||||||
|
Serializable.class);
|
||||||
|
verify(store).compareAndSet(
|
||||||
|
anyString(),
|
||||||
|
eq(0L),
|
||||||
|
chunkCaptor.capture(),
|
||||||
|
eq(1L),
|
||||||
|
anyString(),
|
||||||
|
eq(1L),
|
||||||
|
anyString(),
|
||||||
|
eq(1L),
|
||||||
|
any(Duration.class));
|
||||||
|
LoopResultRepositoryImpl.LoopResultChunk
|
||||||
|
committed =
|
||||||
|
(LoopResultRepositoryImpl.LoopResultChunk)
|
||||||
|
chunkCaptor.getValue();
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> committedFirst =
|
||||||
|
(Map<String, Object>)
|
||||||
|
committed.getValues()
|
||||||
|
.get("value")
|
||||||
|
.get(0);
|
||||||
|
Assert.assertEquals(
|
||||||
|
"original",
|
||||||
|
committedFirst.get("name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证跨分块后活动输出缓存只保留当前分块。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入或反射读取失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldKeepOnlyCurrentOutputChunkInActiveCache()
|
||||||
|
throws Exception {
|
||||||
|
LoopResultRepositoryImpl repository =
|
||||||
|
new LoopResultRepositoryImpl();
|
||||||
|
VersionedObjectStore store =
|
||||||
|
mock(VersionedObjectStore.class);
|
||||||
|
Field storeField = LoopResultRepositoryImpl.class
|
||||||
|
.getDeclaredField("versionedObjectStore");
|
||||||
|
storeField.setAccessible(true);
|
||||||
|
storeField.set(repository, store);
|
||||||
|
when(store.createIfAbsent(
|
||||||
|
anyString(),
|
||||||
|
any(Serializable.class),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
any(Duration.class)))
|
||||||
|
.thenReturn(true);
|
||||||
|
|
||||||
|
repository.append(
|
||||||
|
"instance",
|
||||||
|
1L,
|
||||||
|
"claim",
|
||||||
|
1L,
|
||||||
|
"instance:chunk-release",
|
||||||
|
0,
|
||||||
|
Map.of("value", "first"));
|
||||||
|
repository.append(
|
||||||
|
"instance",
|
||||||
|
1L,
|
||||||
|
"claim",
|
||||||
|
1L,
|
||||||
|
"instance:chunk-release",
|
||||||
|
LoopResultRepositoryImpl.CHUNK_SIZE,
|
||||||
|
Map.of("value", "next"));
|
||||||
|
|
||||||
|
Field cacheField = LoopResultRepositoryImpl.class
|
||||||
|
.getDeclaredField("outputChunkCache");
|
||||||
|
cacheField.setAccessible(true);
|
||||||
|
Object cache = cacheField.get(repository);
|
||||||
|
Field valuesField = cache.getClass()
|
||||||
|
.getDeclaredField("values");
|
||||||
|
valuesField.setAccessible(true);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> cachedValues =
|
||||||
|
(Map<String, Object>)
|
||||||
|
valuesField.get(cache);
|
||||||
|
Assert.assertEquals(
|
||||||
|
1, cachedValues.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证物化中失去 fencing 守卫后立即停止并清理本 owner 已写分块。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldCleanupPartialInputWhenClaimIsLost()
|
||||||
|
throws Exception {
|
||||||
|
LoopResultRepositoryImpl repository =
|
||||||
|
new LoopResultRepositoryImpl();
|
||||||
|
VersionedObjectStore store =
|
||||||
|
mock(VersionedObjectStore.class);
|
||||||
|
Field field = LoopResultRepositoryImpl.class
|
||||||
|
.getDeclaredField("versionedObjectStore");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(repository, store);
|
||||||
|
when(store.createIfAbsent(
|
||||||
|
anyString(),
|
||||||
|
any(Serializable.class),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
any(Duration.class)))
|
||||||
|
.thenReturn(true, false);
|
||||||
|
List<Integer> input = java.util.stream.IntStream
|
||||||
|
.range(0, LoopResultRepositoryImpl.CHUNK_SIZE + 1)
|
||||||
|
.boxed()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
try {
|
||||||
|
repository.storeInput(
|
||||||
|
"instance",
|
||||||
|
1L,
|
||||||
|
"claim",
|
||||||
|
1L,
|
||||||
|
"instance:guarded-input",
|
||||||
|
input,
|
||||||
|
10_000L);
|
||||||
|
Assert.fail("lost claim must stop materialization");
|
||||||
|
} catch (TriggerClaimLostException expected) {
|
||||||
|
// 第二个分块守卫失败后立即退出。
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(store).deleteAll(
|
||||||
|
org.mockito.ArgumentMatchers.argThat(
|
||||||
|
keys -> keys.size() == 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证锁外物化只依赖稳定 claim,合法实例锁代际推进不会中断后续分块。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldMaterializeAllChunksWithStableClaimGuard()
|
||||||
|
throws Exception {
|
||||||
|
LoopResultRepositoryImpl repository =
|
||||||
|
new LoopResultRepositoryImpl();
|
||||||
|
VersionedObjectStore store =
|
||||||
|
mock(VersionedObjectStore.class);
|
||||||
|
Field field = LoopResultRepositoryImpl.class
|
||||||
|
.getDeclaredField("versionedObjectStore");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(repository, store);
|
||||||
|
when(store.createIfAbsent(
|
||||||
|
anyString(),
|
||||||
|
any(Serializable.class),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
anyLong(),
|
||||||
|
any(Duration.class))).thenReturn(true);
|
||||||
|
List<Integer> input = java.util.stream.IntStream
|
||||||
|
.range(0, LoopResultRepositoryImpl.CHUNK_SIZE + 1)
|
||||||
|
.boxed()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
int stored = repository.storeProducedInput(
|
||||||
|
"instance",
|
||||||
|
0L,
|
||||||
|
"claim",
|
||||||
|
7L,
|
||||||
|
"instance:stable-input",
|
||||||
|
sink -> input.forEach(sink),
|
||||||
|
10_000L);
|
||||||
|
|
||||||
|
Assert.assertEquals(input.size(), stored);
|
||||||
|
verify(store, times(3)).createIfAbsent(
|
||||||
|
anyString(),
|
||||||
|
any(Serializable.class),
|
||||||
|
anyLong(),
|
||||||
|
anyString(),
|
||||||
|
eq(7L),
|
||||||
|
any(Duration.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用内存 Map 隔离 JetCache 的测试仓储。
|
||||||
|
*/
|
||||||
|
private static final class InMemoryLoopResultRepository extends LoopResultRepositoryImpl {
|
||||||
|
|
||||||
|
private final Map<String, Object> values = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将分块写入测试内存。
|
||||||
|
*
|
||||||
|
* @param key 缓存键
|
||||||
|
* @param value 缓存值
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void putCache(String key, Object value) {
|
||||||
|
values.put(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从测试内存读取分块。
|
||||||
|
*
|
||||||
|
* @param key 缓存键
|
||||||
|
* @param clazz 期望类型
|
||||||
|
* @param <T> 缓存值类型
|
||||||
|
* @return 命中的分块
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected <T> T getCache(String key, Class<T> clazz) {
|
||||||
|
return clazz.cast(values.get(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.repository;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.runtime.Trigger;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.ArgumentMatchers;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.ValueOperations;
|
||||||
|
import org.springframework.data.redis.core.ZSetOperations;
|
||||||
|
import org.springframework.data.redis.core.script.RedisScript;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link RedisTriggerStore} 分布式认领语义回归测试。
|
||||||
|
*/
|
||||||
|
public class RedisTriggerStoreTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证同一到期窗口超过 200 条任务时仍可一次填充本地调度容量。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试触发器序列化失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public void findDueShouldLoadMoreThanLegacyBatchLimit()
|
||||||
|
throws Exception {
|
||||||
|
StringRedisTemplate redisTemplate =
|
||||||
|
Mockito.mock(StringRedisTemplate.class);
|
||||||
|
ZSetOperations<String, String> zSetOperations =
|
||||||
|
Mockito.mock(ZSetOperations.class);
|
||||||
|
ValueOperations<String, String> valueOperations =
|
||||||
|
Mockito.mock(ValueOperations.class);
|
||||||
|
Mockito.when(redisTemplate.opsForZSet())
|
||||||
|
.thenReturn(zSetOperations);
|
||||||
|
Mockito.when(redisTemplate.opsForValue())
|
||||||
|
.thenReturn(valueOperations);
|
||||||
|
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
Set<String> ids = new LinkedHashSet<>();
|
||||||
|
List<String> payloads = new ArrayList<>();
|
||||||
|
for (int index = 0; index < 512; index++) {
|
||||||
|
String id = "due-" + index;
|
||||||
|
Trigger trigger = new Trigger();
|
||||||
|
trigger.setId(id);
|
||||||
|
trigger.setStateInstanceId(
|
||||||
|
"instance-" + index);
|
||||||
|
trigger.setTriggerAt(1000L);
|
||||||
|
ids.add(id);
|
||||||
|
payloads.add(
|
||||||
|
objectMapper.writeValueAsString(
|
||||||
|
trigger));
|
||||||
|
}
|
||||||
|
Mockito.when(zSetOperations.rangeByScore(
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyDouble(),
|
||||||
|
ArgumentMatchers.anyDouble(),
|
||||||
|
ArgumentMatchers.eq(0L),
|
||||||
|
ArgumentMatchers.eq(1024L)))
|
||||||
|
.thenReturn(ids);
|
||||||
|
Mockito.when(valueOperations.multiGet(
|
||||||
|
ArgumentMatchers.anyList()))
|
||||||
|
.thenReturn(payloads);
|
||||||
|
RedisTriggerStore store =
|
||||||
|
new RedisTriggerStore(
|
||||||
|
redisTemplate,
|
||||||
|
objectMapper);
|
||||||
|
|
||||||
|
List<Trigger> due =
|
||||||
|
store.findDue(1000L);
|
||||||
|
|
||||||
|
Assert.assertEquals(512, due.size());
|
||||||
|
Mockito.verify(zSetOperations)
|
||||||
|
.rangeByScore(
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.eq(0.0),
|
||||||
|
ArgumentMatchers.eq(1000.0),
|
||||||
|
ArgumentMatchers.eq(0L),
|
||||||
|
ArgumentMatchers.eq(1024L));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证稳定触发器通过单条 Redis 脚本完成存在性判断和创建。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||||
|
public void saveIfAbsentShouldUseAtomicRedisScript() {
|
||||||
|
StringRedisTemplate redisTemplate =
|
||||||
|
Mockito.mock(StringRedisTemplate.class);
|
||||||
|
Mockito.doReturn(1L).when(redisTemplate).execute(
|
||||||
|
ArgumentMatchers.<RedisScript<Long>>any(),
|
||||||
|
ArgumentMatchers.<List<String>>any(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString());
|
||||||
|
RedisTriggerStore store =
|
||||||
|
new RedisTriggerStore(
|
||||||
|
redisTemplate,
|
||||||
|
new ObjectMapper());
|
||||||
|
Trigger trigger = new Trigger();
|
||||||
|
trigger.setId("stable-trigger");
|
||||||
|
trigger.setTriggerAt(
|
||||||
|
System.currentTimeMillis());
|
||||||
|
|
||||||
|
Assert.assertTrue(
|
||||||
|
store.saveIfAbsent(trigger));
|
||||||
|
|
||||||
|
ArgumentCaptor<RedisScript<Long>>
|
||||||
|
scriptCaptor =
|
||||||
|
ArgumentCaptor.forClass(
|
||||||
|
(Class) RedisScript.class);
|
||||||
|
Mockito.verify(redisTemplate).execute(
|
||||||
|
scriptCaptor.capture(),
|
||||||
|
ArgumentMatchers.<List<String>>any(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString());
|
||||||
|
String script =
|
||||||
|
scriptCaptor.getValue()
|
||||||
|
.getScriptAsString();
|
||||||
|
Assert.assertTrue(script.contains(
|
||||||
|
"exists', KEYS[1]"));
|
||||||
|
Assert.assertTrue(script.contains(
|
||||||
|
"psetex', KEYS[1]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证认领触发器分配一次独立代际,并创建与该 trigger claim 绑定的执行守卫。
|
||||||
|
*
|
||||||
|
* <p>认领代际与实例锁 fencing token 使用不同计数器,claim 不推进实例锁 fence。</p>
|
||||||
|
*
|
||||||
|
* @throws Exception JSON 构造失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||||
|
public void claimShouldCreateTriggerScopedExecutionGuard() throws Exception {
|
||||||
|
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
Trigger stored = new Trigger();
|
||||||
|
stored.setId("trigger-1");
|
||||||
|
stored.setStateInstanceId("instance-1");
|
||||||
|
stored.setTriggerAt(System.currentTimeMillis());
|
||||||
|
stored.setFencingToken(7L);
|
||||||
|
String payload = objectMapper.writeValueAsString(stored);
|
||||||
|
Mockito.doReturn("8\n" + payload).when(redisTemplate).execute(
|
||||||
|
ArgumentMatchers.<RedisScript<String>>any(),
|
||||||
|
ArgumentMatchers.<List<String>>any(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString());
|
||||||
|
RedisTriggerStore store =
|
||||||
|
new RedisTriggerStore(redisTemplate, objectMapper);
|
||||||
|
|
||||||
|
Trigger claimed = store.claim(stored, 60_000L);
|
||||||
|
|
||||||
|
Assert.assertNotNull(claimed);
|
||||||
|
Assert.assertEquals(8L, claimed.getFencingToken());
|
||||||
|
ArgumentCaptor<RedisScript<String>> scriptCaptor =
|
||||||
|
ArgumentCaptor.forClass((Class) RedisScript.class);
|
||||||
|
ArgumentCaptor<List<String>> keysCaptor =
|
||||||
|
ArgumentCaptor.forClass((Class) List.class);
|
||||||
|
Mockito.verify(redisTemplate).execute(
|
||||||
|
scriptCaptor.capture(),
|
||||||
|
keysCaptor.capture(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.anyString());
|
||||||
|
Assert.assertTrue(
|
||||||
|
scriptCaptor.getValue().getScriptAsString().contains(
|
||||||
|
"hset', KEYS[4], 'version'"));
|
||||||
|
Assert.assertEquals(
|
||||||
|
"workflowState:{instance-1}:claim:trigger-1",
|
||||||
|
keysCaptor.getValue().get(3));
|
||||||
|
Assert.assertEquals(
|
||||||
|
"workflowState:{instance-1}:claim-seq",
|
||||||
|
keysCaptor.getValue().get(4));
|
||||||
|
Assert.assertTrue(
|
||||||
|
scriptCaptor.getValue().getScriptAsString().contains(
|
||||||
|
"hincrby', KEYS[5], 'version'"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.repository;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.config.WorkflowRuntimeProperties;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.event.WorkflowDefinitionChangedEvent;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WorkflowDefinitionCache} 命中、失效和编译去重回归测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowDefinitionCacheTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证同一版本重复执行只编译一次。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldCompileOnlyOnceForRepeatedReads() {
|
||||||
|
InMemoryVersionStore versionStore = new InMemoryVersionStore();
|
||||||
|
WorkflowDefinitionCache cache = cache(versionStore);
|
||||||
|
AtomicInteger loads = new AtomicInteger();
|
||||||
|
|
||||||
|
ChainDefinition first = cache.get("1", () -> definition("1", loads));
|
||||||
|
ChainDefinition second = cache.get("1", () -> definition("1", loads));
|
||||||
|
|
||||||
|
Assert.assertSame(first, second);
|
||||||
|
Assert.assertEquals(1, loads.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证工作流变更后草稿态和发布态缓存同时失效。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldInvalidateDraftAndPublishedDefinitionsTogether() {
|
||||||
|
InMemoryVersionStore versionStore = new InMemoryVersionStore();
|
||||||
|
WorkflowDefinitionCache cache = cache(versionStore);
|
||||||
|
AtomicInteger loads = new AtomicInteger();
|
||||||
|
String publishedId = PublishedWorkflowDefinitionIds.published("2");
|
||||||
|
|
||||||
|
ChainDefinition draftBefore = cache.get("2", () -> definition("2", loads));
|
||||||
|
ChainDefinition publishedBefore = cache.get(publishedId, () -> definition(publishedId, loads));
|
||||||
|
cache.onDefinitionChanged(new WorkflowDefinitionChangedEvent("2"));
|
||||||
|
ChainDefinition draftAfter = cache.get("2", () -> definition("2", loads));
|
||||||
|
ChainDefinition publishedAfter = cache.get(publishedId, () -> definition(publishedId, loads));
|
||||||
|
|
||||||
|
Assert.assertNotSame(draftBefore, draftAfter);
|
||||||
|
Assert.assertNotSame(publishedBefore, publishedAfter);
|
||||||
|
Assert.assertEquals(4, loads.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建测试缓存。
|
||||||
|
*
|
||||||
|
* @param versionStore 版本令牌仓储
|
||||||
|
* @return 定义缓存
|
||||||
|
*/
|
||||||
|
private WorkflowDefinitionCache cache(WorkflowDefinitionVersionStore versionStore) {
|
||||||
|
WorkflowRuntimeProperties properties = new WorkflowRuntimeProperties();
|
||||||
|
properties.setDefinitionCacheMaxEntries(4);
|
||||||
|
return new WorkflowDefinitionCache(versionStore, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建测试定义并记录编译次数。
|
||||||
|
*
|
||||||
|
* @param id 定义 ID
|
||||||
|
* @param loads 编译计数
|
||||||
|
* @return 工作流定义
|
||||||
|
*/
|
||||||
|
private ChainDefinition definition(String id, AtomicInteger loads) {
|
||||||
|
loads.incrementAndGet();
|
||||||
|
ChainDefinition definition = new ChainDefinition();
|
||||||
|
definition.setId(id);
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进程内版本令牌测试仓储。
|
||||||
|
*/
|
||||||
|
private static final class InMemoryVersionStore implements WorkflowDefinitionVersionStore {
|
||||||
|
|
||||||
|
private final Map<String, String> tokens = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String currentToken(String definitionId) {
|
||||||
|
return tokens.computeIfAbsent(definitionId, ignored -> UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void invalidateWorkflow(String workflowId) {
|
||||||
|
tokens.put(workflowId, UUID.randomUUID().toString());
|
||||||
|
tokens.put(PublishedWorkflowDefinitionIds.published(workflowId), UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.repository;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.NodeState;
|
||||||
|
import com.easyagents.flow.core.chain.repository.NodeStateField;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.support.WorkflowExecutionStepKey;
|
||||||
|
import tech.easyflow.common.cache.VersionedFields;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点字段化状态编码回归测试。
|
||||||
|
*/
|
||||||
|
public class WorkflowStateFieldsNodeTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证节点生命周期业务尝试键可跨 Redis 字段快照恢复。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldPreserveExecutionAttemptKey() {
|
||||||
|
NodeState state = new NodeState();
|
||||||
|
state.setNodeId("loop");
|
||||||
|
state.setChainInstanceId("instance");
|
||||||
|
state.setExecutionAttemptKey(
|
||||||
|
"instance:loop:trigger");
|
||||||
|
state.setVersion(7L);
|
||||||
|
|
||||||
|
Map<String, Object> encoded =
|
||||||
|
new LinkedHashMap<>(
|
||||||
|
WorkflowStateFields
|
||||||
|
.allNodeFields(state));
|
||||||
|
NodeState decoded =
|
||||||
|
WorkflowStateFields.decodeNode(
|
||||||
|
new VersionedFields(
|
||||||
|
7L,
|
||||||
|
encoded));
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
"instance:loop:trigger",
|
||||||
|
decoded
|
||||||
|
.getExecutionAttemptKey());
|
||||||
|
Assert.assertEquals(
|
||||||
|
7L, decoded.getVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证升级前在途节点沿用 memory.executeId,避免结束审计关联到新键。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRestoreLegacyExecutionKey() {
|
||||||
|
NodeState legacyState = new NodeState();
|
||||||
|
legacyState.setNodeId("loop");
|
||||||
|
legacyState.setChainInstanceId(
|
||||||
|
"instance");
|
||||||
|
legacyState.getMemory().put(
|
||||||
|
"executeId",
|
||||||
|
"legacy-step-key");
|
||||||
|
|
||||||
|
Map<String, Object> encoded =
|
||||||
|
new LinkedHashMap<>(
|
||||||
|
WorkflowStateFields
|
||||||
|
.allNodeFields(
|
||||||
|
legacyState));
|
||||||
|
encoded.remove(
|
||||||
|
NodeStateField
|
||||||
|
.EXECUTION_ATTEMPT_KEY
|
||||||
|
.name());
|
||||||
|
|
||||||
|
NodeState decoded =
|
||||||
|
WorkflowStateFields.decodeNode(
|
||||||
|
new VersionedFields(
|
||||||
|
3L,
|
||||||
|
encoded));
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
"legacy-step-key",
|
||||||
|
WorkflowExecutionStepKey.resolve(
|
||||||
|
decoded
|
||||||
|
.getExecutionAttemptKey()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证旧对象快照同样补齐最终执行键。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldNormalizeLegacyObjectState() {
|
||||||
|
NodeState legacyState = new NodeState();
|
||||||
|
legacyState.getMemory().put(
|
||||||
|
"executeId",
|
||||||
|
"legacy-object-step");
|
||||||
|
|
||||||
|
WorkflowStateFields.normalizeNode(
|
||||||
|
legacyState);
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
"legacy-object-step",
|
||||||
|
WorkflowExecutionStepKey.resolve(
|
||||||
|
legacyState
|
||||||
|
.getExecutionAttemptKey()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.service;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.ChainState;
|
||||||
|
import com.easyagents.flow.core.chain.ChainStatus;
|
||||||
|
import com.easyagents.flow.core.chain.NodeState;
|
||||||
|
import com.easyagents.flow.core.chain.NodeStatus;
|
||||||
|
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
||||||
|
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
|
||||||
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流设计器状态轮询服务测试。
|
||||||
|
*/
|
||||||
|
public class TinyFlowServiceTest {
|
||||||
|
|
||||||
|
private static final String EXECUTE_ID = "execution-1";
|
||||||
|
private static final String NODE_ID = "node-1";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证尚未启动的节点返回 READY,且一次轮询只读取一次工作流状态。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReturnReadyForMissingNodeStateWithoutRepeatedChainReads()
|
||||||
|
throws Exception {
|
||||||
|
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||||
|
ChainStateRepository chainStateRepository =
|
||||||
|
mock(ChainStateRepository.class);
|
||||||
|
NodeStateRepository nodeStateRepository =
|
||||||
|
mock(NodeStateRepository.class);
|
||||||
|
ChainState chainState = new ChainState();
|
||||||
|
chainState.setStatus(ChainStatus.RUNNING);
|
||||||
|
when(chainExecutor.getChainStateRepository())
|
||||||
|
.thenReturn(chainStateRepository);
|
||||||
|
when(chainExecutor.getNodeStateRepository())
|
||||||
|
.thenReturn(nodeStateRepository);
|
||||||
|
when(chainStateRepository.load(EXECUTE_ID))
|
||||||
|
.thenReturn(chainState);
|
||||||
|
when(nodeStateRepository.load(EXECUTE_ID, NODE_ID))
|
||||||
|
.thenReturn(null);
|
||||||
|
TinyFlowService service = service(chainExecutor);
|
||||||
|
NodeInfo node = node(NodeStatus.SUCCEEDED);
|
||||||
|
|
||||||
|
ChainInfo result = service.getChainStatus(
|
||||||
|
EXECUTE_ID, List.of(node));
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
Integer.valueOf(ChainStatus.RUNNING.getValue()),
|
||||||
|
result.getStatus());
|
||||||
|
Assert.assertEquals(
|
||||||
|
Integer.valueOf(NodeStatus.READY.getValue()),
|
||||||
|
result.getNodes().get(NODE_ID).getStatus());
|
||||||
|
verify(chainStateRepository, times(1)).load(EXECUTE_ID);
|
||||||
|
verify(nodeStateRepository, times(1))
|
||||||
|
.load(EXECUTE_ID, NODE_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证已存在节点仍返回仓储中的真实执行状态。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReturnPersistedNodeStatus()
|
||||||
|
throws Exception {
|
||||||
|
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||||
|
ChainStateRepository chainStateRepository =
|
||||||
|
mock(ChainStateRepository.class);
|
||||||
|
NodeStateRepository nodeStateRepository =
|
||||||
|
mock(NodeStateRepository.class);
|
||||||
|
ChainState chainState = new ChainState();
|
||||||
|
chainState.setStatus(ChainStatus.RUNNING);
|
||||||
|
NodeState nodeState = new NodeState();
|
||||||
|
nodeState.setStatus(NodeStatus.RUNNING);
|
||||||
|
when(chainExecutor.getChainStateRepository())
|
||||||
|
.thenReturn(chainStateRepository);
|
||||||
|
when(chainExecutor.getNodeStateRepository())
|
||||||
|
.thenReturn(nodeStateRepository);
|
||||||
|
when(chainStateRepository.load(EXECUTE_ID))
|
||||||
|
.thenReturn(chainState);
|
||||||
|
when(nodeStateRepository.load(EXECUTE_ID, NODE_ID))
|
||||||
|
.thenReturn(nodeState);
|
||||||
|
TinyFlowService service = service(chainExecutor);
|
||||||
|
|
||||||
|
ChainInfo result = service.getChainStatus(
|
||||||
|
EXECUTE_ID, List.of(node(NodeStatus.READY)));
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
Integer.valueOf(NodeStatus.RUNNING.getValue()),
|
||||||
|
result.getNodes().get(NODE_ID).getStatus());
|
||||||
|
verify(chainStateRepository, times(1)).load(EXECUTE_ID);
|
||||||
|
verify(nodeStateRepository, times(1))
|
||||||
|
.load(EXECUTE_ID, NODE_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带指定初始状态的设计器节点。
|
||||||
|
*
|
||||||
|
* @param status 初始节点状态
|
||||||
|
* @return 设计器节点
|
||||||
|
*/
|
||||||
|
private NodeInfo node(NodeStatus status) {
|
||||||
|
NodeInfo node = new NodeInfo();
|
||||||
|
node.setNodeId(NODE_ID);
|
||||||
|
node.setStatus(status.getValue());
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建并注入执行器的轮询服务。
|
||||||
|
*
|
||||||
|
* @param chainExecutor 工作流执行器
|
||||||
|
* @return 已完成依赖注入的服务
|
||||||
|
* @throws Exception 反射访问失败时抛出
|
||||||
|
*/
|
||||||
|
private TinyFlowService service(ChainExecutor chainExecutor)
|
||||||
|
throws Exception {
|
||||||
|
TinyFlowService service = new TinyFlowService();
|
||||||
|
Field field = TinyFlowService.class.getDeclaredField(
|
||||||
|
"chainExecutor");
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(service, chainExecutor);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,89 @@ import java.util.Map;
|
|||||||
|
|
||||||
public class WorkflowCheckServiceTest {
|
public class WorkflowCheckServiceTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证普通节点循环次数必须处于 1~300。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldBlockConfiguredLoopCountAboveLimit() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
JSONObject codeData = data("循环处理");
|
||||||
|
codeData.put("loopEnable", true);
|
||||||
|
codeData.put("maxLoopCount", 301);
|
||||||
|
String content = workflowJson(
|
||||||
|
array(node("code-1", "codeNode", null, codeData)),
|
||||||
|
new JSONArray());
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(
|
||||||
|
content, WorkflowCheckStage.SAVE, null);
|
||||||
|
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "LOOP_COUNT_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证显式循环节点的固定次数不能为零。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldBlockFixedExplicitLoopCountZero() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
JSONObject loopData = data("循环");
|
||||||
|
JSONObject loopVar = new JSONObject();
|
||||||
|
loopVar.put("name", "loopVar");
|
||||||
|
loopVar.put("refType", "fixed");
|
||||||
|
loopVar.put("value", "0");
|
||||||
|
loopData.put("loopVars", array(loopVar));
|
||||||
|
String content = workflowJson(
|
||||||
|
array(node("loop-1", "loopNode", null, loopData)),
|
||||||
|
new JSONArray());
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(
|
||||||
|
content, WorkflowCheckStage.SAVE, null);
|
||||||
|
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "EXPLICIT_LOOP_COUNT_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证嵌套节点只能挂在显式循环节点下。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldBlockNonLoopParent() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("code-parent", "codeNode", null, data("父节点")),
|
||||||
|
node("code-child", "codeNode", "code-parent", data("子节点"))
|
||||||
|
),
|
||||||
|
new JSONArray());
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(
|
||||||
|
content, WorkflowCheckStage.SAVE, null);
|
||||||
|
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "NODE_PARENT_NOT_LOOP");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证显式循环嵌套层级不能形成 parentId 环。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testSaveShouldBlockLoopParentCycle() throws Exception {
|
||||||
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
String content = workflowJson(
|
||||||
|
array(
|
||||||
|
node("loop-a", "loopNode", "loop-b", data("循环 A")),
|
||||||
|
node("loop-b", "loopNode", "loop-a", data("循环 B"))
|
||||||
|
),
|
||||||
|
new JSONArray());
|
||||||
|
|
||||||
|
WorkflowCheckResult result = service.checkContent(
|
||||||
|
content, WorkflowCheckStage.SAVE, null);
|
||||||
|
|
||||||
|
Assert.assertFalse(result.isPassed());
|
||||||
|
assertHasCode(result, "LOOP_PARENT_CYCLE");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testSaveShouldPassForValidDraft() throws Exception {
|
public void testSaveShouldPassForValidDraft() throws Exception {
|
||||||
WorkflowCheckService service = newService(new HashMap<>());
|
WorkflowCheckService service = newService(new HashMap<>());
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package tech.easyflow.ai.node;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 临时文件 MultipartFile 适配测试。
|
||||||
|
*/
|
||||||
|
public class TemporaryFileMultipartFileTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证文件流、大小及 transferTo 均复用磁盘内容。
|
||||||
|
*
|
||||||
|
* @throws Exception 临时文件读写失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldExposeTemporaryFileWithoutChangingContent() throws Exception {
|
||||||
|
byte[] content = "workflow-streaming-file".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
Path source = Files.createTempFile("temporary-file-multipart-source-", ".txt");
|
||||||
|
Path target = Files.createTempFile("temporary-file-multipart-target-", ".txt");
|
||||||
|
try {
|
||||||
|
Files.write(source, content);
|
||||||
|
TemporaryFileMultipartFile file =
|
||||||
|
new TemporaryFileMultipartFile("result.txt", source, "text/plain");
|
||||||
|
|
||||||
|
Assert.assertEquals(content.length, file.getSize());
|
||||||
|
Assert.assertEquals("text/plain", file.getContentType());
|
||||||
|
Assert.assertArrayEquals(content, file.getInputStream().readAllBytes());
|
||||||
|
|
||||||
|
file.transferTo(target.toFile());
|
||||||
|
Assert.assertArrayEquals(content, Files.readAllBytes(target));
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(source);
|
||||||
|
Files.deleteIfExists(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package tech.easyflow.ai.utils;
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DocUtil 流式下载测试。
|
||||||
|
*/
|
||||||
|
public class DocUtilStreamingDownloadTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证大响应按流落盘、内容完整且关闭后清理临时文件。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试服务器或文件读取失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldStreamResponseToTemporaryFileAndCleanup() throws Exception {
|
||||||
|
byte[] content = new byte[2 * 1024 * 1024 + 17];
|
||||||
|
Arrays.fill(content, (byte) 7);
|
||||||
|
HttpServer server = startServer(content);
|
||||||
|
try {
|
||||||
|
String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/download";
|
||||||
|
java.nio.file.Path path;
|
||||||
|
try (DocUtil.DownloadedFile downloadedFile =
|
||||||
|
DocUtil.downloadFileToTemp(url, content.length + 1L)) {
|
||||||
|
path = downloadedFile.path();
|
||||||
|
Assert.assertEquals(content.length, downloadedFile.size());
|
||||||
|
Assert.assertEquals("application/octet-stream", downloadedFile.contentType());
|
||||||
|
Assert.assertArrayEquals(content, Files.readAllBytes(path));
|
||||||
|
}
|
||||||
|
Assert.assertFalse(Files.exists(path));
|
||||||
|
} finally {
|
||||||
|
server.stop(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证超过配置上限时显式失败。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试服务器初始化失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectResponseAboveConfiguredLimit() throws Exception {
|
||||||
|
byte[] content = new byte[1024];
|
||||||
|
HttpServer server = startServer(content);
|
||||||
|
try {
|
||||||
|
String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/download";
|
||||||
|
try {
|
||||||
|
DocUtil.downloadFileToTemp(url, content.length - 1L);
|
||||||
|
Assert.fail("expected download limit failure");
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
Assert.assertTrue(exception.getCause().getMessage().contains("超过限制"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
server.stop(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动仅用于本测试的本地 HTTP 文件服务。
|
||||||
|
*
|
||||||
|
* @param content 响应内容
|
||||||
|
* @return 已启动的 HTTP 服务
|
||||||
|
* @throws Exception 服务创建失败时抛出
|
||||||
|
*/
|
||||||
|
private HttpServer startServer(byte[] content) throws Exception {
|
||||||
|
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
|
server.createContext("/download", exchange -> {
|
||||||
|
exchange.getResponseHeaders().set("Content-Type", "application/octet-stream");
|
||||||
|
exchange.sendResponseHeaders(200, content.length);
|
||||||
|
try (OutputStream output = exchange.getResponseBody()) {
|
||||||
|
for (int offset = 0; offset < content.length; offset += 8192) {
|
||||||
|
int length = Math.min(8192, content.length - offset);
|
||||||
|
output.write(content, offset, length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.start();
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
package tech.easyflow.datacenter.connector.impl;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.datacenter.entity.DatacenterTable;
|
||||||
|
import tech.easyflow.datacenter.entity.DatacenterTableField;
|
||||||
|
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.ResultSetMetaData;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.atLeastOnce;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目 MySQL 连接器批量写入测试。
|
||||||
|
*/
|
||||||
|
public class ProjectMysqlConnectorBatchTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证原始 SQL 在单连接、单 ResultSet 中原样流式消费。
|
||||||
|
*
|
||||||
|
* @throws Exception JDBC 模拟初始化失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldStreamOriginalSqlWithoutPaginationRewrite()
|
||||||
|
throws Exception {
|
||||||
|
DataSource dataSource = mock(DataSource.class);
|
||||||
|
Connection connection = mock(Connection.class);
|
||||||
|
PreparedStatement statement =
|
||||||
|
mock(PreparedStatement.class);
|
||||||
|
ResultSet resultSet = mock(ResultSet.class);
|
||||||
|
ResultSetMetaData metadata =
|
||||||
|
mock(ResultSetMetaData.class);
|
||||||
|
String sql =
|
||||||
|
"SELECT id FROM sample LIMIT 10 FOR UPDATE";
|
||||||
|
when(dataSource.getConnection())
|
||||||
|
.thenReturn(connection);
|
||||||
|
when(connection.prepareStatement(
|
||||||
|
eq(sql),
|
||||||
|
eq(ResultSet.TYPE_FORWARD_ONLY),
|
||||||
|
eq(ResultSet.CONCUR_READ_ONLY)))
|
||||||
|
.thenReturn(statement);
|
||||||
|
when(statement.executeQuery())
|
||||||
|
.thenReturn(resultSet);
|
||||||
|
when(resultSet.getMetaData())
|
||||||
|
.thenReturn(metadata);
|
||||||
|
when(metadata.getColumnCount()).thenReturn(1);
|
||||||
|
when(metadata.getColumnLabel(1)).thenReturn("id");
|
||||||
|
when(resultSet.next())
|
||||||
|
.thenReturn(true, false);
|
||||||
|
when(resultSet.getObject(1))
|
||||||
|
.thenReturn(1L);
|
||||||
|
List<String> ids = new ArrayList<>();
|
||||||
|
|
||||||
|
new ProjectMysqlConnector(dataSource)
|
||||||
|
.consumeBySql(
|
||||||
|
source(),
|
||||||
|
sql,
|
||||||
|
1_000,
|
||||||
|
row -> ids.add(
|
||||||
|
row.getString("id")));
|
||||||
|
|
||||||
|
org.junit.Assert.assertEquals(
|
||||||
|
List.of("1"), ids);
|
||||||
|
verify(dataSource).getConnection();
|
||||||
|
verify(connection).prepareStatement(
|
||||||
|
sql,
|
||||||
|
ResultSet.TYPE_FORWARD_ONLY,
|
||||||
|
ResultSet.CONCUR_READ_ONLY);
|
||||||
|
verify(statement).setFetchSize(
|
||||||
|
Integer.MIN_VALUE);
|
||||||
|
verify(statement).executeQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证多行写入仅获取一次连接,并按批次执行 JDBC batch。
|
||||||
|
*
|
||||||
|
* @throws Exception JDBC 模拟初始化失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReuseSingleConnectionAndExecuteConfiguredBatches() throws Exception {
|
||||||
|
DataSource dataSource = mock(DataSource.class);
|
||||||
|
Connection connection = mock(Connection.class);
|
||||||
|
List<PreparedStatement> statements = new ArrayList<>();
|
||||||
|
when(dataSource.getConnection()).thenReturn(connection);
|
||||||
|
when(connection.prepareStatement(anyString())).thenAnswer(invocation -> {
|
||||||
|
PreparedStatement statement = mock(PreparedStatement.class);
|
||||||
|
statements.add(statement);
|
||||||
|
return statement;
|
||||||
|
});
|
||||||
|
|
||||||
|
ProjectMysqlConnector connector = new ProjectMysqlConnector(dataSource);
|
||||||
|
DatacenterSource source = new DatacenterSource();
|
||||||
|
source.setDatabaseName("easyflow");
|
||||||
|
DatacenterTable table = new DatacenterTable();
|
||||||
|
table.setTableName("sample");
|
||||||
|
DatacenterTableField nameField = new DatacenterTableField();
|
||||||
|
nameField.setFieldName("name");
|
||||||
|
nameField.setWritable(1);
|
||||||
|
table.setFields(List.of(nameField));
|
||||||
|
|
||||||
|
List<JSONObject> rows = new ArrayList<>();
|
||||||
|
for (int index = 0; index < 5; index++) {
|
||||||
|
JSONObject row = new JSONObject();
|
||||||
|
row.put("name", "row-" + index);
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
connector.saveRows(source, table, rows, null, 2);
|
||||||
|
|
||||||
|
verify(dataSource, times(1)).getConnection();
|
||||||
|
if (statements.size() != 3) {
|
||||||
|
throw new AssertionError("expected 3 JDBC batches but got " + statements.size());
|
||||||
|
}
|
||||||
|
int addBatchCalls = 0;
|
||||||
|
for (PreparedStatement statement : statements) {
|
||||||
|
verify(statement, times(1)).executeBatch();
|
||||||
|
addBatchCalls += org.mockito.Mockito.mockingDetails(statement)
|
||||||
|
.getInvocations()
|
||||||
|
.stream()
|
||||||
|
.filter(invocation -> "addBatch".equals(invocation.getMethod().getName()))
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
if (addBatchCalls != rows.size()) {
|
||||||
|
throw new AssertionError("expected " + rows.size() + " addBatch calls but got " + addBatchCalls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证回执和业务批量写入在同一 JDBC 事务中提交。
|
||||||
|
*
|
||||||
|
* @throws Exception JDBC 模拟初始化失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldCommitReceiptAndRowsInSingleTransaction() throws Exception {
|
||||||
|
DataSource dataSource = mock(DataSource.class);
|
||||||
|
Connection connection = mock(Connection.class);
|
||||||
|
PreparedStatement receiptStatement = mock(PreparedStatement.class);
|
||||||
|
PreparedStatement queryStatement = mock(PreparedStatement.class);
|
||||||
|
PreparedStatement rowStatement = mock(PreparedStatement.class);
|
||||||
|
ResultSet resultSet = mock(ResultSet.class);
|
||||||
|
when(dataSource.getConnection()).thenReturn(connection);
|
||||||
|
when(connection.getAutoCommit()).thenReturn(true);
|
||||||
|
when(connection.prepareStatement(anyString())).thenAnswer(invocation -> {
|
||||||
|
String sql = invocation.getArgument(0);
|
||||||
|
if (sql.startsWith("SELECT")) {
|
||||||
|
return queryStatement;
|
||||||
|
}
|
||||||
|
return sql.contains("tb_datacenter_write_receipt")
|
||||||
|
? receiptStatement
|
||||||
|
: rowStatement;
|
||||||
|
});
|
||||||
|
when(queryStatement.executeQuery()).thenReturn(resultSet);
|
||||||
|
when(resultSet.next()).thenReturn(false);
|
||||||
|
|
||||||
|
ProjectMysqlConnector connector = new ProjectMysqlConnector(dataSource);
|
||||||
|
DatacenterSource source = source();
|
||||||
|
DatacenterTable table = table();
|
||||||
|
JSONObject row = new JSONObject();
|
||||||
|
row.put("name", "row-1");
|
||||||
|
|
||||||
|
boolean written = connector.saveRowsIdempotently(
|
||||||
|
source,
|
||||||
|
table,
|
||||||
|
List.of(row),
|
||||||
|
null,
|
||||||
|
100,
|
||||||
|
"receipt-key",
|
||||||
|
"payload-hash");
|
||||||
|
|
||||||
|
assertTrue(written);
|
||||||
|
verify(connection).setAutoCommit(false);
|
||||||
|
verify(receiptStatement).executeBatch();
|
||||||
|
verify(receiptStatement).executeUpdate();
|
||||||
|
verify(rowStatement).executeBatch();
|
||||||
|
verify(connection, times(2)).commit();
|
||||||
|
verify(connection, never()).rollback();
|
||||||
|
verify(connection).setAutoCommit(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证业务批量失败时回执与业务数据一并回滚。
|
||||||
|
*
|
||||||
|
* @throws Exception JDBC 模拟初始化失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRollbackReceiptWhenBatchWriteFails() throws Exception {
|
||||||
|
DataSource dataSource = mock(DataSource.class);
|
||||||
|
Connection connection = mock(Connection.class);
|
||||||
|
PreparedStatement receiptStatement = mock(PreparedStatement.class);
|
||||||
|
PreparedStatement queryStatement = mock(PreparedStatement.class);
|
||||||
|
PreparedStatement rowStatement = mock(PreparedStatement.class);
|
||||||
|
ResultSet resultSet = mock(ResultSet.class);
|
||||||
|
when(dataSource.getConnection()).thenReturn(connection);
|
||||||
|
when(connection.getAutoCommit()).thenReturn(true);
|
||||||
|
when(connection.prepareStatement(anyString())).thenAnswer(invocation -> {
|
||||||
|
String sql = invocation.getArgument(0);
|
||||||
|
if (sql.startsWith("SELECT")) {
|
||||||
|
return queryStatement;
|
||||||
|
}
|
||||||
|
return sql.contains("tb_datacenter_write_receipt")
|
||||||
|
? receiptStatement
|
||||||
|
: rowStatement;
|
||||||
|
});
|
||||||
|
when(queryStatement.executeQuery()).thenReturn(resultSet);
|
||||||
|
when(resultSet.next()).thenReturn(false);
|
||||||
|
when(rowStatement.executeBatch()).thenThrow(new SQLException("write failed"));
|
||||||
|
|
||||||
|
ProjectMysqlConnector connector = new ProjectMysqlConnector(dataSource);
|
||||||
|
JSONObject row = new JSONObject();
|
||||||
|
row.put("name", "row-1");
|
||||||
|
|
||||||
|
try {
|
||||||
|
connector.saveRowsIdempotently(
|
||||||
|
source(),
|
||||||
|
table(),
|
||||||
|
List.of(row),
|
||||||
|
null,
|
||||||
|
100,
|
||||||
|
"receipt-key",
|
||||||
|
"payload-hash");
|
||||||
|
throw new AssertionError("failed business batch must rollback");
|
||||||
|
} catch (BusinessException expected) {
|
||||||
|
assertTrue(expected.getMessage().contains("write failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(connection, atLeastOnce()).rollback();
|
||||||
|
verify(connection, never()).commit();
|
||||||
|
verify(connection).setAutoCommit(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证中间行失败时前序行已经提交,后续行不会执行。
|
||||||
|
*
|
||||||
|
* @throws Exception JDBC 模拟初始化失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldKeepEarlierRowsCommittedWhenMiddleRowFails() throws Exception {
|
||||||
|
DataSource dataSource = mock(DataSource.class);
|
||||||
|
Connection connection = mock(Connection.class);
|
||||||
|
PreparedStatement receiptStatement = mock(PreparedStatement.class);
|
||||||
|
PreparedStatement queryStatement = mock(PreparedStatement.class);
|
||||||
|
PreparedStatement rowStatement = mock(PreparedStatement.class);
|
||||||
|
ResultSet resultSet = mock(ResultSet.class);
|
||||||
|
when(dataSource.getConnection()).thenReturn(connection);
|
||||||
|
when(connection.getAutoCommit()).thenReturn(true);
|
||||||
|
when(connection.prepareStatement(anyString())).thenAnswer(invocation -> {
|
||||||
|
String sql = invocation.getArgument(0);
|
||||||
|
if (sql.startsWith("SELECT")) {
|
||||||
|
return queryStatement;
|
||||||
|
}
|
||||||
|
return sql.contains("tb_datacenter_write_receipt")
|
||||||
|
? receiptStatement
|
||||||
|
: rowStatement;
|
||||||
|
});
|
||||||
|
when(queryStatement.executeQuery()).thenReturn(resultSet);
|
||||||
|
when(resultSet.next()).thenReturn(false);
|
||||||
|
when(rowStatement.executeBatch())
|
||||||
|
.thenThrow(new SQLException("batch failed"))
|
||||||
|
.thenReturn(new int[]{1})
|
||||||
|
.thenThrow(new SQLException("middle row failed"));
|
||||||
|
|
||||||
|
ProjectMysqlConnector connector = new ProjectMysqlConnector(dataSource);
|
||||||
|
List<JSONObject> rows = List.of(
|
||||||
|
row("row-0"), row("row-1"), row("row-2"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
connector.saveRowsIdempotently(
|
||||||
|
source(),
|
||||||
|
table(),
|
||||||
|
rows,
|
||||||
|
null,
|
||||||
|
100,
|
||||||
|
"receipt-key",
|
||||||
|
"payload-hash");
|
||||||
|
throw new AssertionError("middle row failure must be propagated");
|
||||||
|
} catch (BusinessException expected) {
|
||||||
|
assertTrue(expected.getMessage().contains("middle row failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(dataSource, times(1)).getConnection();
|
||||||
|
verify(connection, times(1)).commit();
|
||||||
|
verify(connection, atLeastOnce()).rollback();
|
||||||
|
verify(rowStatement, times(3)).executeBatch();
|
||||||
|
verify(receiptStatement).executeBatch();
|
||||||
|
verify(receiptStatement, times(2)).executeUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建测试数据源元数据。
|
||||||
|
*
|
||||||
|
* @return 项目 MySQL 数据源
|
||||||
|
*/
|
||||||
|
private DatacenterSource source() {
|
||||||
|
DatacenterSource source = new DatacenterSource();
|
||||||
|
source.setDatabaseName("easyflow");
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建包含一个可写字段的测试表。
|
||||||
|
*
|
||||||
|
* @return 测试数据表
|
||||||
|
*/
|
||||||
|
private DatacenterTable table() {
|
||||||
|
DatacenterTable table = new DatacenterTable();
|
||||||
|
table.setTableName("sample");
|
||||||
|
DatacenterTableField nameField = new DatacenterTableField();
|
||||||
|
nameField.setFieldName("name");
|
||||||
|
nameField.setWritable(1);
|
||||||
|
table.setFields(List.of(nameField));
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建测试数据行。
|
||||||
|
*
|
||||||
|
* @param name 行名称
|
||||||
|
* @return JSON 行
|
||||||
|
*/
|
||||||
|
private JSONObject row(String name) {
|
||||||
|
JSONObject row = new JSONObject();
|
||||||
|
row.put("name", name);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package tech.easyflow.datacenter.connector.support;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.mybatisflex.core.row.Db;
|
||||||
|
import com.mybatisflex.core.row.Row;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.datacenter.entity.DatacenterTable;
|
||||||
|
import tech.easyflow.datacenter.entity.DatacenterTableField;
|
||||||
|
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||||
|
import tech.easyflow.datacenter.meta.enums.DatacenterCapability;
|
||||||
|
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyMap;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部动态表幂等批量写入回归测试。
|
||||||
|
*/
|
||||||
|
public class AbstractInternalTableConnectorBatchTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证正常路径按批次写入回执和数据,不退化为逐行 SQL。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldBatchReceiptsAndRowsOnNormalPath() {
|
||||||
|
DatacenterTable table = mock(DatacenterTable.class);
|
||||||
|
DatacenterTableField field =
|
||||||
|
mock(DatacenterTableField.class);
|
||||||
|
org.mockito.Mockito.when(table.getFields())
|
||||||
|
.thenReturn(List.of(field));
|
||||||
|
org.mockito.Mockito.when(table.getMaterializedTable())
|
||||||
|
.thenReturn("tb_internal_test");
|
||||||
|
org.mockito.Mockito.when(field.getFieldName())
|
||||||
|
.thenReturn("name");
|
||||||
|
LoginAccount account = mock(LoginAccount.class);
|
||||||
|
org.mockito.Mockito.when(account.getId())
|
||||||
|
.thenReturn(BigInteger.ONE);
|
||||||
|
org.mockito.Mockito.when(account.getDeptId())
|
||||||
|
.thenReturn(BigInteger.ONE);
|
||||||
|
org.mockito.Mockito.when(account.getTenantId())
|
||||||
|
.thenReturn(BigInteger.ONE);
|
||||||
|
JSONObject first = JSONObject.of("name", "first");
|
||||||
|
JSONObject second = JSONObject.of("name", "second");
|
||||||
|
|
||||||
|
try (MockedStatic<Db> db = mockStatic(Db.class)) {
|
||||||
|
db.when(() -> Db.selectOneByMap(
|
||||||
|
eq("tb_datacenter_write_receipt"),
|
||||||
|
anyMap()))
|
||||||
|
.thenReturn(null);
|
||||||
|
db.when(() -> Db.txWithResult(
|
||||||
|
org.mockito.ArgumentMatchers
|
||||||
|
.<Supplier<Object>>any()))
|
||||||
|
.thenAnswer(invocation -> invocation
|
||||||
|
.<Supplier<?>>getArgument(0)
|
||||||
|
.get());
|
||||||
|
|
||||||
|
boolean written = new TestInternalConnector()
|
||||||
|
.saveRowsIdempotently(
|
||||||
|
new DatacenterSource(),
|
||||||
|
table,
|
||||||
|
List.of(first, second),
|
||||||
|
account,
|
||||||
|
2,
|
||||||
|
"receipt",
|
||||||
|
"hash");
|
||||||
|
|
||||||
|
Assert.assertTrue(written);
|
||||||
|
db.verify(() -> Db.insertBatch(
|
||||||
|
eq("tb_datacenter_write_receipt"),
|
||||||
|
anyCollection(),
|
||||||
|
eq(2)));
|
||||||
|
db.verify(() -> Db.insertBatch(
|
||||||
|
eq("tb_internal_test"),
|
||||||
|
anyCollection(),
|
||||||
|
eq(2)));
|
||||||
|
db.verify(() -> Db.updateBatchById(
|
||||||
|
eq("tb_internal_test"),
|
||||||
|
anyList()), never());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅用于测试内部动态表批量协议的最小连接器。
|
||||||
|
*/
|
||||||
|
private static final class TestInternalConnector
|
||||||
|
extends AbstractInternalTableConnector {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建测试连接器。
|
||||||
|
*/
|
||||||
|
private TestInternalConnector() {
|
||||||
|
super(
|
||||||
|
DatacenterSourceType.EXCEL,
|
||||||
|
Collections.<DatacenterCapability>emptySet(),
|
||||||
|
mock(DataSource.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package tech.easyflow.datacenter.connector.support;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.InOrder;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import tech.easyflow.datacenter.connector.dialect.PostgresqlSqlDialect;
|
||||||
|
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||||
|
import tech.easyflow.datacenter.meta.enums.DatacenterCapability;
|
||||||
|
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.ResultSetMetaData;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostgreSQL 服务端游标连接状态回归测试。
|
||||||
|
*/
|
||||||
|
public class PostgresqlStreamingConnectorTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证自动提交连接进入游标事务并在消费完成后恢复。
|
||||||
|
*
|
||||||
|
* @throws Exception JDBC 模拟调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRestoreAutoCommitAfterStreaming()
|
||||||
|
throws Exception {
|
||||||
|
Connection connection =
|
||||||
|
Mockito.mock(Connection.class);
|
||||||
|
PreparedStatement statement =
|
||||||
|
Mockito.mock(
|
||||||
|
PreparedStatement.class);
|
||||||
|
ResultSet resultSet =
|
||||||
|
Mockito.mock(ResultSet.class);
|
||||||
|
ResultSetMetaData metaData =
|
||||||
|
Mockito.mock(
|
||||||
|
ResultSetMetaData.class);
|
||||||
|
Mockito.when(connection.getAutoCommit())
|
||||||
|
.thenReturn(true);
|
||||||
|
Mockito.when(connection.prepareStatement(
|
||||||
|
"SELECT id FROM sample",
|
||||||
|
ResultSet.TYPE_FORWARD_ONLY,
|
||||||
|
ResultSet.CONCUR_READ_ONLY))
|
||||||
|
.thenReturn(statement);
|
||||||
|
Mockito.when(statement.executeQuery())
|
||||||
|
.thenReturn(resultSet);
|
||||||
|
Mockito.when(resultSet.getMetaData())
|
||||||
|
.thenReturn(metaData);
|
||||||
|
Mockito.when(resultSet.next())
|
||||||
|
.thenReturn(false);
|
||||||
|
TestConnector connector =
|
||||||
|
new TestConnector(connection);
|
||||||
|
|
||||||
|
connector.consumeBySql(
|
||||||
|
new DatacenterSource(),
|
||||||
|
"SELECT id FROM sample",
|
||||||
|
512,
|
||||||
|
row -> {
|
||||||
|
});
|
||||||
|
|
||||||
|
InOrder order = Mockito.inOrder(
|
||||||
|
connection,
|
||||||
|
statement,
|
||||||
|
resultSet);
|
||||||
|
order.verify(connection)
|
||||||
|
.getAutoCommit();
|
||||||
|
order.verify(connection)
|
||||||
|
.setAutoCommit(false);
|
||||||
|
order.verify(connection)
|
||||||
|
.prepareStatement(
|
||||||
|
"SELECT id FROM sample",
|
||||||
|
ResultSet.TYPE_FORWARD_ONLY,
|
||||||
|
ResultSet.CONCUR_READ_ONLY);
|
||||||
|
order.verify(statement)
|
||||||
|
.setFetchSize(512);
|
||||||
|
order.verify(statement)
|
||||||
|
.executeQuery();
|
||||||
|
order.verify(resultSet)
|
||||||
|
.close();
|
||||||
|
order.verify(statement)
|
||||||
|
.close();
|
||||||
|
order.verify(connection)
|
||||||
|
.rollback();
|
||||||
|
order.verify(connection)
|
||||||
|
.setAutoCommit(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用测试连接执行 PostgreSQL 查询。
|
||||||
|
*/
|
||||||
|
private static final class TestConnector
|
||||||
|
extends AbstractJdbcConnector {
|
||||||
|
|
||||||
|
private final Connection connection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建测试连接器。
|
||||||
|
*
|
||||||
|
* @param connection 测试 JDBC 连接
|
||||||
|
*/
|
||||||
|
private TestConnector(
|
||||||
|
Connection connection) {
|
||||||
|
super(
|
||||||
|
DatacenterSourceType.POSTGRESQL,
|
||||||
|
new PostgresqlSqlDialect(),
|
||||||
|
EnumSet.of(
|
||||||
|
DatacenterCapability.READ_QUERY));
|
||||||
|
this.connection = connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected <T> T withConnection(
|
||||||
|
DatacenterSource source,
|
||||||
|
boolean cacheable,
|
||||||
|
JdbcCallback<T> callback)
|
||||||
|
throws Exception {
|
||||||
|
return callback.apply(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package tech.easyflow.datacenter.execution.service.impl;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import tech.easyflow.common.cache.RedisIdempotencyExecutor;
|
||||||
|
import tech.easyflow.datacenter.connector.DatacenterConnector;
|
||||||
|
import tech.easyflow.datacenter.connector.DatacenterConnectorRegistry;
|
||||||
|
import tech.easyflow.datacenter.entity.DatacenterTable;
|
||||||
|
import tech.easyflow.datacenter.execution.model.DatasetRef;
|
||||||
|
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||||
|
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
|
||||||
|
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据集写入服务的幂等批写测试。
|
||||||
|
*/
|
||||||
|
public class DatacenterDatasetWriteServiceImplTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证服务只调用一次连接器,并保留配置的批大小供连接器复用连接处理。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldDelegateIdempotentRowsInSingleConnectorCall() throws Exception {
|
||||||
|
DatacenterDatasetRegistryService registryService =
|
||||||
|
mock(DatacenterDatasetRegistryService.class);
|
||||||
|
DatacenterConnectorRegistry connectorRegistry =
|
||||||
|
mock(DatacenterConnectorRegistry.class);
|
||||||
|
DatacenterConnector connector = mock(DatacenterConnector.class);
|
||||||
|
RedisIdempotencyExecutor idempotencyExecutor =
|
||||||
|
mock(RedisIdempotencyExecutor.class);
|
||||||
|
|
||||||
|
BigInteger tableId = BigInteger.ONE;
|
||||||
|
BigInteger sourceId = BigInteger.TWO;
|
||||||
|
DatasetRef datasetRef = new DatasetRef();
|
||||||
|
datasetRef.setTableId(tableId);
|
||||||
|
DatacenterTable table = new DatacenterTable();
|
||||||
|
table.setSourceId(sourceId);
|
||||||
|
DatacenterSource source = new DatacenterSource();
|
||||||
|
source.setSourceType(DatacenterSourceType.PROJECT_MYSQL.name());
|
||||||
|
when(registryService.getTableWithFields(tableId)).thenReturn(table);
|
||||||
|
when(registryService.getSourceRequired(sourceId)).thenReturn(source);
|
||||||
|
when(connectorRegistry.getConnector(
|
||||||
|
DatacenterSourceType.PROJECT_MYSQL.name())).thenReturn(connector);
|
||||||
|
when(idempotencyExecutor.executeOnce(
|
||||||
|
anyString(), anyString(), any(Runnable.class)))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
invocation.<Runnable>getArgument(2).run();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
when(connector.saveRowsIdempotently(
|
||||||
|
any(), any(), anyList(), any(), anyInt(), anyString(), anyString()))
|
||||||
|
.thenReturn(true);
|
||||||
|
|
||||||
|
DatacenterDatasetWriteServiceImpl service =
|
||||||
|
new DatacenterDatasetWriteServiceImpl();
|
||||||
|
inject(service, "registryService", registryService);
|
||||||
|
inject(service, "connectorRegistry", connectorRegistry);
|
||||||
|
inject(service, "idempotencyExecutor", idempotencyExecutor);
|
||||||
|
List<JSONObject> rows = List.of(
|
||||||
|
row("row-0"), row("row-1"), row("row-2"));
|
||||||
|
|
||||||
|
Assert.assertTrue(service.saveRowsIdempotently(
|
||||||
|
datasetRef, rows, null, 64, "stable-execution-key"));
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<List<JSONObject>> rowsCaptor =
|
||||||
|
ArgumentCaptor.forClass(List.class);
|
||||||
|
verify(connector, times(1)).saveRowsIdempotently(
|
||||||
|
eq(source),
|
||||||
|
eq(table),
|
||||||
|
rowsCaptor.capture(),
|
||||||
|
any(),
|
||||||
|
eq(64),
|
||||||
|
anyString(),
|
||||||
|
anyString());
|
||||||
|
Assert.assertEquals(rows, rowsCaptor.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建测试行。
|
||||||
|
*
|
||||||
|
* @param name 行名称
|
||||||
|
* @return JSON 行
|
||||||
|
*/
|
||||||
|
private JSONObject row(String name) {
|
||||||
|
JSONObject row = new JSONObject();
|
||||||
|
row.put("name", name);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注入服务测试依赖。
|
||||||
|
*
|
||||||
|
* @param target 目标服务
|
||||||
|
* @param fieldName 字段名
|
||||||
|
* @param value 字段值
|
||||||
|
* @throws Exception 反射访问失败时抛出
|
||||||
|
*/
|
||||||
|
private void inject(Object target, String fieldName, Object value) throws Exception {
|
||||||
|
Field field = target.getClass().getDeclaredField(fieldName);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(target, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package tech.easyflow.datacenter.schedule;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据集写入回执清理任务测试。
|
||||||
|
*/
|
||||||
|
public class DatacenterWriteReceiptCleanupJobTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证清理任务按固定大小分批,并在最后一个非满批次后停止。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldDeleteExpiredReceiptsInBoundedBatches() {
|
||||||
|
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||||
|
when(jdbcTemplate.update(anyString(), any(), anyInt()))
|
||||||
|
.thenReturn(1000, 7);
|
||||||
|
DatacenterWriteReceiptCleanupJob job =
|
||||||
|
new DatacenterWriteReceiptCleanupJob(
|
||||||
|
jdbcTemplate, 14L, 1000, 20);
|
||||||
|
|
||||||
|
job.cleanup();
|
||||||
|
|
||||||
|
verify(jdbcTemplate, times(2)).update(
|
||||||
|
anyString(), any(), anyInt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,18 @@
|
|||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-common-web</artifactId>
|
<artifactId>easyflow-common-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>junit</groupId>
|
||||||
|
<artifactId>junit</artifactId>
|
||||||
|
<version>${junit.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mockito</groupId>
|
||||||
|
<artifactId>mockito-core</artifactId>
|
||||||
|
<version>5.12.0</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<profiles>
|
<profiles>
|
||||||
|
|||||||
@@ -7,9 +7,45 @@ import tech.easyflow.datacenter.execution.model.DatacenterQueryRequest;
|
|||||||
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public interface QueryExecutor {
|
public interface QueryExecutor {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询结构化数据集。
|
||||||
|
*
|
||||||
|
* @param source 数据源
|
||||||
|
* @param table 数据表
|
||||||
|
* @param request 查询请求
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
Page<Row> queryPage(DatacenterSource source, DatacenterTable table, DatacenterQueryRequest request);
|
Page<Row> queryPage(DatacenterSource source, DatacenterTable table, DatacenterQueryRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行原生 SQL 并返回完整结果。
|
||||||
|
*
|
||||||
|
* @param source 数据源
|
||||||
|
* @param sql 已校验 SQL
|
||||||
|
* @return 完整结果
|
||||||
|
*/
|
||||||
List<Row> queryBySql(DatacenterSource source, String sql);
|
List<Row> queryBySql(DatacenterSource source, String sql);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在单次查询中按结果顺序消费原生 SQL 返回行。
|
||||||
|
*
|
||||||
|
* <p>缺省实现保持第三方连接器兼容;JDBC 连接器应覆盖此方法并使用单连接、
|
||||||
|
* 单 ResultSet 流式读取。</p>
|
||||||
|
*
|
||||||
|
* @param source 数据源
|
||||||
|
* @param sql 已校验 SQL
|
||||||
|
* @param fetchSize JDBC 建议拉取行数
|
||||||
|
* @param consumer 单行消费者
|
||||||
|
*/
|
||||||
|
default void consumeBySql(
|
||||||
|
DatacenterSource source,
|
||||||
|
String sql,
|
||||||
|
int fetchSize,
|
||||||
|
Consumer<Row> consumer) {
|
||||||
|
queryBySql(source, sql).forEach(consumer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,59 @@ import tech.easyflow.datacenter.entity.DatacenterTable;
|
|||||||
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface WriteExecutor {
|
public interface WriteExecutor {
|
||||||
|
|
||||||
void saveRow(DatacenterSource source, DatacenterTable table, JSONObject data, LoginAccount account);
|
void saveRow(DatacenterSource source, DatacenterTable table, JSONObject data, LoginAccount account);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量保存数据行。
|
||||||
|
* <p>
|
||||||
|
* 缺省实现保持逐行语义;支持批处理的连接器应覆盖此方法。
|
||||||
|
*
|
||||||
|
* @param source 数据源
|
||||||
|
* @param table 数据表
|
||||||
|
* @param rows 待保存数据行
|
||||||
|
* @param account 当前操作账号
|
||||||
|
* @param batchSize 单批最大行数
|
||||||
|
*/
|
||||||
|
default void saveRows(DatacenterSource source,
|
||||||
|
DatacenterTable table,
|
||||||
|
List<JSONObject> rows,
|
||||||
|
LoginAccount account,
|
||||||
|
int batchSize) {
|
||||||
|
for (JSONObject row : rows) {
|
||||||
|
saveRow(source, table, row, account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在目标数据库中以唯一回执和业务写入同事务保存数据。
|
||||||
|
*
|
||||||
|
* <p>缺省实现用于不支持目标库事务回执的连接器,仍保持普通批量写入语义。支持写能力
|
||||||
|
* 的连接器应覆盖该方法。</p>
|
||||||
|
*
|
||||||
|
* @param source 数据源
|
||||||
|
* @param table 数据表
|
||||||
|
* @param rows 待保存行
|
||||||
|
* @param account 操作账号
|
||||||
|
* @param batchSize 单批最大行数
|
||||||
|
* @param receiptKey 有界幂等回执键
|
||||||
|
* @param payloadHash 输入负载摘要
|
||||||
|
* @return 本次实际写入时为 {@code true},同负载回执已存在时为 {@code false}
|
||||||
|
*/
|
||||||
|
default boolean saveRowsIdempotently(
|
||||||
|
DatacenterSource source,
|
||||||
|
DatacenterTable table,
|
||||||
|
List<JSONObject> rows,
|
||||||
|
LoginAccount account,
|
||||||
|
int batchSize,
|
||||||
|
String receiptKey,
|
||||||
|
String payloadHash) {
|
||||||
|
saveRows(source, table, rows, account, batchSize);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void deleteRow(DatacenterSource source, DatacenterTable table, BigInteger id, LoginAccount account);
|
void deleteRow(DatacenterSource source, DatacenterTable table, BigInteger id, LoginAccount account);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import tech.easyflow.datacenter.connector.support.AbstractInternalTableConnector
|
|||||||
import tech.easyflow.datacenter.meta.enums.DatacenterCapability;
|
import tech.easyflow.datacenter.meta.enums.DatacenterCapability;
|
||||||
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
|
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class ExcelConnector extends AbstractInternalTableConnector {
|
public class ExcelConnector extends AbstractInternalTableConnector {
|
||||||
public ExcelConnector() {
|
public ExcelConnector(DataSource dataSource) {
|
||||||
super(DatacenterSourceType.EXCEL, EnumSet.of(
|
super(DatacenterSourceType.EXCEL, EnumSet.of(
|
||||||
DatacenterCapability.TEST_CONNECTION,
|
DatacenterCapability.TEST_CONNECTION,
|
||||||
DatacenterCapability.BROWSE_METADATA,
|
DatacenterCapability.BROWSE_METADATA,
|
||||||
@@ -17,6 +18,6 @@ public class ExcelConnector extends AbstractInternalTableConnector {
|
|||||||
DatacenterCapability.WRITE_MUTATION,
|
DatacenterCapability.WRITE_MUTATION,
|
||||||
DatacenterCapability.MATERIALIZE,
|
DatacenterCapability.MATERIALIZE,
|
||||||
DatacenterCapability.EXPORT
|
DatacenterCapability.EXPORT
|
||||||
));
|
), dataSource);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import tech.easyflow.datacenter.connector.support.AbstractInternalTableConnector
|
|||||||
import tech.easyflow.datacenter.meta.enums.DatacenterCapability;
|
import tech.easyflow.datacenter.meta.enums.DatacenterCapability;
|
||||||
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
|
import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class ExcelMaterializedConnector extends AbstractInternalTableConnector {
|
public class ExcelMaterializedConnector extends AbstractInternalTableConnector {
|
||||||
public ExcelMaterializedConnector() {
|
public ExcelMaterializedConnector(
|
||||||
|
DataSource dataSource) {
|
||||||
super(DatacenterSourceType.EXCEL_MATERIALIZED, EnumSet.of(
|
super(DatacenterSourceType.EXCEL_MATERIALIZED, EnumSet.of(
|
||||||
DatacenterCapability.TEST_CONNECTION,
|
DatacenterCapability.TEST_CONNECTION,
|
||||||
DatacenterCapability.BROWSE_METADATA,
|
DatacenterCapability.BROWSE_METADATA,
|
||||||
@@ -17,6 +19,6 @@ public class ExcelMaterializedConnector extends AbstractInternalTableConnector {
|
|||||||
DatacenterCapability.WRITE_MUTATION,
|
DatacenterCapability.WRITE_MUTATION,
|
||||||
DatacenterCapability.MATERIALIZE,
|
DatacenterCapability.MATERIALIZE,
|
||||||
DatacenterCapability.EXPORT
|
DatacenterCapability.EXPORT
|
||||||
));
|
), dataSource);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import tech.easyflow.common.entity.LoginAccount;
|
|||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.datacenter.connector.dialect.MysqlSqlDialect;
|
import tech.easyflow.datacenter.connector.dialect.MysqlSqlDialect;
|
||||||
import tech.easyflow.datacenter.connector.support.AbstractJdbcConnector;
|
import tech.easyflow.datacenter.connector.support.AbstractJdbcConnector;
|
||||||
|
import tech.easyflow.datacenter.connector.support.WriteReceiptSupport;
|
||||||
import tech.easyflow.datacenter.entity.DatacenterTable;
|
import tech.easyflow.datacenter.entity.DatacenterTable;
|
||||||
import tech.easyflow.datacenter.entity.DatacenterTableField;
|
import tech.easyflow.datacenter.entity.DatacenterTableField;
|
||||||
import tech.easyflow.datacenter.execution.model.DatacenterQueryRequest;
|
import tech.easyflow.datacenter.execution.model.DatacenterQueryRequest;
|
||||||
@@ -20,9 +21,15 @@ import javax.sql.DataSource;
|
|||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@@ -116,6 +123,444 @@ public class ProjectMysqlConnector extends AbstractJdbcConnector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用单个连接按相同 SQL 连续分组批量保存数据。
|
||||||
|
*
|
||||||
|
* @param source 数据源
|
||||||
|
* @param table 数据表
|
||||||
|
* @param rows 待保存数据行
|
||||||
|
* @param account 当前操作账号
|
||||||
|
* @param batchSize 单批最大行数
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void saveRows(DatacenterSource source,
|
||||||
|
DatacenterTable table,
|
||||||
|
List<JSONObject> rows,
|
||||||
|
LoginAccount account,
|
||||||
|
int batchSize) {
|
||||||
|
if (rows == null || rows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (Connection connection = dataSource.getConnection()) {
|
||||||
|
saveRows(connection, source, table, rows, batchSize);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new BusinessException("项目 MySQL 批量写入失败: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean saveRowsIdempotently(
|
||||||
|
DatacenterSource source,
|
||||||
|
DatacenterTable table,
|
||||||
|
List<JSONObject> rows,
|
||||||
|
LoginAccount account,
|
||||||
|
int batchSize,
|
||||||
|
String receiptKey,
|
||||||
|
String payloadHash) {
|
||||||
|
if (StrUtil.isBlank(receiptKey)) {
|
||||||
|
saveRows(source, table, rows, account, batchSize);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try (Connection connection = dataSource.getConnection()) {
|
||||||
|
boolean originalAutoCommit = connection.getAutoCommit();
|
||||||
|
connection.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
if (hasMatchingReceipt(connection, receiptKey, payloadHash)) {
|
||||||
|
connection.rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int effectiveBatchSize = Math.max(1, batchSize);
|
||||||
|
for (int offset = 0; offset < rows.size(); offset += effectiveBatchSize) {
|
||||||
|
int end = Math.min(rows.size(), offset + effectiveBatchSize);
|
||||||
|
List<PendingRow> batch = pendingRows(
|
||||||
|
connection,
|
||||||
|
rows,
|
||||||
|
offset,
|
||||||
|
end,
|
||||||
|
receiptKey,
|
||||||
|
payloadHash);
|
||||||
|
if (batch.isEmpty()) {
|
||||||
|
connection.rollback();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
insertReceiptsBatch(connection, batch, payloadHash);
|
||||||
|
saveRows(
|
||||||
|
connection,
|
||||||
|
source,
|
||||||
|
table,
|
||||||
|
batch.stream()
|
||||||
|
.map(PendingRow::row)
|
||||||
|
.collect(Collectors.toList()),
|
||||||
|
effectiveBatchSize);
|
||||||
|
connection.commit();
|
||||||
|
} catch (Exception batchError) {
|
||||||
|
connection.rollback();
|
||||||
|
/*
|
||||||
|
* 批失败才逐行回放,精确保留旧实现“失败前行已提交、失败后不再执行”
|
||||||
|
* 的可观察语义,同时让正常路径按 batchSize 真正批量提交。
|
||||||
|
*/
|
||||||
|
replayRowsIndividually(
|
||||||
|
connection,
|
||||||
|
source,
|
||||||
|
table,
|
||||||
|
batch,
|
||||||
|
payloadHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!insertReceipt(connection, receiptKey, payloadHash)) {
|
||||||
|
connection.rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
connection.commit();
|
||||||
|
return true;
|
||||||
|
} catch (Exception error) {
|
||||||
|
connection.rollback();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.setAutoCommit(originalAutoCommit);
|
||||||
|
}
|
||||||
|
} catch (Exception error) {
|
||||||
|
throw new BusinessException("项目 MySQL 幂等批量写入失败: " + error.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量读取子回执并筛出尚未写入的行。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param rows 全部数据行
|
||||||
|
* @param startInclusive 批起始下标
|
||||||
|
* @param endExclusive 批结束下标
|
||||||
|
* @param receiptKey 根回执键
|
||||||
|
* @param payloadHash 负载摘要
|
||||||
|
* @return 尚未提交的行
|
||||||
|
* @throws SQLException 查询失败或回执负载冲突
|
||||||
|
*/
|
||||||
|
private List<PendingRow> pendingRows(
|
||||||
|
Connection connection,
|
||||||
|
List<JSONObject> rows,
|
||||||
|
int startInclusive,
|
||||||
|
int endExclusive,
|
||||||
|
String receiptKey,
|
||||||
|
String payloadHash) throws SQLException {
|
||||||
|
List<PendingRow> candidates =
|
||||||
|
new ArrayList<>(endExclusive - startInclusive);
|
||||||
|
for (int rowIndex = startInclusive;
|
||||||
|
rowIndex < endExclusive;
|
||||||
|
rowIndex++) {
|
||||||
|
candidates.add(new PendingRow(
|
||||||
|
WriteReceiptSupport.childKey(receiptKey, rowIndex),
|
||||||
|
rows.get(rowIndex)));
|
||||||
|
}
|
||||||
|
String placeholders = candidates.stream()
|
||||||
|
.map(candidate -> "?")
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
String sql = "SELECT idempotency_key, payload_hash "
|
||||||
|
+ "FROM tb_datacenter_write_receipt "
|
||||||
|
+ "WHERE idempotency_key IN (" + placeholders + ")";
|
||||||
|
Map<String, String> existing = new HashMap<>();
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
for (int index = 0; index < candidates.size(); index++) {
|
||||||
|
statement.setString(index + 1, candidates.get(index).receiptKey());
|
||||||
|
}
|
||||||
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
while (resultSet.next()) {
|
||||||
|
existing.put(resultSet.getString(1), resultSet.getString(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<PendingRow> pending = new ArrayList<>(candidates.size());
|
||||||
|
for (PendingRow candidate : candidates) {
|
||||||
|
String existingHash = existing.get(candidate.receiptKey());
|
||||||
|
if (existingHash == null) {
|
||||||
|
pending.add(candidate);
|
||||||
|
} else if (!java.util.Objects.equals(payloadHash, existingHash)) {
|
||||||
|
throw new SQLException(
|
||||||
|
"相同幂等键对应的数据内容不一致", "23000");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在当前事务中批量创建子回执。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param rows 待写行
|
||||||
|
* @param payloadHash 负载摘要
|
||||||
|
* @throws SQLException 回执批写失败
|
||||||
|
*/
|
||||||
|
private void insertReceiptsBatch(
|
||||||
|
Connection connection,
|
||||||
|
List<PendingRow> rows,
|
||||||
|
String payloadHash) throws SQLException {
|
||||||
|
String sql = "INSERT INTO tb_datacenter_write_receipt "
|
||||||
|
+ "(idempotency_key, payload_hash, created) "
|
||||||
|
+ "VALUES (?, ?, CURRENT_TIMESTAMP)";
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
for (PendingRow row : rows) {
|
||||||
|
statement.setString(1, row.receiptKey());
|
||||||
|
statement.setString(2, payloadHash);
|
||||||
|
statement.addBatch();
|
||||||
|
}
|
||||||
|
statement.executeBatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批失败后逐行回放,定位首个业务失败并保留旧部分成功边界。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param source 数据源
|
||||||
|
* @param table 数据表
|
||||||
|
* @param rows 本批待写行
|
||||||
|
* @param payloadHash 负载摘要
|
||||||
|
* @throws Exception 首个真实行写入失败
|
||||||
|
*/
|
||||||
|
private void replayRowsIndividually(
|
||||||
|
Connection connection,
|
||||||
|
DatacenterSource source,
|
||||||
|
DatacenterTable table,
|
||||||
|
List<PendingRow> rows,
|
||||||
|
String payloadHash) throws Exception {
|
||||||
|
for (PendingRow pendingRow : rows) {
|
||||||
|
if (!insertReceipt(
|
||||||
|
connection,
|
||||||
|
pendingRow.receiptKey(),
|
||||||
|
payloadHash)) {
|
||||||
|
connection.rollback();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
saveRows(
|
||||||
|
connection,
|
||||||
|
source,
|
||||||
|
table,
|
||||||
|
List.of(pendingRow.row()),
|
||||||
|
1);
|
||||||
|
connection.commit();
|
||||||
|
} catch (Exception rowError) {
|
||||||
|
connection.rollback();
|
||||||
|
throw rowError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待写行及其稳定子回执键。
|
||||||
|
*
|
||||||
|
* @param receiptKey 子回执键
|
||||||
|
* @param row 数据行
|
||||||
|
*/
|
||||||
|
private record PendingRow(String receiptKey, JSONObject row) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查整次写入回执是否已经存在并校验负载。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param receiptKey 回执键
|
||||||
|
* @param payloadHash 负载摘要
|
||||||
|
* @return 相同负载的回执存在时为 {@code true}
|
||||||
|
* @throws SQLException 数据库访问失败或负载冲突
|
||||||
|
*/
|
||||||
|
private boolean hasMatchingReceipt(
|
||||||
|
Connection connection, String receiptKey, String payloadHash)
|
||||||
|
throws SQLException {
|
||||||
|
String querySql = "SELECT payload_hash FROM tb_datacenter_write_receipt "
|
||||||
|
+ "WHERE idempotency_key = ?";
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(querySql)) {
|
||||||
|
statement.setString(1, receiptKey);
|
||||||
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
if (!resultSet.next()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!java.util.Objects.equals(payloadHash, resultSet.getString(1))) {
|
||||||
|
throw new SQLException(
|
||||||
|
"相同幂等键对应的数据内容不一致", "23000");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在当前连接中写入唯一回执。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param receiptKey 回执键
|
||||||
|
* @param payloadHash 负载摘要
|
||||||
|
* @return 新建回执时为 {@code true},相同负载回执已存在时为 {@code false}
|
||||||
|
* @throws SQLException 数据库访问失败或负载摘要冲突
|
||||||
|
*/
|
||||||
|
private boolean insertReceipt(
|
||||||
|
Connection connection, String receiptKey, String payloadHash) throws SQLException {
|
||||||
|
String insertSql = "INSERT INTO tb_datacenter_write_receipt "
|
||||||
|
+ "(idempotency_key, payload_hash, created) VALUES (?, ?, CURRENT_TIMESTAMP)";
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(insertSql)) {
|
||||||
|
statement.setString(1, receiptKey);
|
||||||
|
statement.setString(2, payloadHash);
|
||||||
|
statement.executeUpdate();
|
||||||
|
return true;
|
||||||
|
} catch (SQLException duplicate) {
|
||||||
|
if (!"23000".equals(duplicate.getSQLState()) && duplicate.getErrorCode() != 1062) {
|
||||||
|
throw duplicate;
|
||||||
|
}
|
||||||
|
String querySql = "SELECT payload_hash FROM tb_datacenter_write_receipt "
|
||||||
|
+ "WHERE idempotency_key = ?";
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(querySql)) {
|
||||||
|
statement.setString(1, receiptKey);
|
||||||
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
if (!resultSet.next()) {
|
||||||
|
throw duplicate;
|
||||||
|
}
|
||||||
|
if (!java.util.Objects.equals(payloadHash, resultSet.getString(1))) {
|
||||||
|
throw new SQLException("相同幂等键对应的数据内容不一致", "23000");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在给定连接上执行完整批量,供普通和事务幂等写入复用。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param source 数据源
|
||||||
|
* @param table 数据表
|
||||||
|
* @param rows 待保存行
|
||||||
|
* @param batchSize 单批最大行数
|
||||||
|
* @throws Exception JDBC 批处理失败
|
||||||
|
*/
|
||||||
|
private void saveRows(
|
||||||
|
Connection connection,
|
||||||
|
DatacenterSource source,
|
||||||
|
DatacenterTable table,
|
||||||
|
List<JSONObject> rows,
|
||||||
|
int batchSize) throws Exception {
|
||||||
|
List<DatacenterTableField> writableFields = table.getFields().stream()
|
||||||
|
.filter(field -> field.getWritable() == null || field.getWritable() == 1)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
int effectiveBatchSize = Math.max(1, batchSize);
|
||||||
|
List<SqlMutation> batch = new ArrayList<>(Math.min(rows.size(), effectiveBatchSize));
|
||||||
|
String batchSql = null;
|
||||||
|
for (JSONObject row : rows) {
|
||||||
|
SqlMutation mutation = buildMutation(source, table, writableFields, row);
|
||||||
|
if (mutation == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (batchSql != null
|
||||||
|
&& (!batchSql.equals(mutation.sql) || batch.size() >= effectiveBatchSize)) {
|
||||||
|
executeBatch(connection, batchSql, batch);
|
||||||
|
batch.clear();
|
||||||
|
}
|
||||||
|
batchSql = mutation.sql;
|
||||||
|
batch.add(mutation);
|
||||||
|
}
|
||||||
|
if (!batch.isEmpty()) {
|
||||||
|
executeBatch(connection, batchSql, batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建单行参数化写入。
|
||||||
|
*
|
||||||
|
* @param source 数据源
|
||||||
|
* @param table 数据表
|
||||||
|
* @param writableFields 可写字段
|
||||||
|
* @param data 数据行
|
||||||
|
* @return SQL 与参数;无可更新字段时返回 null
|
||||||
|
*/
|
||||||
|
private SqlMutation buildMutation(DatacenterSource source,
|
||||||
|
DatacenterTable table,
|
||||||
|
List<DatacenterTableField> writableFields,
|
||||||
|
JSONObject data) {
|
||||||
|
Object id = data.get("id");
|
||||||
|
if (id == null) {
|
||||||
|
List<String> columns = new ArrayList<>();
|
||||||
|
List<Object> values = new ArrayList<>();
|
||||||
|
for (DatacenterTableField field : writableFields) {
|
||||||
|
Object value = data.get(field.getFieldName());
|
||||||
|
if (value != null) {
|
||||||
|
columns.add(field.getFieldName());
|
||||||
|
values.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (columns.isEmpty()) {
|
||||||
|
throw new BusinessException("没有可写字段");
|
||||||
|
}
|
||||||
|
String sql = "INSERT INTO "
|
||||||
|
+ dialect.qualifyTable(source.getDatabaseName(), resolvePhysicalTableName(table))
|
||||||
|
+ " (" + columns.stream().map(dialect::quoteIdentifier).collect(Collectors.joining(","))
|
||||||
|
+ ") VALUES (" + columns.stream().map(item -> "?").collect(Collectors.joining(",")) + ")";
|
||||||
|
return new SqlMutation(sql, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> setClauses = new ArrayList<>();
|
||||||
|
List<Object> values = new ArrayList<>();
|
||||||
|
for (DatacenterTableField field : writableFields) {
|
||||||
|
if (!data.containsKey(field.getFieldName())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
setClauses.add(dialect.quoteIdentifier(field.getFieldName()) + " = ?");
|
||||||
|
values.add(data.get(field.getFieldName()));
|
||||||
|
}
|
||||||
|
if (setClauses.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String sql = "UPDATE "
|
||||||
|
+ dialect.qualifyTable(source.getDatabaseName(), resolvePhysicalTableName(table))
|
||||||
|
+ " SET " + String.join(",", setClauses)
|
||||||
|
+ " WHERE " + dialect.quoteIdentifier("id") + " = ?";
|
||||||
|
values.add(id);
|
||||||
|
return new SqlMutation(sql, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行同构 SQL 批次。
|
||||||
|
*
|
||||||
|
* @param connection 数据库连接
|
||||||
|
* @param sql 参数化 SQL
|
||||||
|
* @param mutations 待执行参数
|
||||||
|
* @throws Exception JDBC 批处理失败时抛出
|
||||||
|
*/
|
||||||
|
private void executeBatch(Connection connection, String sql, List<SqlMutation> mutations) throws Exception {
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
for (SqlMutation mutation : mutations) {
|
||||||
|
for (int index = 0; index < mutation.parameters.size(); index++) {
|
||||||
|
statement.setObject(index + 1, mutation.parameters.get(index));
|
||||||
|
}
|
||||||
|
statement.addBatch();
|
||||||
|
}
|
||||||
|
statement.executeBatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 参数化写入描述。
|
||||||
|
*/
|
||||||
|
private static final class SqlMutation {
|
||||||
|
|
||||||
|
private final String sql;
|
||||||
|
private final List<Object> parameters;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建参数化写入。
|
||||||
|
*
|
||||||
|
* @param sql SQL 文本
|
||||||
|
* @param parameters SQL 参数
|
||||||
|
*/
|
||||||
|
private SqlMutation(String sql, List<Object> parameters) {
|
||||||
|
this.sql = sql;
|
||||||
|
this.parameters = parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void deleteRow(DatacenterSource source, DatacenterTable table, BigInteger id, LoginAccount account) {
|
public void deleteRow(DatacenterSource source, DatacenterTable table, BigInteger id, LoginAccount account) {
|
||||||
String sql = "DELETE FROM " + dialect.qualifyTable(source.getDatabaseName(), resolvePhysicalTableName(table))
|
String sql = "DELETE FROM " + dialect.qualifyTable(source.getDatabaseName(), resolvePhysicalTableName(table))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.mybatisflex.core.row.Db;
|
|||||||
import com.mybatisflex.core.row.Row;
|
import com.mybatisflex.core.row.Row;
|
||||||
import com.mybatisflex.core.row.RowKey;
|
import com.mybatisflex.core.row.RowKey;
|
||||||
import org.springframework.util.CollectionUtils;
|
import org.springframework.util.CollectionUtils;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.datacenter.connector.DatacenterConnector;
|
import tech.easyflow.datacenter.connector.DatacenterConnector;
|
||||||
@@ -21,18 +22,30 @@ import tech.easyflow.datacenter.meta.enums.DatacenterSourceType;
|
|||||||
import tech.easyflow.datacenter.meta.model.DatacenterCatalogMeta;
|
import tech.easyflow.datacenter.meta.model.DatacenterCatalogMeta;
|
||||||
import tech.easyflow.datacenter.meta.model.DatacenterTableDetailMeta;
|
import tech.easyflow.datacenter.meta.model.DatacenterTableDetailMeta;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.ResultSetMetaData;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public abstract class AbstractInternalTableConnector implements DatacenterConnector {
|
public abstract class AbstractInternalTableConnector implements DatacenterConnector {
|
||||||
|
|
||||||
|
private static final String WRITE_RECEIPT_TABLE = "tb_datacenter_write_receipt";
|
||||||
private final DatacenterSourceType sourceType;
|
private final DatacenterSourceType sourceType;
|
||||||
private final Set<DatacenterCapability> capabilities;
|
private final Set<DatacenterCapability> capabilities;
|
||||||
|
private final DataSource dataSource;
|
||||||
|
|
||||||
protected AbstractInternalTableConnector(DatacenterSourceType sourceType, Set<DatacenterCapability> capabilities) {
|
protected AbstractInternalTableConnector(
|
||||||
|
DatacenterSourceType sourceType,
|
||||||
|
Set<DatacenterCapability> capabilities,
|
||||||
|
DataSource dataSource) {
|
||||||
this.sourceType = sourceType;
|
this.sourceType = sourceType;
|
||||||
this.capabilities = capabilities;
|
this.capabilities = capabilities;
|
||||||
|
this.dataSource = dataSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -93,6 +106,58 @@ public abstract class AbstractInternalTableConnector implements DatacenterConnec
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void consumeBySql(
|
||||||
|
DatacenterSource source,
|
||||||
|
String sql,
|
||||||
|
int fetchSize,
|
||||||
|
Consumer<Row> consumer) {
|
||||||
|
if (fetchSize <= 0 || consumer == null) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"fetchSize and consumer must be valid");
|
||||||
|
}
|
||||||
|
try (Connection connection =
|
||||||
|
dataSource.getConnection();
|
||||||
|
PreparedStatement statement =
|
||||||
|
connection.prepareStatement(
|
||||||
|
sql,
|
||||||
|
ResultSet.TYPE_FORWARD_ONLY,
|
||||||
|
ResultSet.CONCUR_READ_ONLY)) {
|
||||||
|
// 内部数据源使用项目 MySQL,启用驱动前向流式结果。
|
||||||
|
statement.setFetchSize(Integer.MIN_VALUE);
|
||||||
|
int timeoutSeconds = Integer.getInteger(
|
||||||
|
"easyflow.datacenter.query.timeout-seconds",
|
||||||
|
300);
|
||||||
|
if (timeoutSeconds > 0) {
|
||||||
|
statement.setQueryTimeout(timeoutSeconds);
|
||||||
|
}
|
||||||
|
try (ResultSet resultSet =
|
||||||
|
statement.executeQuery()) {
|
||||||
|
ResultSetMetaData metaData =
|
||||||
|
resultSet.getMetaData();
|
||||||
|
while (resultSet.next()) {
|
||||||
|
Row row = new Row();
|
||||||
|
for (int index = 1;
|
||||||
|
index <= metaData.getColumnCount();
|
||||||
|
index++) {
|
||||||
|
row.put(
|
||||||
|
metaData.getColumnLabel(index),
|
||||||
|
normalizeValue(
|
||||||
|
resultSet.getObject(index)));
|
||||||
|
}
|
||||||
|
consumer.accept(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception error) {
|
||||||
|
throw DatacenterConnectorExceptionSupport
|
||||||
|
.wrapAccessException(
|
||||||
|
"SQL 流式查询失败", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void saveRow(DatacenterSource source, DatacenterTable table, JSONObject data, LoginAccount account) {
|
public void saveRow(DatacenterSource source, DatacenterTable table, JSONObject data, LoginAccount account) {
|
||||||
List<DatacenterTableField> fields = table.getFields();
|
List<DatacenterTableField> fields = table.getFields();
|
||||||
@@ -100,21 +165,314 @@ public abstract class AbstractInternalTableConnector implements DatacenterConnec
|
|||||||
throw new BusinessException("数据集字段为空,无法写入");
|
throw new BusinessException("数据集字段为空,无法写入");
|
||||||
}
|
}
|
||||||
String actualTable = resolveTableName(table);
|
String actualTable = resolveTableName(table);
|
||||||
|
RowMutation mutation = buildRowMutation(fields, data, account);
|
||||||
|
if (mutation.insert) {
|
||||||
|
Db.insert(actualTable, mutation.row);
|
||||||
|
} else {
|
||||||
|
Db.updateById(actualTable, mutation.row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 MyBatis-Flex 动态表批处理保存数据行。
|
||||||
|
*
|
||||||
|
* @param source 数据源
|
||||||
|
* @param table 数据表
|
||||||
|
* @param rows 待保存数据行
|
||||||
|
* @param account 当前操作账号
|
||||||
|
* @param batchSize 单批最大行数
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void saveRows(DatacenterSource source,
|
||||||
|
DatacenterTable table,
|
||||||
|
List<JSONObject> rows,
|
||||||
|
LoginAccount account,
|
||||||
|
int batchSize) {
|
||||||
|
List<DatacenterTableField> fields = table.getFields();
|
||||||
|
if (CollectionUtils.isEmpty(fields)) {
|
||||||
|
throw new BusinessException("数据集字段为空,无法写入");
|
||||||
|
}
|
||||||
|
if (rows == null || rows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String actualTable = resolveTableName(table);
|
||||||
|
int effectiveBatchSize = Math.max(1, batchSize);
|
||||||
|
List<Row> batch = new ArrayList<>(Math.min(rows.size(), effectiveBatchSize));
|
||||||
|
Boolean insertBatch = null;
|
||||||
|
for (JSONObject data : rows) {
|
||||||
|
RowMutation mutation = buildRowMutation(fields, data, account);
|
||||||
|
if (insertBatch != null
|
||||||
|
&& (insertBatch != mutation.insert || batch.size() >= effectiveBatchSize)) {
|
||||||
|
executeRowBatch(actualTable, batch, insertBatch);
|
||||||
|
batch.clear();
|
||||||
|
}
|
||||||
|
insertBatch = mutation.insert;
|
||||||
|
batch.add(mutation.row);
|
||||||
|
}
|
||||||
|
if (!batch.isEmpty()) {
|
||||||
|
executeRowBatch(actualTable, batch, Boolean.TRUE.equals(insertBatch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean saveRowsIdempotently(
|
||||||
|
DatacenterSource source,
|
||||||
|
DatacenterTable table,
|
||||||
|
List<JSONObject> rows,
|
||||||
|
LoginAccount account,
|
||||||
|
int batchSize,
|
||||||
|
String receiptKey,
|
||||||
|
String payloadHash) {
|
||||||
|
if (StrUtil.isBlank(receiptKey)) {
|
||||||
|
saveRows(source, table, rows, account, batchSize);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Row completedReceipt = Db.selectOneByMap(
|
||||||
|
WRITE_RECEIPT_TABLE,
|
||||||
|
Collections.singletonMap("idempotency_key", receiptKey));
|
||||||
|
if (completedReceipt != null) {
|
||||||
|
Object existingHash = completedReceipt.get("payload_hash");
|
||||||
|
if (!Objects.equals(payloadHash, existingHash)) {
|
||||||
|
throw new BusinessException("相同幂等键对应的数据内容不一致");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
List<DatacenterTableField> fields = table.getFields();
|
||||||
|
if (CollectionUtils.isEmpty(fields)) {
|
||||||
|
throw new BusinessException("数据集字段为空,无法写入");
|
||||||
|
}
|
||||||
|
String actualTable = resolveTableName(table);
|
||||||
|
int effectiveBatchSize = Math.max(1, batchSize);
|
||||||
|
for (int offset = 0; offset < rows.size(); offset += effectiveBatchSize) {
|
||||||
|
int end = Math.min(rows.size(), offset + effectiveBatchSize);
|
||||||
|
List<PendingInternalRow> batch = new ArrayList<>(end - offset);
|
||||||
|
for (int index = offset; index < end; index++) {
|
||||||
|
batch.add(new PendingInternalRow(
|
||||||
|
WriteReceiptSupport.childKey(receiptKey, index),
|
||||||
|
rows.get(index),
|
||||||
|
buildRowMutation(
|
||||||
|
fields,
|
||||||
|
rows.get(index),
|
||||||
|
account)));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Db.txWithResult(() -> {
|
||||||
|
insertReceiptsBatch(batch, payloadHash);
|
||||||
|
executePendingMutations(actualTable, batch);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} catch (RuntimeException batchError) {
|
||||||
|
/*
|
||||||
|
* 正常路径每批一个事务;批失败后才逐行回放,继续保持旧实现的
|
||||||
|
* 部分成功顺序边界,并利用子回执跳过已提交行。
|
||||||
|
*/
|
||||||
|
replayInternalRows(
|
||||||
|
actualTable,
|
||||||
|
fields,
|
||||||
|
batch,
|
||||||
|
account,
|
||||||
|
payloadHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Db.txWithResult(() -> {
|
||||||
|
Row receipt = new Row();
|
||||||
|
receipt.put("idempotency_key", receiptKey);
|
||||||
|
receipt.put("payload_hash", payloadHash);
|
||||||
|
receipt.put("created", new Date());
|
||||||
|
try {
|
||||||
|
Db.insert(WRITE_RECEIPT_TABLE, receipt);
|
||||||
|
} catch (DuplicateKeyException duplicate) {
|
||||||
|
Row existing = Db.selectOneByMap(
|
||||||
|
WRITE_RECEIPT_TABLE,
|
||||||
|
Collections.singletonMap("idempotency_key", receiptKey));
|
||||||
|
Object existingHash = existing == null ? null : existing.get("payload_hash");
|
||||||
|
if (!Objects.equals(payloadHash, existingHash)) {
|
||||||
|
throw new BusinessException("相同幂等键对应的数据内容不一致");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在当前 MyBatis-Flex 事务中写入单行动态表数据。
|
||||||
|
*
|
||||||
|
* @param actualTable 实际表名
|
||||||
|
* @param fields 可写字段
|
||||||
|
* @param data 原始行
|
||||||
|
* @param account 当前账号
|
||||||
|
*/
|
||||||
|
private void saveRowInCurrentTransaction(
|
||||||
|
String actualTable,
|
||||||
|
List<DatacenterTableField> fields,
|
||||||
|
JSONObject data,
|
||||||
|
LoginAccount account) {
|
||||||
|
RowMutation mutation = buildRowMutation(fields, data, account);
|
||||||
|
if (mutation.insert) {
|
||||||
|
Db.insert(actualTable, mutation.row);
|
||||||
|
} else {
|
||||||
|
Db.updateById(actualTable, mutation.row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建写入回执。
|
||||||
|
*
|
||||||
|
* @param receiptKey 回执键
|
||||||
|
* @param payloadHash 负载摘要
|
||||||
|
*/
|
||||||
|
private void insertReceipt(String receiptKey, String payloadHash) {
|
||||||
|
Row receipt = new Row();
|
||||||
|
receipt.put("idempotency_key", receiptKey);
|
||||||
|
receipt.put("payload_hash", payloadHash);
|
||||||
|
receipt.put("created", new Date());
|
||||||
|
Db.insert(WRITE_RECEIPT_TABLE, receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在当前事务中批量创建子回执。
|
||||||
|
*
|
||||||
|
* @param rows 本批待写行
|
||||||
|
* @param payloadHash 负载摘要
|
||||||
|
*/
|
||||||
|
private void insertReceiptsBatch(
|
||||||
|
List<PendingInternalRow> rows,
|
||||||
|
String payloadHash) {
|
||||||
|
Date created = new Date();
|
||||||
|
List<Row> receipts = new ArrayList<>(rows.size());
|
||||||
|
for (PendingInternalRow pendingRow : rows) {
|
||||||
|
Row receipt = new Row();
|
||||||
|
receipt.put(
|
||||||
|
"idempotency_key",
|
||||||
|
pendingRow.receiptKey());
|
||||||
|
receipt.put("payload_hash", payloadHash);
|
||||||
|
receipt.put("created", created);
|
||||||
|
receipts.add(receipt);
|
||||||
|
}
|
||||||
|
Db.insertBatch(
|
||||||
|
WRITE_RECEIPT_TABLE,
|
||||||
|
receipts,
|
||||||
|
receipts.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按原始顺序合并相邻同类型写入,减少正常路径 SQL 往返。
|
||||||
|
*
|
||||||
|
* @param actualTable 实际表名
|
||||||
|
* @param rows 本批待写行
|
||||||
|
*/
|
||||||
|
private void executePendingMutations(
|
||||||
|
String actualTable,
|
||||||
|
List<PendingInternalRow> rows) {
|
||||||
|
List<Row> batch = new ArrayList<>(rows.size());
|
||||||
|
Boolean insertBatch = null;
|
||||||
|
for (PendingInternalRow pendingRow : rows) {
|
||||||
|
RowMutation mutation = pendingRow.mutation();
|
||||||
|
if (insertBatch != null
|
||||||
|
&& insertBatch != mutation.insert) {
|
||||||
|
executeRowBatch(
|
||||||
|
actualTable,
|
||||||
|
batch,
|
||||||
|
insertBatch);
|
||||||
|
batch.clear();
|
||||||
|
}
|
||||||
|
insertBatch = mutation.insert;
|
||||||
|
batch.add(mutation.row);
|
||||||
|
}
|
||||||
|
if (!batch.isEmpty()) {
|
||||||
|
executeRowBatch(
|
||||||
|
actualTable,
|
||||||
|
batch,
|
||||||
|
Boolean.TRUE.equals(insertBatch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批失败后逐行回放,并校验重复回执的负载摘要。
|
||||||
|
*
|
||||||
|
* @param actualTable 实际表名
|
||||||
|
* @param fields 可写字段
|
||||||
|
* @param rows 本批数据
|
||||||
|
* @param account 当前账号
|
||||||
|
* @param payloadHash 负载摘要
|
||||||
|
*/
|
||||||
|
private void replayInternalRows(
|
||||||
|
String actualTable,
|
||||||
|
List<DatacenterTableField> fields,
|
||||||
|
List<PendingInternalRow> rows,
|
||||||
|
LoginAccount account,
|
||||||
|
String payloadHash) {
|
||||||
|
for (PendingInternalRow pendingRow : rows) {
|
||||||
|
Db.txWithResult(() -> {
|
||||||
|
try {
|
||||||
|
insertReceipt(pendingRow.receiptKey(), payloadHash);
|
||||||
|
} catch (DuplicateKeyException duplicate) {
|
||||||
|
Row existing = Db.selectOneByMap(
|
||||||
|
WRITE_RECEIPT_TABLE,
|
||||||
|
Collections.singletonMap(
|
||||||
|
"idempotency_key",
|
||||||
|
pendingRow.receiptKey()));
|
||||||
|
Object existingHash = existing == null
|
||||||
|
? null
|
||||||
|
: existing.get("payload_hash");
|
||||||
|
if (!Objects.equals(payloadHash, existingHash)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
"相同幂等键对应的数据内容不一致");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
saveRowInCurrentTransaction(
|
||||||
|
actualTable,
|
||||||
|
fields,
|
||||||
|
pendingRow.data(),
|
||||||
|
account);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部动态表待写行。
|
||||||
|
*
|
||||||
|
* @param receiptKey 子回执键
|
||||||
|
* @param data 原始行
|
||||||
|
* @param mutation 已构建的写入对象
|
||||||
|
*/
|
||||||
|
private record PendingInternalRow(
|
||||||
|
String receiptKey,
|
||||||
|
JSONObject data,
|
||||||
|
RowMutation mutation) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建动态表单行写入对象。
|
||||||
|
*
|
||||||
|
* @param fields 数据表字段
|
||||||
|
* @param data 输入数据
|
||||||
|
* @param account 当前操作账号
|
||||||
|
* @return 行数据与写入类型
|
||||||
|
*/
|
||||||
|
private RowMutation buildRowMutation(
|
||||||
|
List<DatacenterTableField> fields, JSONObject data, LoginAccount account) {
|
||||||
Object id = data.get("id");
|
Object id = data.get("id");
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
|
Date now = new Date();
|
||||||
Row row = Row.ofKey(RowKey.SNOW_FLAKE_ID);
|
Row row = Row.ofKey(RowKey.SNOW_FLAKE_ID);
|
||||||
row.put("dept_id", account.getDeptId());
|
row.put("dept_id", account.getDeptId());
|
||||||
row.put("tenant_id", account.getTenantId());
|
row.put("tenant_id", account.getTenantId());
|
||||||
row.put("created", new Date());
|
row.put("created", now);
|
||||||
row.put("created_by", account.getId());
|
row.put("created_by", account.getId());
|
||||||
row.put("modified", new Date());
|
row.put("modified", now);
|
||||||
row.put("modified_by", account.getId());
|
row.put("modified_by", account.getId());
|
||||||
row.put("remark", data.get("remark"));
|
row.put("remark", data.get("remark"));
|
||||||
for (DatacenterTableField field : fields) {
|
for (DatacenterTableField field : fields) {
|
||||||
row.put(field.getFieldName(), data.get(field.getFieldName()));
|
row.put(field.getFieldName(), data.get(field.getFieldName()));
|
||||||
}
|
}
|
||||||
Db.insert(actualTable, row);
|
return new RowMutation(true, row);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
Row row = Row.ofKey("id", id);
|
Row row = Row.ofKey("id", id);
|
||||||
row.put("modified", new Date());
|
row.put("modified", new Date());
|
||||||
@@ -122,7 +480,42 @@ public abstract class AbstractInternalTableConnector implements DatacenterConnec
|
|||||||
for (DatacenterTableField field : fields) {
|
for (DatacenterTableField field : fields) {
|
||||||
row.put(field.getFieldName(), data.get(field.getFieldName()));
|
row.put(field.getFieldName(), data.get(field.getFieldName()));
|
||||||
}
|
}
|
||||||
Db.updateById(actualTable, row);
|
return new RowMutation(false, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行动态表同类型批次。
|
||||||
|
*
|
||||||
|
* @param actualTable 实际表名
|
||||||
|
* @param rows 行数据
|
||||||
|
* @param insert 是否为新增批次
|
||||||
|
*/
|
||||||
|
private void executeRowBatch(String actualTable, List<Row> rows, boolean insert) {
|
||||||
|
if (insert) {
|
||||||
|
Db.insertBatch(actualTable, rows, rows.size());
|
||||||
|
} else {
|
||||||
|
Db.updateBatchById(actualTable, rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态表单行写入描述。
|
||||||
|
*/
|
||||||
|
private static final class RowMutation {
|
||||||
|
|
||||||
|
private final boolean insert;
|
||||||
|
private final Row row;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建动态表行写入描述。
|
||||||
|
*
|
||||||
|
* @param insert 是否新增
|
||||||
|
* @param row 行数据
|
||||||
|
*/
|
||||||
|
private RowMutation(boolean insert, Row row) {
|
||||||
|
this.insert = insert;
|
||||||
|
this.row = row;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -138,15 +531,27 @@ public abstract class AbstractInternalTableConnector implements DatacenterConnec
|
|||||||
for (Row record : records) {
|
for (Row record : records) {
|
||||||
Map<String, Object> converted = new LinkedHashMap<>();
|
Map<String, Object> converted = new LinkedHashMap<>();
|
||||||
for (Map.Entry<String, Object> entry : record.entrySet()) {
|
for (Map.Entry<String, Object> entry : record.entrySet()) {
|
||||||
Object value = entry.getValue();
|
converted.put(
|
||||||
if (value instanceof BigInteger || value instanceof BigDecimal || value instanceof Long) {
|
entry.getKey(),
|
||||||
converted.put(entry.getKey(), value.toString());
|
normalizeValue(entry.getValue()));
|
||||||
} else {
|
|
||||||
converted.put(entry.getKey(), value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
record.clear();
|
record.clear();
|
||||||
record.putAll(converted);
|
record.putAll(converted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一内部查询的数值 JSON 表现。
|
||||||
|
*
|
||||||
|
* @param value JDBC 原始值
|
||||||
|
* @return 兼容既有查询接口的值
|
||||||
|
*/
|
||||||
|
private Object normalizeValue(Object value) {
|
||||||
|
if (value instanceof BigInteger
|
||||||
|
|| value instanceof BigDecimal
|
||||||
|
|| value instanceof Long) {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import java.math.BigDecimal;
|
|||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.sql.*;
|
import java.sql.*;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.function.Consumer;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
public abstract class AbstractJdbcConnector implements DatacenterConnector {
|
public abstract class AbstractJdbcConnector implements DatacenterConnector {
|
||||||
@@ -265,6 +266,43 @@ public abstract class AbstractJdbcConnector implements DatacenterConnector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void consumeBySql(
|
||||||
|
DatacenterSource source,
|
||||||
|
String sql,
|
||||||
|
int fetchSize,
|
||||||
|
Consumer<Row> consumer) {
|
||||||
|
if (!capabilities.contains(
|
||||||
|
DatacenterCapability.READ_QUERY)) {
|
||||||
|
throw new BusinessException(
|
||||||
|
"当前数据源暂不支持查询");
|
||||||
|
}
|
||||||
|
if (fetchSize <= 0 || consumer == null) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"fetchSize and consumer must be valid");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
withConnection(
|
||||||
|
source,
|
||||||
|
true,
|
||||||
|
connection -> {
|
||||||
|
consumeBySql(
|
||||||
|
connection,
|
||||||
|
sql,
|
||||||
|
fetchSize,
|
||||||
|
consumer);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw DatacenterConnectorExceptionSupport
|
||||||
|
.wrapAccessException(
|
||||||
|
"SQL 流式查询失败", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void saveRow(DatacenterSource source, DatacenterTable table, JSONObject data, LoginAccount account) {
|
public void saveRow(DatacenterSource source, DatacenterTable table, JSONObject data, LoginAccount account) {
|
||||||
throw new BusinessException("当前数据源不支持写入");
|
throw new BusinessException("当前数据源不支持写入");
|
||||||
@@ -330,9 +368,163 @@ public abstract class AbstractJdbcConnector implements DatacenterConnector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected List<Row> doQueryBySql(Connection connection, String sql) throws SQLException {
|
protected List<Row> doQueryBySql(Connection connection, String sql) throws SQLException {
|
||||||
try (PreparedStatement statement = connection.prepareStatement(sql);
|
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
ResultSet resultSet = statement.executeQuery()) {
|
configureStreamingQuery(statement);
|
||||||
return readRows(resultSet);
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
return readRows(resultSet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用单连接、单 ResultSet 顺序消费查询结果。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param sql 已校验 SQL
|
||||||
|
* @param fetchSize JDBC 建议拉取行数
|
||||||
|
* @param consumer 单行消费者
|
||||||
|
* @throws SQLException 查询失败
|
||||||
|
*/
|
||||||
|
protected void consumeBySql(
|
||||||
|
Connection connection,
|
||||||
|
String sql,
|
||||||
|
int fetchSize,
|
||||||
|
Consumer<Row> consumer) throws SQLException {
|
||||||
|
boolean localCursorTransaction =
|
||||||
|
usesPostgresqlCursor()
|
||||||
|
&& connection.getAutoCommit();
|
||||||
|
if (localCursorTransaction) {
|
||||||
|
// PostgreSQL 协议仅在事务内按 fetchSize 使用服务端游标。
|
||||||
|
connection.setAutoCommit(false);
|
||||||
|
}
|
||||||
|
Throwable queryFailure = null;
|
||||||
|
try {
|
||||||
|
try (PreparedStatement statement =
|
||||||
|
connection.prepareStatement(
|
||||||
|
sql,
|
||||||
|
ResultSet.TYPE_FORWARD_ONLY,
|
||||||
|
ResultSet.CONCUR_READ_ONLY)) {
|
||||||
|
configureCursorQuery(statement, fetchSize);
|
||||||
|
try (ResultSet resultSet =
|
||||||
|
statement.executeQuery()) {
|
||||||
|
ResultSetMetaData metaData =
|
||||||
|
resultSet.getMetaData();
|
||||||
|
while (resultSet.next()) {
|
||||||
|
Row row = new Row();
|
||||||
|
for (int index = 1;
|
||||||
|
index <= metaData.getColumnCount();
|
||||||
|
index++) {
|
||||||
|
row.put(
|
||||||
|
metaData.getColumnLabel(index),
|
||||||
|
normalizeValue(
|
||||||
|
resultSet.getObject(index)));
|
||||||
|
}
|
||||||
|
consumer.accept(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException | RuntimeException | Error ex) {
|
||||||
|
queryFailure = ex;
|
||||||
|
throw ex;
|
||||||
|
} finally {
|
||||||
|
if (localCursorTransaction) {
|
||||||
|
restoreCursorConnection(
|
||||||
|
connection, queryFailure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回滚只读游标事务并恢复连接池连接状态。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param queryFailure 查询阶段异常;为空表示查询成功
|
||||||
|
* @throws SQLException 清理失败且查询本身成功
|
||||||
|
*/
|
||||||
|
private void restoreCursorConnection(
|
||||||
|
Connection connection,
|
||||||
|
Throwable queryFailure) throws SQLException {
|
||||||
|
SQLException cleanupFailure = null;
|
||||||
|
try {
|
||||||
|
connection.rollback();
|
||||||
|
} catch (SQLException ex) {
|
||||||
|
cleanupFailure = ex;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
connection.setAutoCommit(true);
|
||||||
|
} catch (SQLException ex) {
|
||||||
|
if (cleanupFailure == null) {
|
||||||
|
cleanupFailure = ex;
|
||||||
|
} else {
|
||||||
|
cleanupFailure.addSuppressed(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleanupFailure == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (queryFailure != null) {
|
||||||
|
queryFailure.addSuppressed(cleanupFailure);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw cleanupFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前连接器是否使用 PostgreSQL 游标协议。
|
||||||
|
*
|
||||||
|
* @return PostgreSQL 或 GaussDB 原生连接器返回 true
|
||||||
|
*/
|
||||||
|
private boolean usesPostgresqlCursor() {
|
||||||
|
return sourceType == DatacenterSourceType.POSTGRESQL
|
||||||
|
|| sourceType
|
||||||
|
== DatacenterSourceType.GAUSSDB_NATIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置长结果游标的拉取策略和超时。
|
||||||
|
*
|
||||||
|
* @param statement JDBC 语句
|
||||||
|
* @param fetchSize 建议拉取行数
|
||||||
|
* @throws SQLException 配置失败
|
||||||
|
*/
|
||||||
|
private void configureCursorQuery(
|
||||||
|
PreparedStatement statement,
|
||||||
|
int fetchSize) throws SQLException {
|
||||||
|
if (sourceType == DatacenterSourceType.MYSQL
|
||||||
|
|| sourceType
|
||||||
|
== DatacenterSourceType.PROJECT_MYSQL
|
||||||
|
|| sourceType == DatacenterSourceType.GBASE_8A
|
||||||
|
|| sourceType == DatacenterSourceType.GBASE_8S) {
|
||||||
|
// MySQL 协议以该值启用前向只读流,避免驱动先缓存完整结果。
|
||||||
|
statement.setFetchSize(Integer.MIN_VALUE);
|
||||||
|
} else {
|
||||||
|
statement.setFetchSize(fetchSize);
|
||||||
|
}
|
||||||
|
int timeoutSeconds = Integer.getInteger(
|
||||||
|
"easyflow.datacenter.query.timeout-seconds",
|
||||||
|
300);
|
||||||
|
if (timeoutSeconds > 0) {
|
||||||
|
statement.setQueryTimeout(timeoutSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为原生查询配置宽松但有限的流式拉取和超时。
|
||||||
|
*
|
||||||
|
* @param statement JDBC 语句
|
||||||
|
* @throws SQLException 配置失败
|
||||||
|
*/
|
||||||
|
private void configureStreamingQuery(
|
||||||
|
PreparedStatement statement) throws SQLException {
|
||||||
|
int fetchSize = Integer.getInteger(
|
||||||
|
"easyflow.datacenter.query.fetch-size", 1_000);
|
||||||
|
int timeoutSeconds = Integer.getInteger(
|
||||||
|
"easyflow.datacenter.query.timeout-seconds", 300);
|
||||||
|
if (fetchSize > 0) {
|
||||||
|
statement.setFetchSize(fetchSize);
|
||||||
|
}
|
||||||
|
if (timeoutSeconds > 0) {
|
||||||
|
statement.setQueryTimeout(timeoutSeconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,17 +541,61 @@ public abstract class AbstractJdbcConnector implements DatacenterConnector {
|
|||||||
protected List<Row> readRows(ResultSet resultSet) throws SQLException {
|
protected List<Row> readRows(ResultSet resultSet) throws SQLException {
|
||||||
List<Row> records = new ArrayList<>();
|
List<Row> records = new ArrayList<>();
|
||||||
ResultSetMetaData metaData = resultSet.getMetaData();
|
ResultSetMetaData metaData = resultSet.getMetaData();
|
||||||
|
int maxRows = Integer.getInteger(
|
||||||
|
"easyflow.datacenter.query.max-rows", 1_000_000);
|
||||||
|
long maxBytes = Long.getLong(
|
||||||
|
"easyflow.datacenter.query.max-bytes",
|
||||||
|
512L * 1024L * 1024L);
|
||||||
|
long estimatedBytes = 0L;
|
||||||
while (resultSet.next()) {
|
while (resultSet.next()) {
|
||||||
|
if (maxRows > 0 && records.size() >= maxRows) {
|
||||||
|
throw new SQLException(
|
||||||
|
"数据集查询结果超过行数上限: " + maxRows);
|
||||||
|
}
|
||||||
Row row = new Row();
|
Row row = new Row();
|
||||||
for (int i = 1; i <= metaData.getColumnCount(); i++) {
|
for (int i = 1; i <= metaData.getColumnCount(); i++) {
|
||||||
String columnLabel = metaData.getColumnLabel(i);
|
String columnLabel = metaData.getColumnLabel(i);
|
||||||
row.put(columnLabel, normalizeValue(resultSet.getObject(i)));
|
Object value = normalizeValue(resultSet.getObject(i));
|
||||||
|
row.put(columnLabel, value);
|
||||||
|
estimatedBytes += estimateQueryValueBytes(
|
||||||
|
columnLabel, value);
|
||||||
|
if (maxBytes > 0L && estimatedBytes > maxBytes) {
|
||||||
|
throw new SQLException(
|
||||||
|
"数据集查询结果超过字节上限: "
|
||||||
|
+ maxBytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
records.add(row);
|
records.add(row);
|
||||||
}
|
}
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 估算查询结果在 JVM 中的最低占用,作为失控保护。
|
||||||
|
*
|
||||||
|
* @param columnLabel 列名
|
||||||
|
* @param value 列值
|
||||||
|
* @return 估算字节数
|
||||||
|
*/
|
||||||
|
private long estimateQueryValueBytes(
|
||||||
|
String columnLabel, Object value) {
|
||||||
|
long bytes = columnLabel == null
|
||||||
|
? 0L
|
||||||
|
: (long) columnLabel.length() * Character.BYTES;
|
||||||
|
if (value == null) {
|
||||||
|
return bytes + 8L;
|
||||||
|
}
|
||||||
|
if (value instanceof byte[]) {
|
||||||
|
return bytes + ((byte[]) value).length;
|
||||||
|
}
|
||||||
|
if (value instanceof CharSequence) {
|
||||||
|
return bytes
|
||||||
|
+ (long) value.toString().length()
|
||||||
|
* Character.BYTES;
|
||||||
|
}
|
||||||
|
return bytes + 64L;
|
||||||
|
}
|
||||||
|
|
||||||
protected String resolveCatalogArgument(DatacenterSource source, String catalogName) {
|
protected String resolveCatalogArgument(DatacenterSource source, String catalogName) {
|
||||||
return usesCatalogNamespace() ? resolveCatalogName(source, catalogName) : source.getDatabaseName();
|
return usesCatalogNamespace() ? resolveCatalogName(source, catalogName) : source.getDatabaseName();
|
||||||
}
|
}
|
||||||
@@ -488,7 +724,7 @@ public abstract class AbstractJdbcConnector implements DatacenterConnector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Object normalizeValue(Object value) {
|
protected Object normalizeValue(Object value) {
|
||||||
if (value instanceof BigDecimal || value instanceof BigInteger || value instanceof Long) {
|
if (value instanceof BigDecimal || value instanceof BigInteger || value instanceof Long) {
|
||||||
return value.toString();
|
return value.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package tech.easyflow.datacenter.connector.support;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据集写入回执键工具。
|
||||||
|
*/
|
||||||
|
public final class WriteReceiptSupport {
|
||||||
|
|
||||||
|
private WriteReceiptSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为一行数据派生固定长度的稳定回执键。
|
||||||
|
*
|
||||||
|
* @param operationReceiptKey 整次写入的回执键
|
||||||
|
* @param rowIndex 行序号
|
||||||
|
* @return SHA-256 行回执键
|
||||||
|
*/
|
||||||
|
public static String childKey(String operationReceiptKey, int rowIndex) {
|
||||||
|
String value = operationReceiptKey + ':' + rowIndex;
|
||||||
|
try {
|
||||||
|
return HexFormat.of().formatHex(
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(
|
||||||
|
value.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException error) {
|
||||||
|
throw new IllegalStateException("SHA-256 is unavailable", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package tech.easyflow.datacenter.execution.model;
|
|||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
|
||||||
public class DatasetRef {
|
public class DatasetRef implements java.io.Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
private BigInteger sourceId;
|
private BigInteger sourceId;
|
||||||
private BigInteger catalogId;
|
private BigInteger catalogId;
|
||||||
private String catalogName;
|
private String catalogName;
|
||||||
|
|||||||
@@ -8,11 +8,51 @@ import tech.easyflow.datacenter.execution.model.DatacenterSqlQueryRequest;
|
|||||||
import tech.easyflow.datacenter.execution.model.DatasetRef;
|
import tech.easyflow.datacenter.execution.model.DatasetRef;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public interface DatacenterDatasetQueryService {
|
public interface DatacenterDatasetQueryService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询结构化数据集。
|
||||||
|
*
|
||||||
|
* @param request 查询请求
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
Page<Row> queryPage(DatacenterQueryRequest request);
|
Page<Row> queryPage(DatacenterQueryRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行原生 SQL 并返回完整结果。
|
||||||
|
*
|
||||||
|
* @param request SQL 查询请求
|
||||||
|
* @return 完整结果
|
||||||
|
*/
|
||||||
List<Row> queryBySql(DatacenterSqlQueryRequest request);
|
List<Row> queryBySql(DatacenterSqlQueryRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用单次数据库查询流式消费原生 SQL 结果。
|
||||||
|
*
|
||||||
|
* @param request SQL 查询请求
|
||||||
|
* @param fetchSize JDBC 建议拉取行数
|
||||||
|
* @param consumer 单行消费者
|
||||||
|
*/
|
||||||
|
void consumeBySql(
|
||||||
|
DatacenterSqlQueryRequest request,
|
||||||
|
int fetchSize,
|
||||||
|
Consumer<Row> consumer);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据集结构。
|
||||||
|
*
|
||||||
|
* @param datasetRef 数据集引用
|
||||||
|
* @return 数据集结构
|
||||||
|
*/
|
||||||
DatacenterSchemaResponse getSchema(DatasetRef datasetRef);
|
DatacenterSchemaResponse getSchema(DatasetRef datasetRef);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅解析数据集定位信息,不加载版本和血缘。
|
||||||
|
*
|
||||||
|
* @param datasetRef 数据集引用
|
||||||
|
* @return 包含 source、catalog、table 的轻量响应
|
||||||
|
*/
|
||||||
|
DatacenterSchemaResponse getLocation(DatasetRef datasetRef);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,54 @@ import tech.easyflow.common.entity.LoginAccount;
|
|||||||
import tech.easyflow.datacenter.execution.model.DatasetRef;
|
import tech.easyflow.datacenter.execution.model.DatasetRef;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface DatacenterDatasetWriteService {
|
public interface DatacenterDatasetWriteService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存单行数据。
|
||||||
|
*
|
||||||
|
* @param datasetRef 数据集引用
|
||||||
|
* @param data 待保存数据
|
||||||
|
* @param account 当前操作账号
|
||||||
|
*/
|
||||||
void saveRow(DatasetRef datasetRef, JSONObject data, LoginAccount account);
|
void saveRow(DatasetRef datasetRef, JSONObject data, LoginAccount account);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量保存数据集行。
|
||||||
|
*
|
||||||
|
* @param datasetRef 数据集引用
|
||||||
|
* @param rows 待保存数据行
|
||||||
|
* @param account 当前操作账号
|
||||||
|
* @param batchSize 单批最大行数
|
||||||
|
*/
|
||||||
|
void saveRows(DatasetRef datasetRef, List<JSONObject> rows, LoginAccount account, int batchSize);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用稳定幂等键批量保存数据集行。
|
||||||
|
*
|
||||||
|
* @param datasetRef 数据集引用
|
||||||
|
* @param rows 待保存数据行
|
||||||
|
* @param account 当前操作账号
|
||||||
|
* @param batchSize 单批最大行数
|
||||||
|
* @param idempotencyKey 稳定业务幂等键;为空时保持普通写入语义
|
||||||
|
* @return 本次实际执行写入时为 {@code true},已有成功记录时为 {@code false}
|
||||||
|
*/
|
||||||
|
default boolean saveRowsIdempotently(DatasetRef datasetRef,
|
||||||
|
List<JSONObject> rows,
|
||||||
|
LoginAccount account,
|
||||||
|
int batchSize,
|
||||||
|
String idempotencyKey) {
|
||||||
|
saveRows(datasetRef, rows, account, batchSize);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除单行数据。
|
||||||
|
*
|
||||||
|
* @param datasetRef 数据集引用
|
||||||
|
* @param id 数据主键
|
||||||
|
* @param account 当前操作账号
|
||||||
|
*/
|
||||||
void deleteRow(DatasetRef datasetRef, BigInteger id, LoginAccount account);
|
void deleteRow(DatasetRef datasetRef, BigInteger id, LoginAccount account);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,13 @@ import tech.easyflow.datacenter.utils.SqlSupportUtils;
|
|||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class DatacenterDatasetQueryServiceImpl implements DatacenterDatasetQueryService {
|
public class DatacenterDatasetQueryServiceImpl implements DatacenterDatasetQueryService {
|
||||||
@@ -73,6 +78,98 @@ public class DatacenterDatasetQueryServiceImpl implements DatacenterDatasetQuery
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Row> queryBySql(DatacenterSqlQueryRequest request) {
|
public List<Row> queryBySql(DatacenterSqlQueryRequest request) {
|
||||||
|
ResolvedSqlQuery query = resolveSqlQuery(request);
|
||||||
|
return query.connector.queryBySql(
|
||||||
|
query.source, query.sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void consumeBySql(
|
||||||
|
DatacenterSqlQueryRequest request,
|
||||||
|
int fetchSize,
|
||||||
|
Consumer<Row> consumer) {
|
||||||
|
if (fetchSize <= 0 || consumer == null) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"fetchSize and consumer must be valid");
|
||||||
|
}
|
||||||
|
ResolvedSqlQuery query = resolveSqlQuery(request);
|
||||||
|
int maxRows = Integer.getInteger(
|
||||||
|
"easyflow.datacenter.query.max-rows",
|
||||||
|
1_000_000);
|
||||||
|
long maxBytes = Long.getLong(
|
||||||
|
"easyflow.datacenter.query.max-bytes",
|
||||||
|
512L * 1024L * 1024L);
|
||||||
|
long[] accumulatedRows = {0L};
|
||||||
|
long[] accumulatedBytes = {0L};
|
||||||
|
query.connector.consumeBySql(
|
||||||
|
query.source,
|
||||||
|
query.sql,
|
||||||
|
fetchSize,
|
||||||
|
row -> {
|
||||||
|
accumulatedRows[0]++;
|
||||||
|
if (maxRows > 0
|
||||||
|
&& accumulatedRows[0] > maxRows) {
|
||||||
|
throw new BusinessException(
|
||||||
|
"数据集查询结果超过行数上限: "
|
||||||
|
+ maxRows);
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, Object> entry
|
||||||
|
: row.entrySet()) {
|
||||||
|
accumulatedBytes[0] +=
|
||||||
|
estimateQueryValueBytes(
|
||||||
|
entry.getKey(),
|
||||||
|
entry.getValue());
|
||||||
|
if (maxBytes > 0L
|
||||||
|
&& accumulatedBytes[0] > maxBytes) {
|
||||||
|
throw new BusinessException(
|
||||||
|
"数据集查询结果超过字节上限: "
|
||||||
|
+ maxBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
consumer.accept(row);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 估算查询值在 JVM 中的最低占用,用于跨页累计保护。
|
||||||
|
*
|
||||||
|
* @param columnLabel 列名
|
||||||
|
* @param value 列值
|
||||||
|
* @return 估算字节数
|
||||||
|
*/
|
||||||
|
private long estimateQueryValueBytes(
|
||||||
|
String columnLabel,
|
||||||
|
Object value) {
|
||||||
|
long bytes = columnLabel == null
|
||||||
|
? 0L
|
||||||
|
: (long) columnLabel.length()
|
||||||
|
* Character.BYTES;
|
||||||
|
if (value == null) {
|
||||||
|
return bytes + 8L;
|
||||||
|
}
|
||||||
|
if (value instanceof byte[] binary) {
|
||||||
|
return bytes + binary.length;
|
||||||
|
}
|
||||||
|
if (value instanceof CharSequence text) {
|
||||||
|
return bytes
|
||||||
|
+ (long) text.length()
|
||||||
|
* Character.BYTES;
|
||||||
|
}
|
||||||
|
return bytes + 64L;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验请求并解析实际连接器与可执行 SQL。
|
||||||
|
*
|
||||||
|
* @param request SQL 查询请求
|
||||||
|
* @return 已解析查询
|
||||||
|
*/
|
||||||
|
private ResolvedSqlQuery resolveSqlQuery(
|
||||||
|
DatacenterSqlQueryRequest request) {
|
||||||
if (request == null || request.getDatasetRef() == null) {
|
if (request == null || request.getDatasetRef() == null) {
|
||||||
throw new BusinessException("datasetRef 不能为空");
|
throw new BusinessException("datasetRef 不能为空");
|
||||||
}
|
}
|
||||||
@@ -90,12 +187,33 @@ public class DatacenterDatasetQueryServiceImpl implements DatacenterDatasetQuery
|
|||||||
if (CollectionUtils.isEmpty(managedTables)) {
|
if (CollectionUtils.isEmpty(managedTables)) {
|
||||||
throw new BusinessException("当前连接下没有已接入表");
|
throw new BusinessException("当前连接下没有已接入表");
|
||||||
}
|
}
|
||||||
|
Map<BigInteger, DatacenterCatalog> catalogsById =
|
||||||
|
loadCatalogsById(managedTables);
|
||||||
SqlSupportUtils.ResolvedSql resolvedSql = SqlSupportUtils.resolve(
|
SqlSupportUtils.ResolvedSql resolvedSql = SqlSupportUtils.resolve(
|
||||||
sql,
|
sql,
|
||||||
managedTables.stream().map(this::toManagedSqlTable).toList()
|
managedTables.stream()
|
||||||
|
.map(table -> toManagedSqlTable(
|
||||||
|
table, catalogsById))
|
||||||
|
.toList()
|
||||||
);
|
);
|
||||||
DatacenterConnector connector = connectorRegistry.getConnector(source.getSourceType());
|
DatacenterConnector connector = connectorRegistry.getConnector(source.getSourceType());
|
||||||
return connector.queryBySql(source, resolvedSql.getExecutableSql());
|
return new ResolvedSqlQuery(
|
||||||
|
source,
|
||||||
|
connector,
|
||||||
|
resolvedSql.getExecutableSql());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次已校验的 SQL 查询上下文。
|
||||||
|
*
|
||||||
|
* @param source 数据源
|
||||||
|
* @param connector 数据连接器
|
||||||
|
* @param sql 可执行 SQL
|
||||||
|
*/
|
||||||
|
private record ResolvedSqlQuery(
|
||||||
|
DatacenterSource source,
|
||||||
|
DatacenterConnector connector,
|
||||||
|
String sql) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -115,6 +233,23 @@ public class DatacenterDatasetQueryServiceImpl implements DatacenterDatasetQuery
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public DatacenterSchemaResponse getLocation(DatasetRef datasetRef) {
|
||||||
|
DatacenterTable table = resolveTable(datasetRef);
|
||||||
|
DatacenterSchemaResponse response =
|
||||||
|
new DatacenterSchemaResponse();
|
||||||
|
response.setDatasetRef(datasetRef);
|
||||||
|
response.setSource(
|
||||||
|
registryService.getSourceRequired(table.getSourceId()));
|
||||||
|
response.setCatalog(
|
||||||
|
registryService.getCatalogById(table.getCatalogId()));
|
||||||
|
response.setTable(table);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
private DatacenterTable resolveTable(DatasetRef datasetRef) {
|
private DatacenterTable resolveTable(DatasetRef datasetRef) {
|
||||||
if (datasetRef.getTableId() != null) {
|
if (datasetRef.getTableId() != null) {
|
||||||
return registryService.getTableWithFields(datasetRef.getTableId());
|
return registryService.getTableWithFields(datasetRef.getTableId());
|
||||||
@@ -174,8 +309,13 @@ public class DatacenterDatasetQueryServiceImpl implements DatacenterDatasetQuery
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private SqlSupportUtils.ManagedTable toManagedSqlTable(DatacenterTable table) {
|
private SqlSupportUtils.ManagedTable toManagedSqlTable(
|
||||||
DatacenterCatalog catalog = registryService.getCatalogById(table.getCatalogId());
|
DatacenterTable table,
|
||||||
|
Map<BigInteger, DatacenterCatalog> catalogsById) {
|
||||||
|
BigInteger catalogId = table.getCatalogId();
|
||||||
|
DatacenterCatalog catalog = catalogId == null
|
||||||
|
? null
|
||||||
|
: catalogsById.get(catalogId);
|
||||||
return new SqlSupportUtils.ManagedTable(
|
return new SqlSupportUtils.ManagedTable(
|
||||||
catalog == null ? null : catalog.getCatalogName(),
|
catalog == null ? null : catalog.getCatalogName(),
|
||||||
table.getTableName(),
|
table.getTableName(),
|
||||||
@@ -183,6 +323,32 @@ public class DatacenterDatasetQueryServiceImpl implements DatacenterDatasetQuery
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次批量加载 SQL 白名单表关联的目录,避免逐表查询。
|
||||||
|
*
|
||||||
|
* @param managedTables 已接入表
|
||||||
|
* @return 目录 ID 到目录实体
|
||||||
|
*/
|
||||||
|
private Map<BigInteger, DatacenterCatalog> loadCatalogsById(
|
||||||
|
List<DatacenterTable> managedTables) {
|
||||||
|
Set<BigInteger> catalogIds = managedTables.stream()
|
||||||
|
.map(DatacenterTable::getCatalogId)
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.collect(Collectors.toCollection(
|
||||||
|
LinkedHashSet::new));
|
||||||
|
if (catalogIds.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
QueryWrapper wrapper = QueryWrapper.create();
|
||||||
|
wrapper.in(DatacenterCatalog::getId, catalogIds);
|
||||||
|
return catalogMapper.selectListByQuery(wrapper).stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
DatacenterCatalog::getId,
|
||||||
|
Function.identity(),
|
||||||
|
(first, ignored) -> first,
|
||||||
|
LinkedHashMap::new));
|
||||||
|
}
|
||||||
|
|
||||||
private String resolvePhysicalTableName(DatacenterTable table) {
|
private String resolvePhysicalTableName(DatacenterTable table) {
|
||||||
if (table == null) {
|
if (table == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package tech.easyflow.datacenter.execution.service.impl;
|
package tech.easyflow.datacenter.execution.service.impl;
|
||||||
|
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.alibaba.fastjson2.JSONWriter;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import tech.easyflow.common.cache.RedisIdempotencyExecutor;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.datacenter.connector.DatacenterConnector;
|
import tech.easyflow.datacenter.connector.DatacenterConnector;
|
||||||
@@ -14,6 +17,11 @@ import tech.easyflow.datacenter.execution.service.DatacenterDatasetWriteService;
|
|||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class DatacenterDatasetWriteServiceImpl implements DatacenterDatasetWriteService {
|
public class DatacenterDatasetWriteServiceImpl implements DatacenterDatasetWriteService {
|
||||||
@@ -22,7 +30,12 @@ public class DatacenterDatasetWriteServiceImpl implements DatacenterDatasetWrite
|
|||||||
private DatacenterDatasetRegistryService registryService;
|
private DatacenterDatasetRegistryService registryService;
|
||||||
@Resource
|
@Resource
|
||||||
private DatacenterConnectorRegistry connectorRegistry;
|
private DatacenterConnectorRegistry connectorRegistry;
|
||||||
|
@Resource
|
||||||
|
private RedisIdempotencyExecutor idempotencyExecutor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void saveRow(DatasetRef datasetRef, JSONObject data, LoginAccount account) {
|
public void saveRow(DatasetRef datasetRef, JSONObject data, LoginAccount account) {
|
||||||
DatacenterTable table = resolveTable(datasetRef);
|
DatacenterTable table = resolveTable(datasetRef);
|
||||||
@@ -31,6 +44,55 @@ public class DatacenterDatasetWriteServiceImpl implements DatacenterDatasetWrite
|
|||||||
connector.saveRow(source, table, data, account);
|
connector.saveRow(source, table, data, account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void saveRows(DatasetRef datasetRef, List<JSONObject> rows, LoginAccount account, int batchSize) {
|
||||||
|
if (rows == null || rows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DatacenterTable table = resolveTable(datasetRef);
|
||||||
|
DatacenterSource source = registryService.getSourceRequired(table.getSourceId());
|
||||||
|
DatacenterConnector connector = connectorRegistry.getConnector(source.getSourceType());
|
||||||
|
connector.saveRows(source, table, rows, account, Math.max(1, batchSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean saveRowsIdempotently(DatasetRef datasetRef,
|
||||||
|
List<JSONObject> rows,
|
||||||
|
LoginAccount account,
|
||||||
|
int batchSize,
|
||||||
|
String idempotencyKey) {
|
||||||
|
if (rows == null || rows.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
DatacenterTable table = resolveTable(datasetRef);
|
||||||
|
DatacenterSource source = registryService.getSourceRequired(table.getSourceId());
|
||||||
|
DatacenterConnector connector = connectorRegistry.getConnector(source.getSourceType());
|
||||||
|
String payloadHash = sha256Rows(rows);
|
||||||
|
if (idempotencyKey == null || idempotencyKey.isBlank()) {
|
||||||
|
connector.saveRows(source, table, rows, account, Math.max(1, batchSize));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String receiptKey = sha256(idempotencyKey);
|
||||||
|
return idempotencyExecutor.executeOnce(idempotencyKey, payloadHash, () ->
|
||||||
|
connector.saveRowsIdempotently(
|
||||||
|
source,
|
||||||
|
table,
|
||||||
|
rows,
|
||||||
|
account,
|
||||||
|
Math.max(1, batchSize),
|
||||||
|
receiptKey,
|
||||||
|
payloadHash));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void deleteRow(DatasetRef datasetRef, BigInteger id, LoginAccount account) {
|
public void deleteRow(DatasetRef datasetRef, BigInteger id, LoginAccount account) {
|
||||||
DatacenterTable table = resolveTable(datasetRef);
|
DatacenterTable table = resolveTable(datasetRef);
|
||||||
@@ -45,4 +107,46 @@ public class DatacenterDatasetWriteServiceImpl implements DatacenterDatasetWrite
|
|||||||
}
|
}
|
||||||
return registryService.getTableWithFields(datasetRef.getTableId());
|
return registryService.getTableWithFields(datasetRef.getTableId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算稳定的 SHA-256 摘要。
|
||||||
|
*
|
||||||
|
* @param value 原始文本
|
||||||
|
* @return 十六进制摘要
|
||||||
|
*/
|
||||||
|
private String sha256(String value) {
|
||||||
|
try {
|
||||||
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException error) {
|
||||||
|
throw new IllegalStateException("SHA-256 is unavailable", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐行计算与排序字段 JSON 数组等价的 SHA-256,避免构造整批字符串副本。
|
||||||
|
*
|
||||||
|
* @param rows 待写入行
|
||||||
|
* @return 十六进制摘要
|
||||||
|
*/
|
||||||
|
private String sha256Rows(List<JSONObject> rows) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest =
|
||||||
|
MessageDigest.getInstance("SHA-256");
|
||||||
|
digest.update((byte) '[');
|
||||||
|
for (int index = 0; index < rows.size(); index++) {
|
||||||
|
if (index > 0) {
|
||||||
|
digest.update((byte) ',');
|
||||||
|
}
|
||||||
|
digest.update(JSON.toJSONBytes(
|
||||||
|
rows.get(index),
|
||||||
|
JSONWriter.Feature.MapSortField));
|
||||||
|
}
|
||||||
|
digest.update((byte) ']');
|
||||||
|
return HexFormat.of().formatHex(digest.digest());
|
||||||
|
} catch (NoSuchAlgorithmException error) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"SHA-256 is unavailable", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package tech.easyflow.datacenter.schedule;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.common.cache.DistributedScheduledLock;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定期分批清理过期的数据集写入幂等回执。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DatacenterWriteReceiptCleanupJob {
|
||||||
|
|
||||||
|
private static final Logger log =
|
||||||
|
LoggerFactory.getLogger(DatacenterWriteReceiptCleanupJob.class);
|
||||||
|
private static final String DELETE_SQL =
|
||||||
|
"DELETE FROM tb_datacenter_write_receipt "
|
||||||
|
+ "WHERE created < ? ORDER BY created LIMIT ?";
|
||||||
|
private static final long MIN_RETENTION_DAYS = 7L;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final long retentionDays;
|
||||||
|
private final int batchSize;
|
||||||
|
private final int maxBatches;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建回执清理任务。
|
||||||
|
*
|
||||||
|
* @param jdbcTemplate JDBC 操作模板
|
||||||
|
* @param retentionDays 回执保留天数,最低七天
|
||||||
|
* @param batchSize 单批删除行数
|
||||||
|
* @param maxBatches 单次调度最多删除批次
|
||||||
|
*/
|
||||||
|
public DatacenterWriteReceiptCleanupJob(
|
||||||
|
JdbcTemplate jdbcTemplate,
|
||||||
|
@Value("${easyflow.workflow.data-write-receipt-retention-days:14}")
|
||||||
|
long retentionDays,
|
||||||
|
@Value("${easyflow.workflow.data-write-receipt-cleanup-batch-size:1000}")
|
||||||
|
int batchSize,
|
||||||
|
@Value("${easyflow.workflow.data-write-receipt-cleanup-max-batches:20}")
|
||||||
|
int maxBatches) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.retentionDays = Math.max(MIN_RETENTION_DAYS, retentionDays);
|
||||||
|
this.batchSize = Math.max(1, batchSize);
|
||||||
|
this.maxBatches = Math.max(1, maxBatches);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在单个集群节点上删除一批超过保留期的回执。
|
||||||
|
*/
|
||||||
|
@Scheduled(
|
||||||
|
fixedDelayString =
|
||||||
|
"${easyflow.workflow.data-write-receipt-cleanup-interval:1h}",
|
||||||
|
initialDelayString =
|
||||||
|
"${easyflow.workflow.data-write-receipt-cleanup-initial-delay:10m}")
|
||||||
|
@DistributedScheduledLock(
|
||||||
|
key = "easyflow:schedule:datacenter-write-receipt-cleanup",
|
||||||
|
leaseSeconds = 300L)
|
||||||
|
public void cleanup() {
|
||||||
|
Timestamp cutoff = Timestamp.from(
|
||||||
|
Instant.now().minus(retentionDays, ChronoUnit.DAYS));
|
||||||
|
int totalDeleted = 0;
|
||||||
|
try {
|
||||||
|
for (int batch = 0; batch < maxBatches; batch++) {
|
||||||
|
int deleted = jdbcTemplate.update(
|
||||||
|
DELETE_SQL, cutoff, batchSize);
|
||||||
|
totalDeleted += deleted;
|
||||||
|
if (deleted < batchSize) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalDeleted > 0) {
|
||||||
|
log.info(
|
||||||
|
"Cleaned {} expired datacenter write receipts",
|
||||||
|
totalDeleted);
|
||||||
|
}
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
log.error("Datacenter write receipt cleanup failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package tech.easyflow.datacenter.execution.service.impl;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.row.Row;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentMatchers;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import tech.easyflow.datacenter.connector.DatacenterConnector;
|
||||||
|
import tech.easyflow.datacenter.connector.DatacenterConnectorRegistry;
|
||||||
|
import tech.easyflow.datacenter.entity.DatacenterTable;
|
||||||
|
import tech.easyflow.datacenter.execution.model.DatacenterSqlQueryRequest;
|
||||||
|
import tech.easyflow.datacenter.execution.model.DatasetRef;
|
||||||
|
import tech.easyflow.datacenter.meta.entity.DatacenterSource;
|
||||||
|
import tech.easyflow.datacenter.meta.service.DatacenterDatasetRegistryService;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link DatacenterDatasetQueryServiceImpl} 分页 SQL 读取回归测试。
|
||||||
|
*/
|
||||||
|
public class DatacenterDatasetQueryServiceImplTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证惰性迭代器逐页读取并保持原始行顺序。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void consumeBySqlShouldStreamSingleQueryInOrder()
|
||||||
|
throws Exception {
|
||||||
|
BigInteger sourceId = BigInteger.valueOf(1001L);
|
||||||
|
DatacenterSource source = new DatacenterSource();
|
||||||
|
source.setId(sourceId);
|
||||||
|
source.setSourceType("MYSQL");
|
||||||
|
DatacenterTable table = new DatacenterTable();
|
||||||
|
table.setId(BigInteger.valueOf(2001L));
|
||||||
|
table.setSourceId(sourceId);
|
||||||
|
table.setTableName("orders");
|
||||||
|
table.setActualTable("orders_actual");
|
||||||
|
|
||||||
|
DatacenterDatasetRegistryService registry =
|
||||||
|
Mockito.mock(
|
||||||
|
DatacenterDatasetRegistryService.class);
|
||||||
|
Mockito.when(registry.getSourceRequired(sourceId))
|
||||||
|
.thenReturn(source);
|
||||||
|
Mockito.when(registry.listManagedTables(
|
||||||
|
sourceId, null))
|
||||||
|
.thenReturn(List.of(table));
|
||||||
|
DatacenterConnector connector =
|
||||||
|
Mockito.mock(DatacenterConnector.class);
|
||||||
|
Mockito.doAnswer(invocation -> {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Consumer<Row> consumer =
|
||||||
|
invocation.getArgument(3);
|
||||||
|
consumer.accept(row(1));
|
||||||
|
consumer.accept(row(2));
|
||||||
|
consumer.accept(row(3));
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.when(connector)
|
||||||
|
.consumeBySql(
|
||||||
|
ArgumentMatchers.eq(source),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.eq(2),
|
||||||
|
ArgumentMatchers.any());
|
||||||
|
DatacenterConnectorRegistry connectors =
|
||||||
|
Mockito.mock(
|
||||||
|
DatacenterConnectorRegistry.class);
|
||||||
|
Mockito.when(connectors.getConnector("MYSQL"))
|
||||||
|
.thenReturn(connector);
|
||||||
|
|
||||||
|
DatacenterDatasetQueryServiceImpl service =
|
||||||
|
new DatacenterDatasetQueryServiceImpl();
|
||||||
|
setField(service, "registryService", registry);
|
||||||
|
setField(service, "connectorRegistry", connectors);
|
||||||
|
DatacenterSqlQueryRequest request =
|
||||||
|
new DatacenterSqlQueryRequest();
|
||||||
|
DatasetRef datasetRef = new DatasetRef();
|
||||||
|
datasetRef.setSourceId(sourceId);
|
||||||
|
request.setDatasetRef(datasetRef);
|
||||||
|
request.setSql("SELECT * FROM orders ORDER BY id");
|
||||||
|
|
||||||
|
List<Integer> ids = new ArrayList<>();
|
||||||
|
service.consumeBySql(
|
||||||
|
request,
|
||||||
|
2,
|
||||||
|
current -> ids.add(
|
||||||
|
current.getInt("id")));
|
||||||
|
|
||||||
|
Assert.assertEquals(
|
||||||
|
List.of(1, 2, 3), ids);
|
||||||
|
Mockito.verify(connector, Mockito.times(1))
|
||||||
|
.consumeBySql(
|
||||||
|
ArgumentMatchers.eq(source),
|
||||||
|
ArgumentMatchers.anyString(),
|
||||||
|
ArgumentMatchers.eq(2),
|
||||||
|
ArgumentMatchers.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建测试数据行。
|
||||||
|
*
|
||||||
|
* @param id 行 ID
|
||||||
|
* @return 数据行
|
||||||
|
*/
|
||||||
|
private Row row(int id) {
|
||||||
|
Row row = new Row();
|
||||||
|
row.put("id", id);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注入被测服务依赖。
|
||||||
|
*
|
||||||
|
* @param target 被测对象
|
||||||
|
* @param name 字段名
|
||||||
|
* @param value 字段值
|
||||||
|
* @throws Exception 反射失败时抛出
|
||||||
|
*/
|
||||||
|
private void setField(
|
||||||
|
Object target,
|
||||||
|
String name,
|
||||||
|
Object value) throws Exception {
|
||||||
|
Field field = target.getClass()
|
||||||
|
.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(target, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ import org.springframework.stereotype.Component;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action 请求与响应日志采集配置。
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
@ConfigurationProperties(prefix = "easyflow.log.reporter")
|
@ConfigurationProperties(prefix = "easyflow.log.reporter")
|
||||||
public class ActionLogReporterProperties {
|
public class ActionLogReporterProperties {
|
||||||
@@ -35,6 +38,7 @@ public class ActionLogReporterProperties {
|
|||||||
"/css/**",
|
"/css/**",
|
||||||
"/images/**",
|
"/images/**",
|
||||||
"/favicon.ico",
|
"/favicon.ico",
|
||||||
|
"/api/v1/agent/media/**",
|
||||||
"/actuator/**",
|
"/actuator/**",
|
||||||
"*.js",
|
"*.js",
|
||||||
"*.css",
|
"*.css",
|
||||||
@@ -45,36 +49,75 @@ public class ActionLogReporterProperties {
|
|||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// getter and setter
|
/**
|
||||||
|
* 判断 Action 报告是否启用。
|
||||||
|
*
|
||||||
|
* @return 是否启用
|
||||||
|
*/
|
||||||
public boolean isEnabled() {
|
public boolean isEnabled() {
|
||||||
return enabled;
|
return enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 Action 报告开关。
|
||||||
|
*
|
||||||
|
* @param enabled 是否启用
|
||||||
|
*/
|
||||||
public void setEnabled(boolean enabled) {
|
public void setEnabled(boolean enabled) {
|
||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取日志采样率。
|
||||||
|
*
|
||||||
|
* @return 采样率
|
||||||
|
*/
|
||||||
public double getSampleRate() {
|
public double getSampleRate() {
|
||||||
return sampleRate;
|
return sampleRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置日志采样率。
|
||||||
|
*
|
||||||
|
* @param sampleRate 采样率
|
||||||
|
*/
|
||||||
public void setSampleRate(double sampleRate) {
|
public void setSampleRate(double sampleRate) {
|
||||||
this.sampleRate = sampleRate;
|
this.sampleRate = sampleRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取需要采集的路径模式。
|
||||||
|
*
|
||||||
|
* @return 包含路径模式
|
||||||
|
*/
|
||||||
public List<String> getIncludePatterns() {
|
public List<String> getIncludePatterns() {
|
||||||
return includePatterns;
|
return includePatterns;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置需要采集的路径模式。
|
||||||
|
*
|
||||||
|
* @param includePatterns 包含路径模式
|
||||||
|
*/
|
||||||
public void setIncludePatterns(List<String> includePatterns) {
|
public void setIncludePatterns(List<String> includePatterns) {
|
||||||
this.includePatterns = includePatterns;
|
this.includePatterns = includePatterns;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取禁止缓存正文的路径模式。
|
||||||
|
*
|
||||||
|
* @return 排除路径模式
|
||||||
|
*/
|
||||||
public List<String> getExcludePatterns() {
|
public List<String> getExcludePatterns() {
|
||||||
return excludePatterns;
|
return excludePatterns;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置禁止缓存正文的路径模式。
|
||||||
|
*
|
||||||
|
* @param excludePatterns 排除路径模式
|
||||||
|
*/
|
||||||
public void setExcludePatterns(List<String> excludePatterns) {
|
public void setExcludePatterns(List<String> excludePatterns) {
|
||||||
this.excludePatterns = excludePatterns;
|
this.excludePatterns = excludePatterns;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package tech.easyflow.log.reporter;
|
|||||||
import jakarta.servlet.*;
|
import jakarta.servlet.*;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -15,7 +14,7 @@ import java.io.IOException;
|
|||||||
import static org.springframework.core.Ordered.HIGHEST_PRECEDENCE;
|
import static org.springframework.core.Ordered.HIGHEST_PRECEDENCE;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 响应缓存 Filter,支持基于路径的排除规则
|
* 缓存需要记录的请求和响应正文,并绕过流式或敏感路径。
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@Order(HIGHEST_PRECEDENCE)
|
@Order(HIGHEST_PRECEDENCE)
|
||||||
@@ -27,10 +26,20 @@ import static org.springframework.core.Ordered.HIGHEST_PRECEDENCE;
|
|||||||
)
|
)
|
||||||
public class ResponseCachingFilter implements Filter {
|
public class ResponseCachingFilter implements Filter {
|
||||||
|
|
||||||
@Autowired
|
private final ActionLogReporterProperties logProperties;
|
||||||
private ActionLogReporterProperties logProperties;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建响应缓存过滤器。
|
||||||
|
*
|
||||||
|
* @param logProperties 日志采集路径配置
|
||||||
|
*/
|
||||||
|
public ResponseCachingFilter(ActionLogReporterProperties logProperties) {
|
||||||
|
this.logProperties = logProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||||
throws IOException, ServletException {
|
throws IOException, ServletException {
|
||||||
@@ -39,25 +48,19 @@ public class ResponseCachingFilter implements Filter {
|
|||||||
String uri = httpRequest.getRequestURI();
|
String uri = httpRequest.getRequestURI();
|
||||||
String method = httpRequest.getMethod();
|
String method = httpRequest.getMethod();
|
||||||
|
|
||||||
// 1如果是 OPTIONS 请求,跳过(通常为预检)
|
// OPTIONS 请求通常是预检,不需要缓存正文。
|
||||||
if ("OPTIONS".equalsIgnoreCase(method)) {
|
if ("OPTIONS".equalsIgnoreCase(method)) {
|
||||||
chain.doFilter(request, response);
|
chain.doFilter(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// // 检查是否为 SSE 请求
|
// 流式下载和敏感媒体路径必须在包装响应前排除,避免截断异步响应或缓存文件正文。
|
||||||
// if (isSseRequest(httpRequest)) {
|
|
||||||
// chain.doFilter(request, response);
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 检查是否匹配排除路径
|
|
||||||
if (isExcluded(uri)) {
|
if (isExcluded(uri)) {
|
||||||
chain.doFilter(request, response);
|
chain.doFilter(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否匹配包含路径(一般为 /**,可省略)
|
// 检查是否匹配包含路径(一般为 /**)。
|
||||||
if (!isIncluded(uri)) {
|
if (!isIncluded(uri)) {
|
||||||
chain.doFilter(request, response);
|
chain.doFilter(request, response);
|
||||||
return;
|
return;
|
||||||
@@ -65,31 +68,45 @@ public class ResponseCachingFilter implements Filter {
|
|||||||
|
|
||||||
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(httpRequest);
|
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(httpRequest);
|
||||||
if (isSseRequest(httpRequest)) {
|
if (isSseRequest(httpRequest)) {
|
||||||
// SSE 请求不缓存
|
// SSE 响应不能经过响应缓存,否则会破坏实时输出。
|
||||||
chain.doFilter(requestWrapper, response);
|
chain.doFilter(requestWrapper, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
HttpServletResponse httpResponse = (HttpServletResponse) response;
|
HttpServletResponse httpResponse = (HttpServletResponse) response;
|
||||||
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(httpResponse);
|
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(httpResponse);
|
||||||
try {
|
try {
|
||||||
chain.doFilter(requestWrapper, responseWrapper);
|
chain.doFilter(requestWrapper, responseWrapper);
|
||||||
} finally {
|
} finally {
|
||||||
responseWrapper.copyBodyToResponse(); // 必须调用
|
responseWrapper.copyBodyToResponse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断请求路径是否禁止缓存。
|
||||||
|
*
|
||||||
|
* @param uri 请求路径
|
||||||
|
* @return 是否排除
|
||||||
|
*/
|
||||||
private boolean isExcluded(String uri) {
|
private boolean isExcluded(String uri) {
|
||||||
return logProperties.getExcludePatterns().stream().anyMatch(p -> match(uri, p));
|
return logProperties.getExcludePatterns().stream().anyMatch(p -> match(uri, p));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断请求路径是否需要采集。
|
||||||
|
*
|
||||||
|
* @param uri 请求路径
|
||||||
|
* @return 是否包含
|
||||||
|
*/
|
||||||
private boolean isIncluded(String uri) {
|
private boolean isIncluded(String uri) {
|
||||||
return logProperties.getIncludePatterns().stream().anyMatch(p -> match(uri, p));
|
return logProperties.getIncludePatterns().stream().anyMatch(p -> match(uri, p));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否为 SSE 请求(基于标准 Accept 头)
|
* 根据标准 Accept 请求头判断是否为 SSE 请求。
|
||||||
|
*
|
||||||
|
* @param request HTTP 请求
|
||||||
|
* @return 是否为 SSE 请求
|
||||||
*/
|
*/
|
||||||
private boolean isSseRequest(HttpServletRequest request) {
|
private boolean isSseRequest(HttpServletRequest request) {
|
||||||
String accept = request.getHeader("Accept");
|
String accept = request.getHeader("Accept");
|
||||||
@@ -97,8 +114,11 @@ public class ResponseCachingFilter implements Filter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 简单的路径匹配(支持 * 和 **)
|
* 匹配支持星号和双星号的简单路径模式。
|
||||||
* 注意:这里简化实现,生产可替换为 AntPathMatcher
|
*
|
||||||
|
* @param path 请求路径
|
||||||
|
* @param pattern 路径模式
|
||||||
|
* @return 是否匹配
|
||||||
*/
|
*/
|
||||||
private boolean match(String path, String pattern) {
|
private boolean match(String path, String pattern) {
|
||||||
if (pattern.equals("/**")) {
|
if (pattern.equals("/**")) {
|
||||||
@@ -119,4 +139,4 @@ public class ResponseCachingFilter implements Filter {
|
|||||||
}
|
}
|
||||||
return path.equals(pattern);
|
return path.equals(pattern);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package tech.easyflow.log.reporter;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link ResponseCachingFilter} 路径排除行为测试。
|
||||||
|
*/
|
||||||
|
public class ResponseCachingFilterTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Agent 媒体下载保持原始响应,避免缓存包装器截断异步文件流。
|
||||||
|
*
|
||||||
|
* @throws Exception Filter 执行失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void agentMediaDownloadShouldBypassResponseCaching() throws Exception {
|
||||||
|
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||||
|
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||||
|
FilterChain chain = mock(FilterChain.class);
|
||||||
|
when(request.getMethod()).thenReturn("GET");
|
||||||
|
when(request.getRequestURI()).thenReturn("/api/v1/agent/media/document/content");
|
||||||
|
|
||||||
|
ResponseCachingFilter filter = new ResponseCachingFilter(new ActionLogReporterProperties());
|
||||||
|
filter.doFilter(request, response, chain);
|
||||||
|
|
||||||
|
verify(chain).doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `tb_datacenter_write_receipt`
|
||||||
|
(
|
||||||
|
`idempotency_key` VARCHAR(64) NOT NULL COMMENT '稳定幂等键摘要',
|
||||||
|
`payload_hash` VARCHAR(64) NOT NULL COMMENT '写入负载摘要',
|
||||||
|
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`idempotency_key`),
|
||||||
|
KEY `idx_datacenter_write_receipt_created` (`created`)
|
||||||
|
) ENGINE = InnoDB
|
||||||
|
DEFAULT CHARSET = utf8mb4
|
||||||
|
COLLATE = utf8mb4_general_ci
|
||||||
|
COMMENT = '数据中心工作流写入幂等回执';
|
||||||
@@ -36,16 +36,33 @@
|
|||||||
<maxHistory>30</maxHistory>
|
<maxHistory>30</maxHistory>
|
||||||
</rollingPolicy>
|
</rollingPolicy>
|
||||||
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
|
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
|
||||||
<pattern>%d{MM-dd HH:mm:ss.SSS} |-%-5level %logger{36}:%L - %m%n</pattern>
|
<pattern>%d{MM-dd HH:mm:ss.SSS} |-%-5level %logger{36} - %m%n</pattern>
|
||||||
</encoder>
|
</encoder>
|
||||||
</appender>
|
</appender>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
INFO 及以下日志进入有界异步队列;队列接近容量时仅允许 Logback 丢弃低于 WARN
|
||||||
|
的事件,WARN/ERROR 继续阻塞等待写入并保留完整异常栈。
|
||||||
|
-->
|
||||||
|
<appender name="ASYNC_CONSOLE" class="ch.qos.logback.classic.AsyncAppender">
|
||||||
|
<queueSize>8192</queueSize>
|
||||||
|
<discardingThreshold>1638</discardingThreshold>
|
||||||
|
<neverBlock>false</neverBlock>
|
||||||
|
<maxFlushTime>0</maxFlushTime>
|
||||||
|
<includeCallerData>false</includeCallerData>
|
||||||
|
<appender-ref ref="CONSOLE"/>
|
||||||
|
</appender>
|
||||||
|
<appender name="ASYNC_LOGFILE" class="ch.qos.logback.classic.AsyncAppender">
|
||||||
|
<queueSize>8192</queueSize>
|
||||||
|
<discardingThreshold>1638</discardingThreshold>
|
||||||
|
<neverBlock>false</neverBlock>
|
||||||
|
<maxFlushTime>0</maxFlushTime>
|
||||||
|
<includeCallerData>false</includeCallerData>
|
||||||
|
<appender-ref ref="LOGFILE"/>
|
||||||
|
</appender>
|
||||||
|
|
||||||
<root level="DEBUG">
|
<root level="DEBUG">
|
||||||
<appender-ref ref="CONSOLE" />
|
<appender-ref ref="ASYNC_CONSOLE"/>
|
||||||
<appender-ref ref="LOGFILE" />
|
<appender-ref ref="ASYNC_LOGFILE"/>
|
||||||
</root>
|
</root>
|
||||||
<root level="INFO">
|
</configuration>
|
||||||
<appender-ref ref="CONSOLE" />
|
|
||||||
<appender-ref ref="LOGFILE" />
|
|
||||||
</root>
|
|
||||||
</configuration>
|
|
||||||
|
|||||||
@@ -84,6 +84,53 @@
|
|||||||
let currentNodeId = getCurrentNodeId();
|
let currentNodeId = getCurrentNodeId();
|
||||||
let wrapperElement: HTMLDivElement | null = null;
|
let wrapperElement: HTMLDivElement | null = null;
|
||||||
const nodeSizeObserver = useTinyflowNodeSizeObserver();
|
const nodeSizeObserver = useTinyflowNodeSizeObserver();
|
||||||
|
const MIN_LOOP_COUNT = 1;
|
||||||
|
const MAX_LOOP_COUNT = 300;
|
||||||
|
let loopCountHint = $state('');
|
||||||
|
|
||||||
|
const normalizeLoopCount = (value: unknown) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < MIN_LOOP_COUNT) {
|
||||||
|
return MIN_LOOP_COUNT;
|
||||||
|
}
|
||||||
|
return Math.min(parsed, MAX_LOOP_COUNT);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLoopEnabled = (event: Event) => {
|
||||||
|
const loopEnable = (event.target as HTMLInputElement).checked;
|
||||||
|
updateNodeData(currentNodeId, {
|
||||||
|
loopEnable,
|
||||||
|
...(loopEnable ? {maxLoopCount: normalizeLoopCount(data.maxLoopCount)} : {})
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMaxLoopCount = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const parsed = Number(input.value);
|
||||||
|
const normalized = normalizeLoopCount(input.value);
|
||||||
|
if (Number.isFinite(parsed) && parsed > MAX_LOOP_COUNT) {
|
||||||
|
loopCountHint = `最大支持 ${MAX_LOOP_COUNT} 次,已调整为 ${MAX_LOOP_COUNT}`;
|
||||||
|
} else if (!Number.isInteger(parsed) || parsed < MIN_LOOP_COUNT) {
|
||||||
|
loopCountHint = `请输入 ${MIN_LOOP_COUNT}~${MAX_LOOP_COUNT} 的整数`;
|
||||||
|
} else {
|
||||||
|
loopCountHint = '';
|
||||||
|
}
|
||||||
|
updateNodeData(currentNodeId, {maxLoopCount: normalized});
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!data.loopEnable) {
|
||||||
|
loopCountHint = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalized = normalizeLoopCount(data.maxLoopCount);
|
||||||
|
if (Number(data.maxLoopCount) !== normalized) {
|
||||||
|
loopCountHint = Number(data.maxLoopCount) > MAX_LOOP_COUNT
|
||||||
|
? `最大支持 ${MAX_LOOP_COUNT} 次,已调整为 ${MAX_LOOP_COUNT}`
|
||||||
|
: `请输入 ${MIN_LOOP_COUNT}~${MAX_LOOP_COUNT} 的整数`;
|
||||||
|
updateNodeData(currentNodeId, {maxLoopCount: normalized});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!wrapperElement) {
|
if (!wrapperElement) {
|
||||||
@@ -187,12 +234,7 @@
|
|||||||
|
|
||||||
<label class="input-item-inline">
|
<label class="input-item-inline">
|
||||||
<span>循环执行:</span>
|
<span>循环执行:</span>
|
||||||
<input type="checkbox" checked={!!data.loopEnable} onchange={(event)=>{
|
<input type="checkbox" checked={!!data.loopEnable} onchange={updateLoopEnabled} />
|
||||||
const value = (event.target as any).checked;
|
|
||||||
updateNodeData(currentNodeId,{
|
|
||||||
loopEnable: value
|
|
||||||
})
|
|
||||||
}} />
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{#if !!data.loopEnable}
|
{#if !!data.loopEnable}
|
||||||
@@ -207,13 +249,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-item">
|
<div class="input-item">
|
||||||
最大循环次数(0 表示不限制):
|
循环次数:
|
||||||
<Textarea rows={1} style="width: 100%;" onchange={(event)=>{
|
<Input
|
||||||
const value = (event.target as any).value;
|
type="number"
|
||||||
updateNodeData(currentNodeId,{
|
min={MIN_LOOP_COUNT}
|
||||||
maxLoopCount: value
|
max={MAX_LOOP_COUNT}
|
||||||
})
|
step="1"
|
||||||
}} value={data.maxLoopCount || '0'} />
|
style="width: 100%;"
|
||||||
|
onchange={updateMaxLoopCount}
|
||||||
|
value={data.maxLoopCount ?? MIN_LOOP_COUNT}
|
||||||
|
/>
|
||||||
|
{#if loopCountHint}
|
||||||
|
<span class="input-hint" role="status" aria-live="polite">{loopCountHint}</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-item">
|
<div class="input-item">
|
||||||
@@ -337,6 +385,11 @@
|
|||||||
color: var(--tf-text-secondary);
|
color: var(--tf-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-hint {
|
||||||
|
color: var(--tf-warning-soft-text);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
input[type='checkbox'] {
|
input[type='checkbox'] {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user