feat: 新增嵌入式分布式调度底座

- 提供通用调度 API、Quartz JDBC Provider 与独立 Starter

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

- 同步完善 Federation 与 Scheduler 模块说明
This commit is contained in:
2026-08-26 18:15:13 +08:00
parent 02b8fdd3ae
commit b4bdc392ee
47 changed files with 5169 additions and 1 deletions

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-scheduler</artifactId>
<version>${revision}</version>
</parent>
<artifactId>easy-agents-scheduler-core</artifactId>
<name>easy-agents-scheduler-core</name>
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,13 @@
package com.easyagents.scheduler;
/**
* 同一调度任务的并发执行策略。
*/
public enum ConcurrencyPolicy {
/** 允许同一任务的不同触发同时执行。 */
ALLOW,
/** 同一任务任意时刻最多执行一个触发。 */
DISALLOW
}

View File

@@ -0,0 +1,43 @@
package com.easyagents.scheduler;
import java.time.ZoneId;
import java.util.Objects;
/**
* 带显式时区的 Cron 调度计划。
*
* @param expression Quartz Cron 表达式
* @param zoneId 解释 Cron 表达式的时区
*/
public record CronSchedulePlan(String expression, ZoneId zoneId) implements SchedulePlan {
/** Quartz 官方 JDBC 表中 Cron 表达式的最大长度。 */
public static final int MAX_EXPRESSION_LENGTH = 120;
/** Quartz 官方 JDBC 表中时区标识的最大长度。 */
public static final int MAX_ZONE_ID_LENGTH = 80;
/**
* 校验并创建 Cron 调度计划。
*
* @throws IllegalArgumentException 表达式为空时抛出
* @throws NullPointerException 时区为空时抛出
*/
public CronSchedulePlan {
if (expression == null || expression.isBlank()) {
throw new IllegalArgumentException("cron expression must not be blank");
}
expression = expression.trim().replaceAll("\\s+", " ");
if (expression.length() > MAX_EXPRESSION_LENGTH) {
throw new IllegalArgumentException(
"cron expression length must not exceed " + MAX_EXPRESSION_LENGTH
);
}
zoneId = Objects.requireNonNull(zoneId, "zoneId must not be null");
if (zoneId.getId().length() > MAX_ZONE_ID_LENGTH) {
throw new IllegalArgumentException(
"zoneId length must not exceed " + MAX_ZONE_ID_LENGTH
);
}
}
}

View File

@@ -0,0 +1,13 @@
package com.easyagents.scheduler;
/**
* 调度器错过计划时间后的处理策略。
*/
public enum MisfirePolicy {
/** 跳过已经错过的触发。 */
SKIP,
/** 恢复后立即补触发一次。 */
FIRE_ONCE_NOW
}

View File

@@ -0,0 +1,23 @@
package com.easyagents.scheduler;
import java.time.Instant;
import java.util.Objects;
/**
* 只执行一次的调度计划。
*
* @param fireAt 计划触发时间
*/
public record OnceSchedulePlan(Instant fireAt) implements SchedulePlan {
/**
* 校验并创建一次性调度计划。
*
* @throws NullPointerException 触发时间为空时抛出
*/
public OnceSchedulePlan {
fireAt = Objects.requireNonNull(fireAt, "fireAt must not be null");
// Quartz 和关系数据库均以毫秒保存时间,提前归一化可保持持久化往返相等。
fireAt = Instant.ofEpochMilli(fireAt.toEpochMilli());
}
}

View File

@@ -0,0 +1,121 @@
package com.easyagents.scheduler;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Objects;
/**
* 与具体调度实现无关的任务定义。
*
* @param id 稳定任务标识
* @param handlerCode 任务处理器代码
* @param plan 调度计划
* @param misfirePolicy 错过触发后的处理策略
* @param concurrencyPolicy 同一任务的并发策略
* @param recoverOnNodeFailure 执行节点故障后是否请求恢复执行
* @param parameters 只包含字符串的不可变任务参数
* @param description 可选任务说明
*/
public record ScheduleDefinition(
ScheduleId id,
String handlerCode,
SchedulePlan plan,
MisfirePolicy misfirePolicy,
ConcurrencyPolicy concurrencyPolicy,
boolean recoverOnNodeFailure,
Map<String, String> parameters,
String description
) {
/** 参数键和值合计允许的默认最大 UTF-8 字节数。 */
public static final int MAX_PARAMETER_BYTES = 16 * 1024;
/** Handler Code 最大长度。 */
public static final int MAX_HANDLER_CODE_LENGTH = 190;
/** 任务说明最大长度,与 Quartz 官方表结构保持一致。 */
public static final int MAX_DESCRIPTION_LENGTH = 250;
/**
* 校验并创建任务定义。
*
* @throws ScheduleException 参数不合法时抛出
* @throws IllegalArgumentException Handler 或说明不合法时抛出
* @throws NullPointerException 必填模型为空时抛出
*/
public ScheduleDefinition {
id = Objects.requireNonNull(id, "id must not be null");
plan = Objects.requireNonNull(plan, "plan must not be null");
misfirePolicy = Objects.requireNonNull(misfirePolicy, "misfirePolicy must not be null");
concurrencyPolicy = Objects.requireNonNull(concurrencyPolicy, "concurrencyPolicy must not be null");
handlerCode = normalizeRequired(handlerCode, "handlerCode", MAX_HANDLER_CODE_LENGTH);
description = description == null ? "" : description.trim();
if (description.length() > MAX_DESCRIPTION_LENGTH) {
throw new IllegalArgumentException(
"description length must not exceed " + MAX_DESCRIPTION_LENGTH
);
}
parameters = copyAndValidateParameters(parameters);
}
/**
* 防御复制并校验字符串参数。
*
* @param parameters 待校验参数,可为空
* @return 不可变参数副本
* @throws ScheduleException 参数键为空、值为空或总大小超限时抛出
*/
public static Map<String, String> copyAndValidateParameters(Map<String, String> parameters) {
if (parameters == null || parameters.isEmpty()) {
return Map.of();
}
int totalBytes = 0;
for (Map.Entry<String, String> entry : parameters.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (key == null || key.isBlank()) {
throw invalidParameters("parameter key must not be blank");
}
if (value == null) {
throw invalidParameters("parameter value must not be null: " + key);
}
totalBytes += key.getBytes(StandardCharsets.UTF_8).length;
totalBytes += value.getBytes(StandardCharsets.UTF_8).length;
if (totalBytes > MAX_PARAMETER_BYTES) {
throw invalidParameters(
"parameter size must not exceed " + MAX_PARAMETER_BYTES + " UTF-8 bytes"
);
}
}
return Map.copyOf(parameters);
}
/**
* 构造参数非法异常。
*
* @param message 非法原因
* @return 携带稳定错误码的异常
*/
private static ScheduleException invalidParameters(String message) {
return new ScheduleException(ScheduleErrorCode.INVALID_DEFINITION, message);
}
/**
* 规范化必填字符串。
*
* @param value 原始值
* @param field 字段名
* @param maxLength 最大长度
* @return 规范化值
*/
private static String normalizeRequired(String value, String field, int maxLength) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
String normalized = value.trim();
if (normalized.length() > maxLength) {
throw new IllegalArgumentException(field + " length must not exceed " + maxLength);
}
return normalized;
}
}

View File

@@ -0,0 +1,31 @@
package com.easyagents.scheduler;
/**
* 调度底座向调用方暴露的稳定错误码。
*/
public enum ScheduleErrorCode {
/** 调度定义不合法。 */
INVALID_DEFINITION,
/** 同标识任务已存在且定义冲突。 */
SCHEDULE_CONFLICT,
/** 目标任务不存在。 */
SCHEDULE_NOT_FOUND,
/** 当前节点没有注册任务所需 Handler。 */
HANDLER_NOT_FOUND,
/** Handler Code 重复注册。 */
DUPLICATE_HANDLER,
/** Quartz 数据库结构缺失或不兼容。 */
SCHEMA_INVALID,
/** 调度 Provider 启动或运行失败。 */
PROVIDER_FAILURE,
/** 调度器已经关闭。 */
SCHEDULER_CLOSED
}

View File

@@ -0,0 +1,57 @@
package com.easyagents.scheduler;
import java.util.Objects;
/**
* 调度底座异常,携带稳定错误码供调用方分类处理。
*/
public class ScheduleException extends RuntimeException {
/** 稳定错误码。 */
private final ScheduleErrorCode errorCode;
/**
* 创建调度异常。
*
* @param errorCode 稳定错误码
* @param message 非空错误说明
*/
public ScheduleException(ScheduleErrorCode errorCode, String message) {
super(requireMessage(message));
this.errorCode = Objects.requireNonNull(errorCode, "errorCode must not be null");
}
/**
* 创建带原始原因的调度异常。
*
* @param errorCode 稳定错误码
* @param message 非空错误说明
* @param cause 原始异常
*/
public ScheduleException(ScheduleErrorCode errorCode, String message, Throwable cause) {
super(requireMessage(message), cause);
this.errorCode = Objects.requireNonNull(errorCode, "errorCode must not be null");
}
/**
* 返回稳定错误码。
*
* @return 错误码
*/
public ScheduleErrorCode errorCode() {
return errorCode;
}
/**
* 校验异常消息。
*
* @param message 原始消息
* @return 非空消息
*/
private static String requireMessage(String message) {
if (message == null || message.isBlank()) {
throw new IllegalArgumentException("message must not be blank");
}
return message;
}
}

View File

@@ -0,0 +1,46 @@
package com.easyagents.scheduler;
import java.time.Duration;
import java.time.Instant;
/**
* 调度执行事件监听器,适合接入日志、指标和诊断事件。
*/
public interface ScheduleExecutionListener {
/**
* Handler 开始执行时回调。
*
* @param context 触发上下文
*/
default void onStarted(ScheduleFireContext context) {
}
/**
* Handler 成功完成时回调。
*
* @param context 触发上下文
* @param duration 执行耗时
*/
default void onSucceeded(ScheduleFireContext context, Duration duration) {
}
/**
* Handler 执行失败时回调。
*
* @param context 触发上下文
* @param duration 失败前执行耗时
* @param failure 原始失败原因
*/
default void onFailed(ScheduleFireContext context, Duration duration, Throwable failure) {
}
/**
* 调度计划发生 Misfire 时回调。
*
* @param scheduleId 任务标识
* @param expectedFireTime 原计划触发时间,底层无法提供时为空
*/
default void onMisfired(ScheduleId scheduleId, Instant expectedFireTime) {
}
}

View File

@@ -0,0 +1,46 @@
package com.easyagents.scheduler;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
/**
* Handler 执行时可读取的稳定触发上下文。
*
* @param scheduleId 任务标识
* @param handlerCode Handler Code
* @param scheduledFireTime 计划触发时间
* @param actualFireTime 实际触发时间
* @param fireInstanceId 当前物理触发实例标识
* @param invocationId 立即触发调用标识,周期触发时可为空
* @param recovering 当前执行是否由节点故障恢复产生
* @param parameters 合并后的不可变字符串参数
*/
public record ScheduleFireContext(
ScheduleId scheduleId,
String handlerCode,
Instant scheduledFireTime,
Instant actualFireTime,
String fireInstanceId,
String invocationId,
boolean recovering,
Map<String, String> parameters
) {
/**
* 校验并创建触发上下文。
*
* @throws NullPointerException 必填字段为空时抛出
*/
public ScheduleFireContext {
scheduleId = Objects.requireNonNull(scheduleId, "scheduleId must not be null");
handlerCode = Objects.requireNonNull(handlerCode, "handlerCode must not be null");
scheduledFireTime = Objects.requireNonNull(
scheduledFireTime,
"scheduledFireTime must not be null"
);
actualFireTime = Objects.requireNonNull(actualFireTime, "actualFireTime must not be null");
fireInstanceId = Objects.requireNonNull(fireInstanceId, "fireInstanceId must not be null");
parameters = ScheduleDefinition.copyAndValidateParameters(parameters);
}
}

View File

@@ -0,0 +1,33 @@
package com.easyagents.scheduler;
import java.time.Instant;
import java.util.Objects;
/**
* 立即触发请求已被调度器接受的回执。
*
* @param scheduleId 任务标识
* @param invocationId 调用方提供的链路标识
* @param acceptedAt 调度器接受请求的时间
*/
public record ScheduleFireReceipt(
ScheduleId scheduleId,
String invocationId,
Instant acceptedAt
) {
/**
* 校验并创建立即触发回执。
*
* @throws IllegalArgumentException 调用标识为空时抛出
* @throws NullPointerException 任务标识或接受时间为空时抛出
*/
public ScheduleFireReceipt {
scheduleId = Objects.requireNonNull(scheduleId, "scheduleId must not be null");
acceptedAt = Objects.requireNonNull(acceptedAt, "acceptedAt must not be null");
if (invocationId == null || invocationId.isBlank()) {
throw new IllegalArgumentException("invocationId must not be blank");
}
invocationId = invocationId.trim();
}
}

View File

@@ -0,0 +1,22 @@
package com.easyagents.scheduler;
/**
* 调度任务处理器,由调用方注册到运行节点。
*/
public interface ScheduleHandler {
/**
* 返回跨节点一致的稳定 Handler Code。
*
* @return 非空 Handler Code
*/
String code();
/**
* 执行一次调度触发。
*
* @param context 触发上下文
* @throws Exception 业务执行失败时抛出,调度器保留原始异常链
*/
void execute(ScheduleFireContext context) throws Exception;
}

View File

@@ -0,0 +1,60 @@
package com.easyagents.scheduler;
/**
* 调度任务的稳定标识,由命名空间和名称组成。
*
* @param namespace 业务无关的命名空间
* @param name 命名空间内唯一的任务名称
*/
public record ScheduleId(String namespace, String name) {
/** 命名空间最大长度。 */
public static final int MAX_NAMESPACE_LENGTH = 160;
/** 任务名称最大长度。 */
public static final int MAX_NAME_LENGTH = 190;
/**
* 校验并创建任务标识。
*
* @throws IllegalArgumentException 标识为空、过长或含控制字符时抛出
*/
public ScheduleId {
namespace = requireIdentifier(namespace, "namespace", MAX_NAMESPACE_LENGTH);
name = requireIdentifier(name, "name", MAX_NAME_LENGTH);
}
/**
* 规范化并校验标识片段。
*
* @param value 原始值
* @param field 字段名
* @param maxLength 最大长度
* @return 规范化值
*/
private static String requireIdentifier(String value, String field, int maxLength) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
String normalized = value.trim();
if (normalized.length() > maxLength) {
throw new IllegalArgumentException(field + " length must not exceed " + maxLength);
}
for (int index = 0; index < normalized.length(); index++) {
if (Character.isISOControl(normalized.charAt(index))) {
throw new IllegalArgumentException(field + " must not contain control characters");
}
}
return normalized;
}
/**
* 返回便于日志和诊断使用的稳定文本。
*
* @return namespace/name 格式的任务标识
*/
@Override
public String toString() {
return namespace + "/" + name;
}
}

View File

@@ -0,0 +1,7 @@
package com.easyagents.scheduler;
/**
* 调度计划的封闭类型,首期支持 Cron 和一次性计划。
*/
public sealed interface SchedulePlan permits CronSchedulePlan, OnceSchedulePlan {
}

View File

@@ -0,0 +1,91 @@
package com.easyagents.scheduler;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 与具体调度实现无关的任务管理服务。
*/
public interface ScheduleService {
/**
* 创建任务;同标识同定义重复创建视为幂等成功。
*
* @param definition 任务定义
* @return 创建后的任务视图
* @throws ScheduleException 同标识存在不同定义或 Provider 失败时抛出
*/
ScheduleView create(ScheduleDefinition definition);
/**
* 将任务完整替换为目标定义,目标不存在时按目标定义创建。
*
* @param definition 目标任务定义
* @return 替换后的任务视图
* @throws ScheduleException Provider 失败时抛出
*/
ScheduleView replace(ScheduleDefinition definition);
/**
* 暂停任务的计划触发。
*
* @param scheduleId 任务标识
* @return 暂停后的任务视图
* @throws ScheduleException 任务不存在或 Provider 失败时抛出
*/
ScheduleView pause(ScheduleId scheduleId);
/**
* 恢复任务的计划触发。
*
* @param scheduleId 任务标识
* @return 恢复后的任务视图
* @throws ScheduleException 任务不存在或 Provider 失败时抛出
*/
ScheduleView resume(ScheduleId scheduleId);
/**
* 删除任务及其计划触发器。
*
* @param scheduleId 任务标识
* @return 存在并删除成功时为 true原本不存在时为 false
* @throws ScheduleException Provider 失败时抛出
*/
boolean delete(ScheduleId scheduleId);
/**
* 立即触发一次已存在任务。
*
* @param scheduleId 任务标识
* @param invocationId 调用方生成的链路和幂等标识
* @param parameters 仅对本次触发生效并覆盖同名基础参数的字符串参数
* @return 请求接受回执
* @throws ScheduleException 参数非法、任务不存在或 Provider 失败时抛出
*/
ScheduleFireReceipt triggerNow(
ScheduleId scheduleId,
String invocationId,
Map<String, String> parameters
);
/**
* 查询任务当前定义和状态。
*
* @param scheduleId 任务标识
* @return 任务不存在时为空
* @throws ScheduleException Provider 失败时抛出
*/
Optional<ScheduleView> get(ScheduleId scheduleId);
/**
* 从当前时间开始预览后续触发时间,不创建持久任务。
*
* @param plan 调度计划
* @param limit 最大返回数量
* @return 按时间升序排列的触发时间
* @throws ScheduleException 计划不合法时抛出
*/
List<Instant> nextFireTimes(SchedulePlan plan, int limit);
}

View File

@@ -0,0 +1,22 @@
package com.easyagents.scheduler;
/**
* 调度任务的实现无关状态。
*/
public enum ScheduleStatus {
/** 等待正常触发。 */
SCHEDULED,
/** 已暂停。 */
PAUSED,
/** 当前触发被并发策略阻塞。 */
BLOCKED,
/** 已完成且不会再次触发。 */
COMPLETE,
/** 调度存储将任务标记为错误。 */
ERROR
}

View File

@@ -0,0 +1,30 @@
package com.easyagents.scheduler;
import java.time.Instant;
import java.util.Objects;
/**
* 调度任务定义及当前运行状态的只读视图。
*
* @param definition 任务定义
* @param status 当前状态
* @param previousFireTime 上一次触发时间,可为空
* @param nextFireTime 下一次触发时间,可为空
*/
public record ScheduleView(
ScheduleDefinition definition,
ScheduleStatus status,
Instant previousFireTime,
Instant nextFireTime
) {
/**
* 校验并创建任务视图。
*
* @throws NullPointerException 定义或状态为空时抛出
*/
public ScheduleView {
definition = Objects.requireNonNull(definition, "definition must not be null");
status = Objects.requireNonNull(status, "status must not be null");
}
}

View File

@@ -0,0 +1,81 @@
package com.easyagents.scheduler;
import org.junit.Test;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
/**
* {@link ScheduleDefinition} 不可变性和输入边界测试。
*/
public class ScheduleDefinitionTest {
/**
* 验证任务参数会被防御复制。
*/
@Test
public void shouldDefensivelyCopyParameters() {
Map<String, String> parameters = new HashMap<>();
parameters.put("reportId", "r-1");
ScheduleDefinition definition = definition(parameters);
parameters.put("reportId", "r-2");
assertEquals("r-1", definition.parameters().get("reportId"));
assertThrows(
UnsupportedOperationException.class,
() -> definition.parameters().put("new", "value")
);
}
/**
* 验证超大参数会被拒绝。
*/
@Test
public void shouldRejectOversizedParameters() {
Map<String, String> parameters = Map.of(
"payload",
"x".repeat(ScheduleDefinition.MAX_PARAMETER_BYTES)
);
ScheduleException exception = assertThrows(
ScheduleException.class,
() -> definition(parameters)
);
assertEquals(ScheduleErrorCode.INVALID_DEFINITION, exception.errorCode());
}
/**
* 验证 Cron 表达式会规范化空白并拒绝超过 JDBC 表上限的内容。
*/
@Test
public void shouldNormalizeAndBoundCronExpression() {
CronSchedulePlan normalized = new CronSchedulePlan(
"0 0/5 * * * ?",
ZoneId.of("UTC")
);
assertEquals("0 0/5 * * * ?", normalized.expression());
assertThrows(
IllegalArgumentException.class,
() -> new CronSchedulePlan("0".repeat(121), ZoneId.of("UTC"))
);
}
private ScheduleDefinition definition(Map<String, String> parameters) {
return new ScheduleDefinition(
new ScheduleId("reports", "daily"),
"generate-report",
new CronSchedulePlan("0 0 2 * * ?", ZoneId.of("Asia/Shanghai")),
MisfirePolicy.FIRE_ONCE_NOW,
ConcurrencyPolicy.DISALLOW,
true,
parameters,
"daily report"
);
}
}