perf: 收敛工作流状态与高 IO 节点开销
- 落地 Redis 版本状态、触发租约和定义缓存 - 优化数据批写、插件请求、文件下载与审计日志 - 补齐循环范围校验、轮询兼容和专项测试
This commit is contained in:
@@ -1,182 +1,461 @@
|
||||
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.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 java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
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,
|
||||
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());
|
||||
private PluginHttpClient() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理请求头(合并默认头和参数头)
|
||||
* 发送插件请求。
|
||||
*
|
||||
* @param url 插件 URL
|
||||
* @param method HTTP 方法
|
||||
* @param headers 默认请求头
|
||||
* @param pluginParams 插件参数
|
||||
* @return JSON 响应
|
||||
*/
|
||||
private static void processHeaders(HttpRequest request,
|
||||
Map<String, Object> defaultHeaders,
|
||||
List<PluginParam> params) {
|
||||
// 添加默认头
|
||||
if (ObjectUtil.isNotEmpty(defaultHeaders)) {
|
||||
defaultHeaders.forEach((k, v) -> request.header(k, v.toString()));
|
||||
}
|
||||
public static JSONObject sendRequest(
|
||||
String url,
|
||||
String method,
|
||||
Map<String, Object> headers,
|
||||
List<PluginParam> pluginParams) {
|
||||
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);
|
||||
|
||||
// 添加参数中指定的头
|
||||
params.stream()
|
||||
.filter(p -> "header".equalsIgnoreCase(p.getMethod()) && p.isEnabled())
|
||||
.forEach(p -> request.header(p.getName(), p.getDefaultValue().toString()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理查询参数和请求体
|
||||
*/
|
||||
/**
|
||||
* 处理查询参数和请求体(新增文件参数支持)
|
||||
*/
|
||||
private static void processQueryAndBodyParams(HttpRequest request,
|
||||
Method httpMethod,
|
||||
List<PluginParam> params) {
|
||||
Map<String, Object> queryParams = new HashMap<>();
|
||||
Map<String, Object> bodyParams = new HashMap<>();
|
||||
// 标记是否包含文件参数
|
||||
AtomicBoolean hasMultipartFile = new AtomicBoolean(false);
|
||||
|
||||
// 分类参数(同时检测是否有文件)
|
||||
params.stream()
|
||||
.filter(PluginParam::isEnabled)
|
||||
.forEach(p -> {
|
||||
String methodType = p.getMethod().toLowerCase();
|
||||
Object paramValue = buildNestedParamValue(p);
|
||||
|
||||
// 检测是否为文件参数(MultipartFile 类型)
|
||||
if (paramValue instanceof org.springframework.web.multipart.MultipartFile) {
|
||||
hasMultipartFile.set(true);
|
||||
}
|
||||
|
||||
switch (methodType) {
|
||||
case "query":
|
||||
queryParams.put(p.getName(), paramValue);
|
||||
break;
|
||||
case "body":
|
||||
bodyParams.put(p.getName(), paramValue);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// 1. 设置查询参数(原有逻辑不变)
|
||||
if (!queryParams.isEmpty()) {
|
||||
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());
|
||||
Semaphore hostPermits = hostSemaphore(processedUrl.host());
|
||||
boolean hostAcquired = false;
|
||||
boolean globalAcquired = false;
|
||||
try {
|
||||
hostAcquired = hostPermits.tryAcquire(
|
||||
1L, TimeUnit.SECONDS);
|
||||
if (!hostAcquired) {
|
||||
throw new IllegalStateException(
|
||||
"插件目标并发已达上限: " + processedUrl.host());
|
||||
}
|
||||
globalAcquired = GLOBAL_PERMITS.tryAcquire(
|
||||
1L, TimeUnit.SECONDS);
|
||||
if (!globalAcquired) {
|
||||
throw new IllegalStateException(
|
||||
"插件 HTTP 并发已达上限");
|
||||
}
|
||||
OkHttpClient client = isIdempotent(normalizedMethod)
|
||||
? CLIENT
|
||||
: NO_RETRY_CLIENT;
|
||||
try (Response response =
|
||||
client.newCall(requestBuilder.build()).execute()) {
|
||||
ResponseBody body = response.body();
|
||||
if (body == null) {
|
||||
throw new IllegalStateException("插件响应内容为空");
|
||||
}
|
||||
long maxBytes = Long.getLong(
|
||||
"easyflow.plugin.http.max-response-bytes",
|
||||
DEFAULT_MAX_RESPONSE_BYTES);
|
||||
String responseText = readBounded(body, maxBytes);
|
||||
return JSONUtil.parseObj(responseText);
|
||||
}
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(
|
||||
"插件 HTTP 请求被中断", error);
|
||||
} catch (IOException error) {
|
||||
throw new IllegalStateException(
|
||||
"插件 HTTP 请求失败", error);
|
||||
} finally {
|
||||
if (globalAcquired) {
|
||||
GLOBAL_PERMITS.release();
|
||||
}
|
||||
if (hostAcquired) {
|
||||
hostPermits.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归构建嵌套参数值
|
||||
* @param param 当前参数
|
||||
* @return 如果是 Object 类型,返回 Map;否则返回 defaultValue
|
||||
* 创建共享 OkHttp 客户端。
|
||||
*
|
||||
* @param retryOnConnectionFailure 是否允许连接级自动恢复
|
||||
* @return 共享客户端
|
||||
*/
|
||||
private static Object buildNestedParamValue(PluginParam param) {
|
||||
// 如果不是 Object 类型,直接返回默认值
|
||||
private static OkHttpClient buildClient(
|
||||
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())) {
|
||||
return param.getDefaultValue();
|
||||
}
|
||||
|
||||
// 如果是 Object 类型,递归处理子参数
|
||||
Map<String, Object> nestedParams = new HashMap<>();
|
||||
Map<String, Object> nested = new HashMap<>();
|
||||
if (param.getChildren() != null) {
|
||||
param.getChildren().stream()
|
||||
.filter(PluginParam::isEnabled)
|
||||
.forEach(child -> {
|
||||
Object childValue = buildNestedParamValue(child); // 递归处理子参数
|
||||
nestedParams.put(child.getName(), childValue);
|
||||
});
|
||||
for (PluginParam child : param.getChildren()) {
|
||||
if (child.isEnabled()) {
|
||||
nested.put(
|
||||
child.getName(),
|
||||
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) {
|
||||
String result = url;
|
||||
|
||||
// 收集路径参数
|
||||
Map<String, Object> pathParams = new HashMap<>();
|
||||
params.stream()
|
||||
.filter(p -> "path".equalsIgnoreCase(p.getMethod()) && p.isEnabled())
|
||||
.forEach(p -> pathParams.put(p.getName(), p.getDefaultValue()));
|
||||
|
||||
// 替换变量
|
||||
for (Map.Entry<String, Object> entry : pathParams.entrySet()) {
|
||||
result = result.replaceAll("\\{" + entry.getKey() + "\\}",
|
||||
entry.getValue().toString());
|
||||
private static String replacePathVariables(
|
||||
String url, List<PluginParam> params) {
|
||||
String result = Objects.requireNonNull(
|
||||
url, "插件 URL 不能为空");
|
||||
for (PluginParam param : params) {
|
||||
if (param.isEnabled()
|
||||
&& "path".equalsIgnoreCase(param.getMethod())
|
||||
&& param.getDefaultValue() != null) {
|
||||
result = result.replace(
|
||||
"{" + param.getName() + "}",
|
||||
String.valueOf(param.getDefaultValue()));
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
String paramName = entry.getKey();
|
||||
Object paramValue = entry.getValue();
|
||||
|
||||
if (paramValue instanceof MultipartFile) {
|
||||
MultipartFile file = (MultipartFile) paramValue;
|
||||
try {
|
||||
request.form(paramName, file.getBytes(), file.getOriginalFilename());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(String.format("文件参数处理失败:参数名=%s,文件名=%s",
|
||||
paramName, file.getOriginalFilename()), e);
|
||||
}
|
||||
} 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);
|
||||
/**
|
||||
* 获取主机隔离许可并严格限制注册表体积。
|
||||
*
|
||||
* @param host 主机名
|
||||
* @return 主机许可
|
||||
*/
|
||||
private static Semaphore hostSemaphore(String host) {
|
||||
Semaphore existing = HOST_PERMITS.get(host);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
synchronized (HOST_REGISTRY_LOCK) {
|
||||
existing = HOST_PERMITS.get(host);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
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_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 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:";
|
||||
}
|
||||
|
||||
@@ -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> RENEW_LOCK_SCRIPT;
|
||||
private static final DefaultRedisScript<Long> NEXT_FENCING_TOKEN_SCRIPT;
|
||||
private static final DefaultRedisScript<Long> ACQUIRE_FENCED_LOCK_SCRIPT;
|
||||
|
||||
static {
|
||||
RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>();
|
||||
@@ -40,6 +42,19 @@ public class RedisLockExecutor {
|
||||
"else return 0 end"
|
||||
);
|
||||
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
|
||||
@@ -132,7 +147,53 @@ public class RedisLockExecutor {
|
||||
if (!acquired) {
|
||||
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 lockValue;
|
||||
private final Duration leaseTimeout;
|
||||
private final long fencingToken;
|
||||
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.lockValue = lockValue;
|
||||
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")
|
||||
private ValueOperations<String, String> mockValueOperations(boolean acquired) {
|
||||
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 shard = keySupport.resolveShard(message.getKey(), shardCount);
|
||||
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);
|
||||
RecordId recordId = stringRedisTemplate.opsForStream().add(
|
||||
StreamRecords.string(Map.of("payload", messageConverter.serialize(message))).withStreamKey(streamKey)
|
||||
@@ -59,7 +59,7 @@ public class RedisMQProducer implements MQProducer {
|
||||
if (recordId == null) {
|
||||
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());
|
||||
return recordId.getValue();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user