diff --git a/README.md b/README.md index be229fa..3165f2c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Easy-Agents 是一个轻量、可扩展的 Java AI 应用开发框架,覆盖 - MCP 客户端能力(调用、拦截、缓存与管理) - 文档读取与切分、向量存储与检索 - 工作流执行引擎(Flow)与 Easy-Agents 适配支持 +- 基于 Calcite 的 SQL 编译、方言适配与流式 JDBC 查询 +- 基于 Quartz JDBC JobStore 的嵌入式分布式定时调度 ## 模块说明 @@ -26,6 +28,8 @@ Easy-Agents 是一个轻量、可扩展的 Java AI 应用开发框架,覆盖 - `easy-agents-mcp`:MCP 集成。 - `easy-agents-skill`:标准 Agent Skills 包模型、安全校验、资源存储与 ZIP 双向编解码。 - `easy-agents-flow`:流程编排核心引擎。 +- `easy-agents-federation-sql`:高性能 SQL 联邦查询内核与可扩展数据库 Adapter。 +- `easy-agents-scheduler`:业务无关的调度 API、Quartz Provider 与独立 Spring Boot Starter。 - `easy-agents-support`:Flow 与 Easy-Agents 适配模块。 - `easy-agents-spring-boot-starter`:Spring Boot 自动配置支持。 - `easy-agents-samples`:示例工程。 @@ -76,7 +80,9 @@ public static void main(String[] args) { com.easyagents easy-agents-bom - 0.0.1 + 1.2.0-RC + pom + import @@ -92,3 +98,37 @@ public static void main(String[] args) { ``` + +## 嵌入式分布式调度 + +Spring Boot 项目可直接引入独立 Starter: + +```xml + + com.easyagents + easy-agents-scheduler-spring-boot-starter + +``` + +引用方需要先从 `easy-agents-scheduler-quartz` 构件的 `quartz-schema/` 目录选择 MySQL、PostgreSQL 或 H2 脚本,并纳入自己的 Flyway、Liquibase 或初始化流程。Starter 不会自动创建、删除或修改 Quartz 表。 + +最小配置: + +```yaml +easy-agents: + scheduler: + enabled: true + # 多 DataSource 时必须指定 Bean 名称 + data-source-bean-name: dataSource + quartz: + scheduler-name: easyAgentsScheduler + instance-id: AUTO + clustered: true + table-prefix: QRTZ_ + thread-count: 8 + shutdown-wait-timeout-millis: 30000 +``` + +业务方将 `ScheduleHandler` 注册为 Spring Bean,并通过 `ScheduleService` 创建 Cron 或一次性任务。调度触发采用至少一次语义,Handler 需要使用 `scheduleId + scheduledFireTime` 或立即触发的 `invocationId` 实现业务幂等。应用关闭超过等待上限后会向 Handler 线程发送协作式中断;长耗时 Handler 必须正确响应线程中断,忽略中断的业务代码仍可能继续占用 Quartz Worker。完整建表说明见 `easy-agents-scheduler/easy-agents-scheduler-quartz/SCHEMA.md`。 + +当前 Provider 固定使用 Quartz `2.5.2`,`easy-agents-bom` 已同步管理该传递依赖。若业务项目还引入了其他 BOM 或显式 Quartz 版本,接入时应执行 `mvn dependency:tree -Dincludes=org.quartz-scheduler:quartz`,确认最终解析版本仍为 `2.5.2`。 diff --git a/easy-agents-bom/pom.xml b/easy-agents-bom/pom.xml index 504ee6f..34242a4 100644 --- a/easy-agents-bom/pom.xml +++ b/easy-agents-bom/pom.xml @@ -11,6 +11,7 @@ easy-agents-bom easy-agents-bom + pom 17 @@ -19,6 +20,11 @@ + + org.quartz-scheduler + quartz + ${quartz.version} + com.easyagents easy-agents-federation-sql-core @@ -29,6 +35,21 @@ easy-agents-federation-sql-adapter-jdbc ${revision} + + com.easyagents + easy-agents-scheduler-core + ${revision} + + + com.easyagents + easy-agents-scheduler-quartz + ${revision} + + + com.easyagents + easy-agents-scheduler-spring-boot-starter + ${revision} + @@ -120,8 +141,20 @@ easy-agents-federation-sql-adapter-jdbc + + com.easyagents + easy-agents-scheduler-core + + + com.easyagents + easy-agents-scheduler-quartz + + + com.easyagents + easy-agents-scheduler-spring-boot-starter + diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/pom.xml b/easy-agents-scheduler/easy-agents-scheduler-core/pom.xml new file mode 100644 index 0000000..0148848 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + + com.easyagents + easy-agents-scheduler + ${revision} + + + easy-agents-scheduler-core + easy-agents-scheduler-core + + + + junit + junit + test + + + diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ConcurrencyPolicy.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ConcurrencyPolicy.java new file mode 100644 index 0000000..d1b7b17 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ConcurrencyPolicy.java @@ -0,0 +1,13 @@ +package com.easyagents.scheduler; + +/** + * 同一调度任务的并发执行策略。 + */ +public enum ConcurrencyPolicy { + + /** 允许同一任务的不同触发同时执行。 */ + ALLOW, + + /** 同一任务任意时刻最多执行一个触发。 */ + DISALLOW +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/CronSchedulePlan.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/CronSchedulePlan.java new file mode 100644 index 0000000..16d96dc --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/CronSchedulePlan.java @@ -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 + ); + } + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/MisfirePolicy.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/MisfirePolicy.java new file mode 100644 index 0000000..68409b8 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/MisfirePolicy.java @@ -0,0 +1,13 @@ +package com.easyagents.scheduler; + +/** + * 调度器错过计划时间后的处理策略。 + */ +public enum MisfirePolicy { + + /** 跳过已经错过的触发。 */ + SKIP, + + /** 恢复后立即补触发一次。 */ + FIRE_ONCE_NOW +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/OnceSchedulePlan.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/OnceSchedulePlan.java new file mode 100644 index 0000000..bd7374b --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/OnceSchedulePlan.java @@ -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()); + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleDefinition.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleDefinition.java new file mode 100644 index 0000000..d940e17 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleDefinition.java @@ -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 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 copyAndValidateParameters(Map parameters) { + if (parameters == null || parameters.isEmpty()) { + return Map.of(); + } + int totalBytes = 0; + for (Map.Entry 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; + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleErrorCode.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleErrorCode.java new file mode 100644 index 0000000..1c88d7e --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleErrorCode.java @@ -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 +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleException.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleException.java new file mode 100644 index 0000000..553c5f6 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleException.java @@ -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; + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleExecutionListener.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleExecutionListener.java new file mode 100644 index 0000000..195884b --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleExecutionListener.java @@ -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) { + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleFireContext.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleFireContext.java new file mode 100644 index 0000000..6b637dc --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleFireContext.java @@ -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 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); + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleFireReceipt.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleFireReceipt.java new file mode 100644 index 0000000..02e60f3 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleFireReceipt.java @@ -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(); + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleHandler.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleHandler.java new file mode 100644 index 0000000..27cfb55 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleHandler.java @@ -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; +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleId.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleId.java new file mode 100644 index 0000000..d76a25b --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleId.java @@ -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; + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/SchedulePlan.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/SchedulePlan.java new file mode 100644 index 0000000..f562de3 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/SchedulePlan.java @@ -0,0 +1,7 @@ +package com.easyagents.scheduler; + +/** + * 调度计划的封闭类型,首期支持 Cron 和一次性计划。 + */ +public sealed interface SchedulePlan permits CronSchedulePlan, OnceSchedulePlan { +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleService.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleService.java new file mode 100644 index 0000000..d0d2123 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleService.java @@ -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 parameters + ); + + /** + * 查询任务当前定义和状态。 + * + * @param scheduleId 任务标识 + * @return 任务不存在时为空 + * @throws ScheduleException Provider 失败时抛出 + */ + Optional get(ScheduleId scheduleId); + + /** + * 从当前时间开始预览后续触发时间,不创建持久任务。 + * + * @param plan 调度计划 + * @param limit 最大返回数量 + * @return 按时间升序排列的触发时间 + * @throws ScheduleException 计划不合法时抛出 + */ + List nextFireTimes(SchedulePlan plan, int limit); +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleStatus.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleStatus.java new file mode 100644 index 0000000..c50c2aa --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleStatus.java @@ -0,0 +1,22 @@ +package com.easyagents.scheduler; + +/** + * 调度任务的实现无关状态。 + */ +public enum ScheduleStatus { + + /** 等待正常触发。 */ + SCHEDULED, + + /** 已暂停。 */ + PAUSED, + + /** 当前触发被并发策略阻塞。 */ + BLOCKED, + + /** 已完成且不会再次触发。 */ + COMPLETE, + + /** 调度存储将任务标记为错误。 */ + ERROR +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleView.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleView.java new file mode 100644 index 0000000..3fec784 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/main/java/com/easyagents/scheduler/ScheduleView.java @@ -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"); + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-core/src/test/java/com/easyagents/scheduler/ScheduleDefinitionTest.java b/easy-agents-scheduler/easy-agents-scheduler-core/src/test/java/com/easyagents/scheduler/ScheduleDefinitionTest.java new file mode 100644 index 0000000..9aacaee --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-core/src/test/java/com/easyagents/scheduler/ScheduleDefinitionTest.java @@ -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 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 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 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" + ); + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/SCHEMA.md b/easy-agents-scheduler/easy-agents-scheduler-quartz/SCHEMA.md new file mode 100644 index 0000000..6b633fd --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/SCHEMA.md @@ -0,0 +1,43 @@ +# Quartz 2.5.2 数据库脚本 + +`easy-agents-scheduler-quartz` 不会自动创建、删除或修改数据库结构。引用方需要选择与数据库匹配的脚本,并将其内容纳入自己的 Flyway、Liquibase 或初始化流程。 + +## 脚本与 Delegate + +| 数据库 | 类路径资源 | `driverDelegateClass` | +|---|---|---| +| MySQL / MariaDB | `quartz-schema/mysql-2.5.2.sql` | `org.quartz.impl.jdbcjobstore.StdJDBCDelegate` | +| PostgreSQL | `quartz-schema/postgresql-2.5.2.sql` | `org.quartz.impl.jdbcjobstore.PostgreSQLDelegate` | +| H2 2.x(开发与测试) | `quartz-schema/h2-2.5.2.sql` | `org.quartz.impl.jdbcjobstore.StdJDBCDelegate` | + +默认表前缀是 `QRTZ_`。若调用方调整表名或 Schema,需要同步设置 `tablePrefix`,例如 `scheduler.QRTZ_`。 + +## 来源与变更 + +脚本来自 Quartz 官方仓库 `v2.5.2` 标签: + +- [MySQL InnoDB 原始脚本](https://github.com/quartz-scheduler/quartz/blob/v2.5.2/quartz/src/main/resources/org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql) +- [PostgreSQL 原始脚本](https://github.com/quartz-scheduler/quartz/blob/v2.5.2/quartz/src/main/resources/org/quartz/impl/jdbcjobstore/tables_postgres.sql) +- [H2 原始脚本](https://github.com/quartz-scheduler/quartz/blob/v2.5.2/quartz/src/main/resources/org/quartz/impl/jdbcjobstore/tables_h2.sql) + +为避免初始化脚本误删调用方已有任务,本模块从 MySQL 和 PostgreSQL 脚本中移除了全部 `DROP TABLE`。H2 版本另将旧式 `IMAGE` 类型替换为 H2 2.x 支持的 `BLOB`,并移除了已经失效的 `MVCC=TRUE` 提示。三套脚本同时移除了显式事务提交和行尾空白,使事务边界由调用方的迁移工具管理,其余建表结构沿用官方脚本。 + +## SHA-256 + +| 文件 | 官方原始文件 | 本模块资源 | +|---|---|---| +| MySQL | `90453cda26f4aad8abd35195a38ec36306561f0265f766dd12fbb42e185d5f82` | `1bfadd7762251836d6e15360e85f3c996a5c942a997ff0e9df30b59c50b0e97d` | +| PostgreSQL | `60d22e05e00203702f18f835916c75270aeb6828e7051bb81dd87ebd7bf6a943` | `911d058d415dbb9b29b344997c952e29d2e0b510ce4fe0b69f38adc5d5afffdb` | +| H2 | `21d806abef330e7c7175491b55b730acffa3c2e17a910840b32eee3fe2bf9fed` | `7750ac7b6c29d91072a5f8f98e4489d79469120effa9f613a7d9f89613beba5b` | + +脚本随 Quartz 版本固化。后续升级 Quartz 时应新增版本化资源并完成数据库升级验证,不回改已经发布的脚本。 + +## 引用方接入要求 + +1. 在目标数据库中执行所选脚本,或复制到引用方自己的迁移文件中。 +2. 保持同一集群节点的 `schedulerName`、`tablePrefix` 和数据库一致。 +3. 每个节点使用唯一 `instanceId`,常规部署可配置为 `AUTO`。 +4. 生产集群启用 `clustered=true`,业务 Handler 按至少一次执行语义实现幂等。 +5. 保留启动结构校验;表或关键列缺失时底座会拒绝启动并报告 `SCHEMA_INVALID`。 + +本模块只包含 Quartz 的 `QRTZ_*` 技术表。任务定义、执行流水、审计和管理界面等业务表由引用方按自身需求设计。 diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/pom.xml b/easy-agents-scheduler/easy-agents-scheduler-quartz/pom.xml new file mode 100644 index 0000000..b7be187 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + + com.easyagents + easy-agents-scheduler + ${revision} + + + easy-agents-scheduler-quartz + easy-agents-scheduler-quartz + + + + com.easyagents + easy-agents-scheduler-core + + + org.quartz-scheduler + quartz + + + org.slf4j + slf4j-api + + + com.h2database + h2 + test + + + junit + junit + test + + + diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/AbstractDispatchJob.java b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/AbstractDispatchJob.java new file mode 100644 index 0000000..2f73903 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/AbstractDispatchJob.java @@ -0,0 +1,49 @@ +package com.easyagents.scheduler.quartz; + +import org.quartz.InterruptableJob; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.quartz.SchedulerException; + +/** + * 将 Quartz Job 委派给当前节点 {@link QuartzRuntime} 的内部基类。 + */ +abstract class AbstractDispatchJob implements InterruptableJob { + + private volatile Thread executionThread; + + /** + * 从 SchedulerContext 定位运行时并分发任务。 + * + * @param context Quartz 执行上下文 + * @throws JobExecutionException 运行时缺失或 Handler 执行失败时抛出 + */ + @Override + public final void execute(JobExecutionContext context) throws JobExecutionException { + executionThread = Thread.currentThread(); + try { + Object runtime = context.getScheduler().getContext().get( + QuartzRuntime.SCHEDULER_CONTEXT_KEY + ); + if (!(runtime instanceof QuartzRuntime quartzRuntime)) { + throw new JobExecutionException("easy-agents scheduler runtime is not available"); + } + quartzRuntime.execute(context); + } catch (SchedulerException exception) { + throw new JobExecutionException("failed to access scheduler runtime", exception, false); + } finally { + executionThread = null; + } + } + + /** + * 向当前 Handler 执行线程发送协作式中断信号。 + */ + @Override + public final void interrupt() { + Thread currentExecutionThread = executionThread; + if (currentExecutionThread != null) { + currentExecutionThread.interrupt(); + } + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/ConcurrentDispatchJob.java b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/ConcurrentDispatchJob.java new file mode 100644 index 0000000..57bcd7e --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/ConcurrentDispatchJob.java @@ -0,0 +1,13 @@ +package com.easyagents.scheduler.quartz; + +/** + * 允许同一任务并发执行的内部 Quartz Job。 + */ +public final class ConcurrentDispatchJob extends AbstractDispatchJob { + + /** + * Quartz 反射创建 Job 所需的公共无参构造器。 + */ + public ConcurrentDispatchJob() { + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/DataSourceConnectionProvider.java b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/DataSourceConnectionProvider.java new file mode 100644 index 0000000..035d6c6 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/DataSourceConnectionProvider.java @@ -0,0 +1,57 @@ +package com.easyagents.scheduler.quartz; + +import org.quartz.utils.ConnectionProvider; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Objects; + +/** + * 将调用方管理的 {@link DataSource} 暴露给 Quartz,且不接管其生命周期。 + */ +final class DataSourceConnectionProvider implements ConnectionProvider { + + private volatile DataSource dataSource; + + /** + * 创建外部 DataSource 连接提供器。 + * + * @param dataSource 调用方管理的 DataSource + */ + DataSourceConnectionProvider(DataSource dataSource) { + this.dataSource = Objects.requireNonNull(dataSource, "dataSource must not be null"); + } + + /** + * 从调用方 DataSource 获取连接。 + * + * @return JDBC 连接 + * @throws SQLException 获取连接失败时抛出 + */ + @Override + public Connection getConnection() throws SQLException { + DataSource currentDataSource = dataSource; + if (currentDataSource == null) { + throw new SQLException("Quartz DataSource provider is already shut down"); + } + return currentDataSource.getConnection(); + } + + /** + * 外部 DataSource 不需要由 Quartz 初始化。 + */ + @Override + public void initialize() { + // DataSource 的创建和初始化由调用方负责。 + } + + /** + * 外部 DataSource 不由 Quartz 关闭。 + */ + @Override + public void shutdown() { + // Quartz 的全局注册表没有公开移除方法;解除引用以免长期持有调用方连接池。 + dataSource = null; + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/DisallowConcurrentDispatchJob.java b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/DisallowConcurrentDispatchJob.java new file mode 100644 index 0000000..cab8a3f --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/DisallowConcurrentDispatchJob.java @@ -0,0 +1,16 @@ +package com.easyagents.scheduler.quartz; + +import org.quartz.DisallowConcurrentExecution; + +/** + * 禁止同一 JobKey 并发执行的内部 Quartz Job。 + */ +@DisallowConcurrentExecution +public final class DisallowConcurrentDispatchJob extends AbstractDispatchJob { + + /** + * Quartz 反射创建 Job 所需的公共无参构造器。 + */ + public DisallowConcurrentDispatchJob() { + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzMisfireListener.java b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzMisfireListener.java new file mode 100644 index 0000000..67d9cc0 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzMisfireListener.java @@ -0,0 +1,57 @@ +package com.easyagents.scheduler.quartz; + +import com.easyagents.scheduler.ScheduleException; +import com.easyagents.scheduler.ScheduleId; +import org.quartz.Trigger; +import org.quartz.listeners.TriggerListenerSupport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; + +/** + * 将 Quartz Misfire 投影到通用执行监听器。 + */ +final class QuartzMisfireListener extends TriggerListenerSupport { + + private static final Logger log = LoggerFactory.getLogger(QuartzMisfireListener.class); + + private final QuartzRuntime runtime; + + /** + * 创建 Misfire 监听器。 + * + * @param runtime 当前节点运行时 + */ + QuartzMisfireListener(QuartzRuntime runtime) { + this.runtime = runtime; + } + + /** + * 返回 Quartz Listener 名称。 + * + * @return 稳定 Listener 名称 + */ + @Override + public String getName() { + return "easyAgentsScheduleMisfireListener"; + } + + /** + * 转发 Misfire 事件,持久数据损坏时记录明确错误。 + * + * @param trigger 发生 Misfire 的 Trigger + */ + @Override + public void triggerMisfired(Trigger trigger) { + try { + ScheduleId scheduleId = QuartzScheduleMapper.toScheduleId(trigger.getJobDataMap()); + Instant expected = trigger.getNextFireTime() == null + ? null + : trigger.getNextFireTime().toInstant(); + runtime.notifyMisfired(scheduleId, expected); + } catch (ScheduleException | IllegalArgumentException exception) { + log.error("Failed to decode misfired Quartz trigger {}", trigger.getKey(), exception); + } + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzRuntime.java b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzRuntime.java new file mode 100644 index 0000000..2d5b30d --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzRuntime.java @@ -0,0 +1,178 @@ +package com.easyagents.scheduler.quartz; + +import com.easyagents.scheduler.ScheduleDefinition; +import com.easyagents.scheduler.ScheduleErrorCode; +import com.easyagents.scheduler.ScheduleException; +import com.easyagents.scheduler.ScheduleExecutionListener; +import com.easyagents.scheduler.ScheduleFireContext; +import com.easyagents.scheduler.ScheduleHandler; +import com.easyagents.scheduler.ScheduleId; +import org.quartz.JobDataMap; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 当前 Scheduler 节点的 Handler 和监听器运行时。 + */ +final class QuartzRuntime { + + static final String SCHEDULER_CONTEXT_KEY = "easyAgentsSchedulerRuntime"; + + private static final Logger log = LoggerFactory.getLogger(QuartzRuntime.class); + + private final Map handlers; + private final List listeners; + + /** + * 创建节点本地运行时。 + * + * @param handlers 当前节点 Handler + * @param listeners 当前节点执行监听器 + */ + QuartzRuntime( + Collection handlers, + Collection listeners + ) { + this.handlers = registerHandlers(handlers); + this.listeners = listeners == null ? List.of() : List.copyOf(listeners); + } + + /** + * 执行 Quartz 触发并保留 Handler 原始失败原因。 + * + * @param quartzContext Quartz 执行上下文 + * @throws JobExecutionException Handler 或运行时执行失败时抛出 + */ + void execute(JobExecutionContext quartzContext) throws JobExecutionException { + JobDataMap data = quartzContext.getMergedJobDataMap(); + ScheduleDefinition definition = QuartzScheduleMapper.toDefinition(data); + Instant actualFireTime = toInstant(quartzContext.getFireTime(), Instant.now()); + ScheduleFireContext context = new ScheduleFireContext( + definition.id(), + definition.handlerCode(), + toInstant(quartzContext.getScheduledFireTime(), actualFireTime), + actualFireTime, + quartzContext.getFireInstanceId(), + stringValue(data, QuartzScheduleMapper.KEY_INVOCATION), + quartzContext.isRecovering(), + definition.parameters() + ); + ScheduleHandler handler = handlers.get(definition.handlerCode()); + if (handler == null) { + ScheduleException failure = new ScheduleException( + ScheduleErrorCode.HANDLER_NOT_FOUND, + "schedule handler not found: " + definition.handlerCode() + ); + notifyFailed(context, Duration.ZERO, failure); + throw new JobExecutionException(failure, false); + } + notifyStarted(context); + long startedAt = System.nanoTime(); + try { + handler.execute(context); + notifySucceeded(context, elapsed(startedAt)); + } catch (Exception exception) { + notifyFailed(context, elapsed(startedAt), exception); + throw new JobExecutionException(exception, false); + } + } + + /** + * 向监听器发布 Misfire 事件。 + * + * @param scheduleId 任务标识 + * @param expectedFireTime 原计划时间,可为空 + */ + void notifyMisfired(ScheduleId scheduleId, Instant expectedFireTime) { + for (ScheduleExecutionListener listener : listeners) { + try { + listener.onMisfired(scheduleId, expectedFireTime); + } catch (RuntimeException exception) { + log.warn("Schedule listener failed on misfire: {}", scheduleId, exception); + } + } + } + + private void notifyStarted(ScheduleFireContext context) { + for (ScheduleExecutionListener listener : listeners) { + try { + listener.onStarted(context); + } catch (RuntimeException exception) { + log.warn("Schedule listener failed on start: {}", context.scheduleId(), exception); + } + } + } + + private void notifySucceeded(ScheduleFireContext context, Duration duration) { + for (ScheduleExecutionListener listener : listeners) { + try { + listener.onSucceeded(context, duration); + } catch (RuntimeException exception) { + log.warn("Schedule listener failed on success: {}", context.scheduleId(), exception); + } + } + } + + private void notifyFailed( + ScheduleFireContext context, + Duration duration, + Throwable failure + ) { + for (ScheduleExecutionListener listener : listeners) { + try { + listener.onFailed(context, duration, failure); + } catch (RuntimeException exception) { + log.warn("Schedule listener failed on failure: {}", context.scheduleId(), exception); + } + } + } + + private static Map registerHandlers( + Collection handlers + ) { + Map registered = new LinkedHashMap<>(); + if (handlers == null) { + return Map.of(); + } + for (ScheduleHandler handler : handlers) { + String rawCode = handler == null ? null : handler.code(); + if (rawCode == null || rawCode.isBlank()) { + throw new ScheduleException( + ScheduleErrorCode.INVALID_DEFINITION, + "schedule handler and handler code must not be blank" + ); + } + String code = rawCode.trim(); + ScheduleHandler previous = registered.putIfAbsent(code, handler); + if (previous != null) { + throw new ScheduleException( + ScheduleErrorCode.DUPLICATE_HANDLER, + "duplicate schedule handler code: " + code + ); + } + } + return Map.copyOf(registered); + } + + private static Duration elapsed(long startedAt) { + return Duration.ofNanos(Math.max(0L, System.nanoTime() - startedAt)); + } + + private static Instant toInstant(java.util.Date value, Instant fallback) { + return value == null ? fallback : value.toInstant(); + } + + private static String stringValue(JobDataMap data, String key) { + Object value = data.get(key); + return value == null ? null : value.toString(); + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzScheduleMapper.java b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzScheduleMapper.java new file mode 100644 index 0000000..9a59b71 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzScheduleMapper.java @@ -0,0 +1,378 @@ +package com.easyagents.scheduler.quartz; + +import com.easyagents.scheduler.ConcurrencyPolicy; +import com.easyagents.scheduler.CronSchedulePlan; +import com.easyagents.scheduler.MisfirePolicy; +import com.easyagents.scheduler.OnceSchedulePlan; +import com.easyagents.scheduler.ScheduleDefinition; +import com.easyagents.scheduler.ScheduleErrorCode; +import com.easyagents.scheduler.ScheduleException; +import com.easyagents.scheduler.ScheduleId; +import com.easyagents.scheduler.SchedulePlan; +import org.quartz.CronScheduleBuilder; +import org.quartz.JobBuilder; +import org.quartz.JobDataMap; +import org.quartz.JobDetail; +import org.quartz.JobKey; +import org.quartz.SimpleScheduleBuilder; +import org.quartz.Trigger; +import org.quartz.TriggerBuilder; +import org.quartz.TriggerKey; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.TimeZone; + +/** + * Core 调度模型与 Quartz 持久模型之间的单一映射器。 + */ +final class QuartzScheduleMapper { + + static final String SCHEMA_VERSION = "1"; + static final String KEY_SCHEMA_VERSION = "ea.schemaVersion"; + static final String KEY_NAMESPACE = "ea.namespace"; + static final String KEY_NAME = "ea.name"; + static final String KEY_HANDLER = "ea.handler"; + static final String KEY_PLAN_TYPE = "ea.planType"; + static final String KEY_CRON = "ea.cron"; + static final String KEY_ZONE = "ea.zone"; + static final String KEY_FIRE_AT = "ea.fireAt"; + static final String KEY_MISFIRE = "ea.misfire"; + static final String KEY_CONCURRENCY = "ea.concurrency"; + static final String KEY_RECOVER = "ea.recover"; + static final String KEY_DESCRIPTION = "ea.description"; + static final String KEY_INVOCATION = "ea.invocationId"; + static final String KEY_IMMEDIATE_PARAMETER_SNAPSHOT = "ea.immediateParameterSnapshot"; + static final String PARAMETER_PREFIX = "ea.parameter."; + static final String IMMEDIATE_PARAMETER_PREFIX = "ea.immediateParameter."; + static final String GROUP_PREFIX = "ea.scheduler."; + + /** 禁止实例化映射器。 */ + private QuartzScheduleMapper() { + } + + /** + * 映射 Quartz Job Key。 + * + * @param scheduleId 调度标识 + * @return Job Key + */ + static JobKey jobKey(ScheduleId scheduleId) { + return new JobKey(scheduleId.name(), GROUP_PREFIX + scheduleId.namespace()); + } + + /** + * 映射 Quartz Trigger Key。 + * + * @param scheduleId 调度标识 + * @return Trigger Key + */ + static TriggerKey triggerKey(ScheduleId scheduleId) { + return new TriggerKey(scheduleId.name(), GROUP_PREFIX + scheduleId.namespace()); + } + + /** + * 将公共定义映射为持久 Job。 + * + * @param definition 调度定义 + * @return Job 详情 + */ + static JobDetail toJobDetail(ScheduleDefinition definition) { + Class jobClass = + definition.concurrencyPolicy() == ConcurrencyPolicy.DISALLOW + ? DisallowConcurrentDispatchJob.class + : ConcurrentDispatchJob.class; + return JobBuilder.newJob(jobClass) + .withIdentity(jobKey(definition.id())) + .withDescription(emptyToNull(definition.description())) + .storeDurably(true) + .requestRecovery(definition.recoverOnNodeFailure()) + .usingJobData(toJobData(definition)) + .build(); + } + + /** + * 将公共定义映射为主 Trigger。 + * + * @param definition 调度定义 + * @return Quartz Trigger + */ + static Trigger toTrigger(ScheduleDefinition definition) { + TriggerBuilder builder = TriggerBuilder.newTrigger() + .withIdentity(triggerKey(definition.id())) + .forJob(jobKey(definition.id())) + .usingJobData(identityData(definition.id())); + SchedulePlan plan = definition.plan(); + if (plan instanceof CronSchedulePlan cronPlan) { + CronScheduleBuilder schedule = CronScheduleBuilder + .cronSchedule(cronPlan.expression()) + .inTimeZone(TimeZone.getTimeZone(cronPlan.zoneId())); + schedule = definition.misfirePolicy() == MisfirePolicy.SKIP + ? schedule.withMisfireHandlingInstructionDoNothing() + : schedule.withMisfireHandlingInstructionFireAndProceed(); + return builder.withSchedule(schedule).build(); + } + if (plan instanceof OnceSchedulePlan oncePlan) { + SimpleScheduleBuilder schedule = SimpleScheduleBuilder.simpleSchedule() + .withRepeatCount(0); + schedule = definition.misfirePolicy() == MisfirePolicy.SKIP + ? schedule.withMisfireHandlingInstructionNextWithExistingCount() + : schedule.withMisfireHandlingInstructionFireNow(); + return builder + .startAt(oncePlan.fireAt()) + .withSchedule(schedule) + .build(); + } + throw new ScheduleException( + ScheduleErrorCode.INVALID_DEFINITION, + "unsupported schedule plan: " + plan.getClass().getName() + ); + } + + /** + * 构造仅对本次立即触发生效的数据。 + * + * @param scheduleId 调度标识 + * @param invocationId 调用标识 + * @param parameters 已合并并校验的参数快照 + * @return Trigger 数据 + */ + static JobDataMap immediateData( + ScheduleId scheduleId, + String invocationId, + Map parameters + ) { + JobDataMap data = identityData(scheduleId); + data.put(KEY_INVOCATION, invocationId); + data.put(KEY_IMMEDIATE_PARAMETER_SNAPSHOT, Boolean.TRUE.toString()); + putParameters(data, parameters, IMMEDIATE_PARAMETER_PREFIX); + return data; + } + + /** + * 从持久 JobData 恢复公共定义。 + * + * @param data 持久数据 + * @return 调度定义 + */ + static ScheduleDefinition toDefinition(JobDataMap data) { + try { + String version = required(data, KEY_SCHEMA_VERSION); + if (!SCHEMA_VERSION.equals(version)) { + throw new ScheduleException( + ScheduleErrorCode.PROVIDER_FAILURE, + "unsupported persisted schedule schema version: " + version + ); + } + ScheduleId scheduleId = toScheduleId(data); + SchedulePlan plan = switch (required(data, KEY_PLAN_TYPE)) { + case "CRON" -> new CronSchedulePlan( + required(data, KEY_CRON), + ZoneId.of(required(data, KEY_ZONE)) + ); + case "ONCE" -> new OnceSchedulePlan( + Instant.ofEpochMilli(Long.parseLong(required(data, KEY_FIRE_AT))) + ); + default -> throw new IllegalArgumentException("unknown plan type"); + }; + return new ScheduleDefinition( + scheduleId, + required(data, KEY_HANDLER), + plan, + MisfirePolicy.valueOf(required(data, KEY_MISFIRE)), + ConcurrencyPolicy.valueOf(required(data, KEY_CONCURRENCY)), + strictBoolean(data, KEY_RECOVER), + parameters(data), + stringValue(data, KEY_DESCRIPTION) + ); + } catch (ScheduleException exception) { + if (exception.errorCode() == ScheduleErrorCode.PROVIDER_FAILURE) { + throw exception; + } + throw new ScheduleException( + ScheduleErrorCode.PROVIDER_FAILURE, + "invalid persisted schedule data", + exception + ); + } catch (RuntimeException exception) { + throw new ScheduleException( + ScheduleErrorCode.PROVIDER_FAILURE, + "invalid persisted schedule data", + exception + ); + } + } + + /** + * 从 JobData 恢复调度标识。 + * + * @param data 持久数据 + * @return 调度标识 + */ + static ScheduleId toScheduleId(JobDataMap data) { + return new ScheduleId(required(data, KEY_NAMESPACE), required(data, KEY_NAME)); + } + + /** + * 解码 JobData 中的业务参数。 + * + * @param data 合并后的 JobData + * @return 不可变参数 + */ + static Map parameters(JobDataMap data) { + String parameterPrefix = immediateParameterSnapshot(data) + ? IMMEDIATE_PARAMETER_PREFIX + : PARAMETER_PREFIX; + Map parameters = new HashMap<>(); + for (String key : data.getKeys()) { + if (!key.startsWith(parameterPrefix)) { + continue; + } + String encodedKey = key.substring(parameterPrefix.length()); + String parameterKey = new String( + Base64.getUrlDecoder().decode(encodedKey), + StandardCharsets.UTF_8 + ); + parameters.put(parameterKey, stringValue(data, key)); + } + return ScheduleDefinition.copyAndValidateParameters(parameters); + } + + /** + * 将公共定义编码为纯字符串 JobData。 + * + * @param definition 调度定义 + * @return 持久数据 + */ + private static JobDataMap toJobData(ScheduleDefinition definition) { + JobDataMap data = identityData(definition.id()); + data.put(KEY_SCHEMA_VERSION, SCHEMA_VERSION); + data.put(KEY_HANDLER, definition.handlerCode()); + data.put(KEY_MISFIRE, definition.misfirePolicy().name()); + data.put(KEY_CONCURRENCY, definition.concurrencyPolicy().name()); + data.put(KEY_RECOVER, Boolean.toString(definition.recoverOnNodeFailure())); + data.put(KEY_DESCRIPTION, definition.description()); + if (definition.plan() instanceof CronSchedulePlan cronPlan) { + data.put(KEY_PLAN_TYPE, "CRON"); + data.put(KEY_CRON, cronPlan.expression()); + data.put(KEY_ZONE, cronPlan.zoneId().getId()); + } else if (definition.plan() instanceof OnceSchedulePlan oncePlan) { + data.put(KEY_PLAN_TYPE, "ONCE"); + data.put(KEY_FIRE_AT, Long.toString(oncePlan.fireAt().toEpochMilli())); + } + putParameters(data, definition.parameters(), PARAMETER_PREFIX); + return data; + } + + /** + * 构造调度标识数据。 + * + * @param scheduleId 调度标识 + * @return 标识数据 + */ + private static JobDataMap identityData(ScheduleId scheduleId) { + JobDataMap data = new JobDataMap(); + data.put(KEY_NAMESPACE, scheduleId.namespace()); + data.put(KEY_NAME, scheduleId.name()); + return data; + } + + /** + * 将参数编码到 JobData。 + * + * @param data 目标 JobData + * @param parameters 参数 + * @param parameterPrefix 参数键前缀 + */ + private static void putParameters( + JobDataMap data, + Map parameters, + String parameterPrefix + ) { + for (Map.Entry entry : parameters.entrySet()) { + String encodedKey = Base64.getUrlEncoder().withoutPadding().encodeToString( + entry.getKey().getBytes(StandardCharsets.UTF_8) + ); + data.put(parameterPrefix + encodedKey, entry.getValue()); + } + } + + /** + * 判断当前数据是否包含立即触发参数快照。 + * + * @param data 合并后的 JobData + * @return 存在完整快照时为 true + */ + private static boolean immediateParameterSnapshot(JobDataMap data) { + Object marker = data.get(KEY_IMMEDIATE_PARAMETER_SNAPSHOT); + if (marker == null) { + return false; + } + if (Boolean.TRUE.toString().equals(marker.toString())) { + return true; + } + throw new ScheduleException( + ScheduleErrorCode.PROVIDER_FAILURE, + "invalid immediate parameter snapshot marker" + ); + } + + /** + * 读取必填字符串字段。 + * + * @param data JobData + * @param key 字段键 + * @return 非空字段值 + */ + private static String required(JobDataMap data, String key) { + String value = stringValue(data, key); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("missing job data: " + key); + } + return value; + } + + /** + * 将 JobData 字段读取为字符串。 + * + * @param data JobData + * @param key 字段键 + * @return 字符串值,可为空 + */ + private static String stringValue(JobDataMap data, String key) { + Object value = data.get(key); + return value == null ? null : value.toString(); + } + + /** + * 严格读取布尔字符串,避免损坏值静默降级为 false。 + * + * @param data JobData + * @param key 字段键 + * @return 布尔值 + */ + private static boolean strictBoolean(JobDataMap data, String key) { + String value = required(data, key); + if ("true".equals(value)) { + return true; + } + if ("false".equals(value)) { + return false; + } + throw new IllegalArgumentException("invalid boolean job data: " + key); + } + + /** + * 将空字符串转换为空值。 + * + * @param value 原始值 + * @return 非空文本或 null + */ + private static String emptyToNull(String value) { + return value == null || value.isEmpty() ? null : value; + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzScheduleService.java b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzScheduleService.java new file mode 100644 index 0000000..a47f42c --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzScheduleService.java @@ -0,0 +1,571 @@ +package com.easyagents.scheduler.quartz; + +import com.easyagents.scheduler.CronSchedulePlan; +import com.easyagents.scheduler.OnceSchedulePlan; +import com.easyagents.scheduler.ScheduleDefinition; +import com.easyagents.scheduler.ScheduleErrorCode; +import com.easyagents.scheduler.ScheduleException; +import com.easyagents.scheduler.ScheduleFireReceipt; +import com.easyagents.scheduler.ScheduleId; +import com.easyagents.scheduler.SchedulePlan; +import com.easyagents.scheduler.ScheduleService; +import com.easyagents.scheduler.ScheduleStatus; +import com.easyagents.scheduler.ScheduleView; +import org.quartz.CronExpression; +import org.quartz.JobDataMap; +import org.quartz.JobDetail; +import org.quartz.ObjectAlreadyExistsException; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.Trigger; +import org.quartz.Trigger.TriggerState; +import org.quartz.TriggerKey; + +import java.text.ParseException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 基于单个 Quartz Scheduler 的调度服务实现。 + */ +public final class QuartzScheduleService implements ScheduleService, AutoCloseable { + + /** 立即触发调用标识的最大长度。 */ + public static final int MAX_INVOCATION_ID_LENGTH = 190; + + /** 单次触发时间预览的最大数量。 */ + public static final int MAX_PREVIEW_LIMIT = 100; + + private final Scheduler scheduler; + private final boolean waitForJobsToCompleteOnShutdown; + private final long shutdownWaitTimeoutMillis; + private final AtomicBoolean closed = new AtomicBoolean(); + + /** + * 创建 Quartz 调度服务。 + * + * @param scheduler 已完成运行时装配、尚未启动的 Scheduler + * @param waitForJobsToCompleteOnShutdown 关闭时是否等待在途任务 + * @param shutdownWaitTimeoutMillis 关闭等待最长毫秒数 + */ + QuartzScheduleService( + Scheduler scheduler, + boolean waitForJobsToCompleteOnShutdown, + long shutdownWaitTimeoutMillis + ) { + this.scheduler = Objects.requireNonNull(scheduler, "scheduler must not be null"); + this.waitForJobsToCompleteOnShutdown = waitForJobsToCompleteOnShutdown; + if (shutdownWaitTimeoutMillis < 1) { + throw new IllegalArgumentException("shutdownWaitTimeoutMillis must be positive"); + } + this.shutdownWaitTimeoutMillis = shutdownWaitTimeoutMillis; + } + + /** + * 启动底层 Quartz Scheduler。 + * + * @throws ScheduleException Scheduler 启动失败或已经关闭时抛出 + */ + public void start() { + ensureOpen(); + try { + if (!scheduler.isStarted()) { + scheduler.start(); + } + } catch (SchedulerException exception) { + ScheduleException failure = providerFailure( + "failed to start Quartz scheduler", + exception + ); + closed.set(true); + try { + if (!scheduler.isShutdown()) { + scheduler.shutdown(false); + } + } catch (SchedulerException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } + } + + /** + * {@inheritDoc} + */ + @Override + public ScheduleView create(ScheduleDefinition definition) { + Objects.requireNonNull(definition, "definition must not be null"); + ensureOpen(); + JobDetail job = mapJob(definition); + Trigger trigger = mapTrigger(definition); + try { + scheduler.scheduleJob(job, trigger); + return requireView(definition.id()); + } catch (ObjectAlreadyExistsException exception) { + Optional existing = get(definition.id()); + if (existing.isPresent() && existing.get().definition().equals(definition)) { + return existing.get(); + } + throw new ScheduleException( + ScheduleErrorCode.SCHEDULE_CONFLICT, + "schedule already exists with a different definition: " + definition.id(), + exception + ); + } catch (SchedulerException exception) { + throw providerFailure("failed to create schedule " + definition.id(), exception); + } + } + + /** + * {@inheritDoc} + */ + @Override + public ScheduleView replace(ScheduleDefinition definition) { + Objects.requireNonNull(definition, "definition must not be null"); + ensureOpen(); + JobDetail job = mapJob(definition); + Trigger trigger = mapTrigger(definition); + try { + // Quartz 在一个 JobStore 事务中替换 Job 与其唯一 Trigger,避免中间态。 + scheduler.scheduleJob(job, Set.of(trigger), true); + return requireView(definition.id()); + } catch (SchedulerException exception) { + throw providerFailure("failed to replace schedule " + definition.id(), exception); + } + } + + /** + * {@inheritDoc} + */ + @Override + public ScheduleView pause(ScheduleId scheduleId) { + Objects.requireNonNull(scheduleId, "scheduleId must not be null"); + ensureOpen(); + try { + if (!scheduler.checkExists(QuartzScheduleMapper.jobKey(scheduleId))) { + throw notFound(scheduleId); + } + TriggerKey triggerKey = QuartzScheduleMapper.triggerKey(scheduleId); + if (scheduler.checkExists(triggerKey)) { + scheduler.pauseTrigger(triggerKey); + } + return requireView(scheduleId); + } catch (ScheduleException exception) { + throw exception; + } catch (SchedulerException exception) { + throw providerFailure("failed to pause schedule " + scheduleId, exception); + } + } + + /** + * {@inheritDoc} + */ + @Override + public ScheduleView resume(ScheduleId scheduleId) { + Objects.requireNonNull(scheduleId, "scheduleId must not be null"); + ensureOpen(); + try { + if (!scheduler.checkExists(QuartzScheduleMapper.jobKey(scheduleId))) { + throw notFound(scheduleId); + } + TriggerKey triggerKey = QuartzScheduleMapper.triggerKey(scheduleId); + if (scheduler.checkExists(triggerKey)) { + scheduler.resumeTrigger(triggerKey); + } + return requireView(scheduleId); + } catch (ScheduleException exception) { + throw exception; + } catch (SchedulerException exception) { + throw providerFailure("failed to resume schedule " + scheduleId, exception); + } + } + + /** + * {@inheritDoc} + */ + @Override + public boolean delete(ScheduleId scheduleId) { + Objects.requireNonNull(scheduleId, "scheduleId must not be null"); + ensureOpen(); + try { + // 批量接口对单个 Job 也直接委派 JobStore,避免逐 Trigger 删除时的完成竞态。 + return scheduler.deleteJobs(List.of(QuartzScheduleMapper.jobKey(scheduleId))); + } catch (SchedulerException exception) { + throw providerFailure("failed to delete schedule " + scheduleId, exception); + } + } + + /** + * {@inheritDoc} + */ + @Override + public ScheduleFireReceipt triggerNow( + ScheduleId scheduleId, + String invocationId, + Map parameters + ) { + Objects.requireNonNull(scheduleId, "scheduleId must not be null"); + String normalizedInvocationId = normalizeInvocationId(invocationId); + ensureOpen(); + try { + JobDetail job = scheduler.getJobDetail(QuartzScheduleMapper.jobKey(scheduleId)); + if (job == null) { + throw notFound(scheduleId); + } + Map mergedParameters = new HashMap<>( + QuartzScheduleMapper.toDefinition(job.getJobDataMap()).parameters() + ); + if (parameters != null) { + mergedParameters.putAll(parameters); + } + Map safeParameters = ScheduleDefinition + .copyAndValidateParameters(mergedParameters); + JobDataMap data = QuartzScheduleMapper.immediateData( + scheduleId, + normalizedInvocationId, + safeParameters + ); + scheduler.triggerJob(QuartzScheduleMapper.jobKey(scheduleId), data); + return new ScheduleFireReceipt(scheduleId, normalizedInvocationId, Instant.now()); + } catch (ScheduleException exception) { + throw exception; + } catch (SchedulerException exception) { + throw providerFailure("failed to trigger schedule " + scheduleId, exception); + } + } + + /** + * {@inheritDoc} + */ + @Override + public Optional get(ScheduleId scheduleId) { + Objects.requireNonNull(scheduleId, "scheduleId must not be null"); + ensureOpen(); + try { + JobDetail job = scheduler.getJobDetail(QuartzScheduleMapper.jobKey(scheduleId)); + if (job == null) { + return Optional.empty(); + } + ScheduleDefinition definition = QuartzScheduleMapper.toDefinition(job.getJobDataMap()); + TriggerKey triggerKey = QuartzScheduleMapper.triggerKey(scheduleId); + Trigger trigger = scheduler.getTrigger(triggerKey); + TriggerState triggerState = trigger == null + ? TriggerState.NONE + : scheduler.getTriggerState(triggerKey); + return Optional.of(new ScheduleView( + definition, + toStatus(triggerState, trigger == null), + toInstant(trigger == null ? null : trigger.getPreviousFireTime()), + toInstant(trigger == null ? null : trigger.getNextFireTime()) + )); + } catch (SchedulerException exception) { + throw providerFailure("failed to read schedule " + scheduleId, exception); + } + } + + /** + * {@inheritDoc} + */ + @Override + public List nextFireTimes(SchedulePlan plan, int limit) { + Objects.requireNonNull(plan, "plan must not be null"); + if (limit < 1 || limit > MAX_PREVIEW_LIMIT) { + throw new ScheduleException( + ScheduleErrorCode.INVALID_DEFINITION, + "limit must be between 1 and " + MAX_PREVIEW_LIMIT + ); + } + if (plan instanceof OnceSchedulePlan oncePlan) { + return oncePlan.fireAt().isAfter(Instant.now()) + ? List.of(oncePlan.fireAt()) + : List.of(); + } + if (plan instanceof CronSchedulePlan cronPlan) { + return nextCronFireTimes(cronPlan, limit); + } + throw new ScheduleException( + ScheduleErrorCode.INVALID_DEFINITION, + "unsupported schedule plan: " + plan.getClass().getName() + ); + } + + /** + * 关闭 Scheduler 并释放 Provider 对外部 DataSource 的强引用。 + * + *

外部 DataSource 的连接池所有权仍属于调用方,本方法不会关闭连接池。

+ * + * @throws ScheduleException Scheduler 关闭失败时抛出 + */ + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + ScheduleException failure = null; + try { + if (!scheduler.isShutdown()) { + scheduler.standby(); + if ( + waitForJobsToCompleteOnShutdown + && !awaitExecutingJobs(shutdownWaitTimeoutMillis) + ) { + failure = new ScheduleException( + ScheduleErrorCode.PROVIDER_FAILURE, + "timed out after " + shutdownWaitTimeoutMillis + + " ms waiting for Quartz jobs to complete" + ); + } + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + failure = providerFailure( + "interrupted while waiting for Quartz jobs to complete", + exception + ); + } catch (SchedulerException exception) { + failure = providerFailure("failed to prepare Quartz scheduler shutdown", exception); + } + try { + if (!scheduler.isShutdown()) { + // false 保证 close 本身有界;Quartz 会向内部 InterruptableJob 发送中断。 + scheduler.shutdown(false); + } + } catch (SchedulerException exception) { + if (failure == null) { + failure = providerFailure("failed to close Quartz scheduler", exception); + } else { + failure.addSuppressed(exception); + } + } + if (failure != null) { + throw failure; + } + } + + /** + * 返回底层 Scheduler,供 Provider 诊断和测试使用。 + * + * @return 当前 Quartz Scheduler + */ + Scheduler quartzScheduler() { + return scheduler; + } + + /** + * 在给定时限内等待当前节点任务完成。 + * + * @param timeoutMillis 最长等待毫秒数 + * @return 全部完成时为 true,超时时为 false + * @throws SchedulerException 读取执行状态失败时抛出 + * @throws InterruptedException 等待线程被中断时抛出 + */ + private boolean awaitExecutingJobs(long timeoutMillis) + throws SchedulerException, InterruptedException { + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.MILLISECONDS.toNanos( + timeoutMillis + ); + while (!scheduler.getCurrentlyExecutingJobs().isEmpty()) { + long remainingNanos = deadline - System.nanoTime(); + if (remainingNanos <= 0L) { + return false; + } + long sleepMillis = Math.max( + 1L, + Math.min( + 100L, + java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(remainingNanos) + ) + ); + Thread.sleep(sleepMillis); + } + return true; + } + + /** + * 查询必然存在的任务视图。 + * + * @param scheduleId 调度标识 + * @return 任务视图 + */ + private ScheduleView requireView(ScheduleId scheduleId) { + return get(scheduleId).orElseThrow(() -> notFound(scheduleId)); + } + + /** + * 映射 Job 并统一定义错误。 + * + * @param definition 调度定义 + * @return Job 详情 + */ + private static JobDetail mapJob(ScheduleDefinition definition) { + try { + return QuartzScheduleMapper.toJobDetail(definition); + } catch (ScheduleException exception) { + throw exception; + } catch (RuntimeException exception) { + throw invalidDefinition(definition.id(), exception); + } + } + + /** + * 映射 Trigger 并统一定义错误。 + * + * @param definition 调度定义 + * @return Quartz Trigger + */ + private static Trigger mapTrigger(ScheduleDefinition definition) { + try { + return QuartzScheduleMapper.toTrigger(definition); + } catch (ScheduleException exception) { + throw exception; + } catch (RuntimeException exception) { + throw invalidDefinition(definition.id(), exception); + } + } + + /** + * 计算 Cron 后续触发时间。 + * + * @param plan Cron 计划 + * @param limit 最大数量 + * @return 触发时间 + */ + private static List nextCronFireTimes(CronSchedulePlan plan, int limit) { + try { + CronExpression expression = new CronExpression(plan.expression()); + expression.setTimeZone(java.util.TimeZone.getTimeZone(plan.zoneId())); + List fireTimes = new ArrayList<>(limit); + Date cursor = Date.from(Instant.now()); + for (int index = 0; index < limit; index++) { + Date next = expression.getNextValidTimeAfter(cursor); + if (next == null) { + break; + } + fireTimes.add(next.toInstant()); + cursor = next; + } + return List.copyOf(fireTimes); + } catch (ParseException exception) { + throw new ScheduleException( + ScheduleErrorCode.INVALID_DEFINITION, + "invalid Quartz cron expression: " + plan.expression(), + exception + ); + } + } + + /** + * 映射 Quartz Trigger 状态。 + * + * @param state Quartz 状态 + * @param completed 是否已无主 Trigger + * @return 公共状态 + */ + private static ScheduleStatus toStatus(TriggerState state, boolean completed) { + if (completed) { + return ScheduleStatus.COMPLETE; + } + return switch (state) { + case NORMAL -> ScheduleStatus.SCHEDULED; + case PAUSED -> ScheduleStatus.PAUSED; + case BLOCKED -> ScheduleStatus.BLOCKED; + case ERROR -> ScheduleStatus.ERROR; + case COMPLETE, NONE -> ScheduleStatus.COMPLETE; + }; + } + + /** + * 将 JDBC/Quartz 时间转换为 Instant。 + * + * @param value 日期,可为空 + * @return Instant,可为空 + */ + private static Instant toInstant(Date value) { + return value == null ? null : value.toInstant(); + } + + /** + * 规范化立即触发调用标识。 + * + * @param invocationId 原始调用标识 + * @return 规范化标识 + */ + private static String normalizeInvocationId(String invocationId) { + if (invocationId == null || invocationId.isBlank()) { + throw new ScheduleException( + ScheduleErrorCode.INVALID_DEFINITION, + "invocationId must not be blank" + ); + } + String normalized = invocationId.trim(); + if (normalized.length() > MAX_INVOCATION_ID_LENGTH) { + throw new ScheduleException( + ScheduleErrorCode.INVALID_DEFINITION, + "invocationId length must not exceed " + MAX_INVOCATION_ID_LENGTH + ); + } + return normalized; + } + + /** + * 确认服务仍可接受操作。 + */ + private void ensureOpen() { + if (closed.get()) { + throw new ScheduleException( + ScheduleErrorCode.SCHEDULER_CLOSED, + "scheduler is already closed" + ); + } + } + + /** + * 构造任务不存在异常。 + * + * @param scheduleId 调度标识 + * @return 稳定异常 + */ + private static ScheduleException notFound(ScheduleId scheduleId) { + return new ScheduleException( + ScheduleErrorCode.SCHEDULE_NOT_FOUND, + "schedule not found: " + scheduleId + ); + } + + /** + * 构造定义非法异常。 + * + * @param scheduleId 调度标识 + * @param exception 原始原因 + * @return 稳定异常 + */ + private static ScheduleException invalidDefinition( + ScheduleId scheduleId, + RuntimeException exception + ) { + return new ScheduleException( + ScheduleErrorCode.INVALID_DEFINITION, + "invalid schedule definition: " + scheduleId, + exception + ); + } + + /** + * 构造 Provider 失败异常。 + * + * @param message 失败摘要 + * @param exception 原始原因 + * @return 稳定异常 + */ + private static ScheduleException providerFailure(String message, Exception exception) { + return new ScheduleException(ScheduleErrorCode.PROVIDER_FAILURE, message, exception); + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzSchedulerConfig.java b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzSchedulerConfig.java new file mode 100644 index 0000000..070f38f --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzSchedulerConfig.java @@ -0,0 +1,114 @@ +package com.easyagents.scheduler.quartz; + +/** + * 使用外部 DataSource 创建 Quartz JDBC 调度器的不可变配置。 + * + * @param schedulerName 集群内保持一致的 Scheduler Name + * @param instanceId 当前实例标识,通常使用 AUTO + * @param tablePrefix Quartz 表前缀,可包含 Schema 前缀 + * @param driverDelegateClass Quartz JDBC DriverDelegate 类名 + * @param clustered 是否启用 JDBC 集群 + * @param threadCount Quartz Worker 线程数 + * @param threadPriority Quartz Worker 线程优先级 + * @param clusterCheckinIntervalMillis 集群心跳间隔,单位毫秒 + * @param misfireThresholdMillis Misfire 判定阈值,单位毫秒 + * @param waitForJobsToCompleteOnShutdown 关闭时是否等待运行中任务完成 + * @param shutdownWaitTimeoutMillis 关闭等待运行中任务的最长时间,单位毫秒 + * @param validateSchema 启动前是否执行只读表结构校验 + */ +public record QuartzSchedulerConfig( + String schedulerName, + String instanceId, + String tablePrefix, + String driverDelegateClass, + boolean clustered, + int threadCount, + int threadPriority, + long clusterCheckinIntervalMillis, + long misfireThresholdMillis, + boolean waitForJobsToCompleteOnShutdown, + long shutdownWaitTimeoutMillis, + boolean validateSchema +) { + + /** Quartz 官方通用 JDBC Delegate。 */ + public static final String STANDARD_JDBC_DELEGATE = + "org.quartz.impl.jdbcjobstore.StdJDBCDelegate"; + + /** + * 校验并创建 Quartz 配置。 + * + * @throws IllegalArgumentException 任一配置越界时抛出 + */ + public QuartzSchedulerConfig { + schedulerName = requireText(schedulerName, "schedulerName", 120); + instanceId = requireText(instanceId, "instanceId", 190); + tablePrefix = requireText(tablePrefix, "tablePrefix", 180); + driverDelegateClass = requireText( + driverDelegateClass, + "driverDelegateClass", + 250 + ); + if (threadCount < 1 || threadCount > 1000) { + throw new IllegalArgumentException("threadCount must be between 1 and 1000"); + } + if (threadPriority < Thread.MIN_PRIORITY || threadPriority > Thread.MAX_PRIORITY) { + throw new IllegalArgumentException("threadPriority must be between 1 and 10"); + } + if (clusterCheckinIntervalMillis < 1000) { + throw new IllegalArgumentException( + "clusterCheckinIntervalMillis must be at least 1000" + ); + } + if (misfireThresholdMillis < 1) { + throw new IllegalArgumentException("misfireThresholdMillis must be positive"); + } + if (shutdownWaitTimeoutMillis < 1 || shutdownWaitTimeoutMillis > 600_000L) { + throw new IllegalArgumentException( + "shutdownWaitTimeoutMillis must be between 1 and 600000" + ); + } + } + + /** + * 创建适合常规 JDBC 集群的安全默认配置。 + * + * @param schedulerName Scheduler Name + * @return 默认配置 + */ + public static QuartzSchedulerConfig clusteredDefaults(String schedulerName) { + return new QuartzSchedulerConfig( + schedulerName, + "AUTO", + "QRTZ_", + STANDARD_JDBC_DELEGATE, + true, + 8, + Thread.NORM_PRIORITY, + 15_000L, + 60_000L, + true, + 30_000L, + true + ); + } + + /** + * 规范化必填配置文本。 + * + * @param value 原始值 + * @param field 字段名 + * @param maxLength 最大长度 + * @return 规范化值 + */ + private static String requireText(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; + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzSchedulerFactory.java b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzSchedulerFactory.java new file mode 100644 index 0000000..57c206f --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzSchedulerFactory.java @@ -0,0 +1,258 @@ +package com.easyagents.scheduler.quartz; + +import com.easyagents.scheduler.ScheduleErrorCode; +import com.easyagents.scheduler.ScheduleException; +import com.easyagents.scheduler.ScheduleExecutionListener; +import com.easyagents.scheduler.ScheduleHandler; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.impl.SchedulerRepository; +import org.quartz.impl.StdSchedulerFactory; +import org.quartz.utils.DBConnectionManager; + +import javax.sql.DataSource; +import java.sql.SQLException; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Properties; + +/** + * 使用调用方 DataSource 创建独立 Quartz Scheduler 的工厂。 + */ +public final class QuartzSchedulerFactory { + + /** 禁止实例化工厂。 */ + private QuartzSchedulerFactory() { + } + + /** + * 创建采用 Quartz JDBC JobStore 的调度服务。 + * + *

返回的服务尚未启动,调用方应在应用生命周期就绪时调用 + * {@link QuartzScheduleService#start()}。

+ * + * @param dataSource 调用方管理生命周期的 DataSource + * @param config Quartz Scheduler 配置 + * @param handlers 当前节点注册的 Handler + * @param listeners 当前节点注册的监听器 + * @return 尚未启动的调度服务 + * @throws ScheduleException 结构校验或 Scheduler 初始化失败时抛出 + */ + public static synchronized QuartzScheduleService createJdbc( + DataSource dataSource, + QuartzSchedulerConfig config, + Collection handlers, + Collection listeners + ) { + Objects.requireNonNull(dataSource, "dataSource must not be null"); + Objects.requireNonNull(config, "config must not be null"); + if (config.validateSchema()) { + try { + QuartzSchemaValidator.validate(dataSource, config.tablePrefix()); + } catch (ScheduleException exception) { + throw new ScheduleException( + exception.errorCode(), + "Quartz schema validation failed for scheduler " + config.schedulerName() + + " and prefix " + config.tablePrefix() + ": " + + exception.getMessage(), + exception + ); + } + } + rejectDuplicateSchedulerName(config.schedulerName()); + + // Quartz 的全局连接注册表不支持移除,稳定名称可避免应用内重建时持续增长。 + String dataSourceName = "easyAgentsScheduler-" + config.schedulerName(); + DBConnectionManager connectionManager = DBConnectionManager.getInstance(); + connectionManager.addConnectionProvider( + dataSourceName, + new DataSourceConnectionProvider(dataSource) + ); + Scheduler scheduler = null; + try { + scheduler = new StdSchedulerFactory(properties(config, dataSourceName)).getScheduler(); + return attach( + scheduler, + config.waitForJobsToCompleteOnShutdown(), + config.shutdownWaitTimeoutMillis(), + handlers, + listeners + ); + } catch (Exception exception) { + cleanupAfterFailure(scheduler, dataSourceName, exception); + if (exception instanceof ScheduleException scheduleException) { + throw scheduleException; + } + throw new ScheduleException( + ScheduleErrorCode.PROVIDER_FAILURE, + "failed to initialize Quartz scheduler " + config.schedulerName(), + exception + ); + } + } + + /** + * 将 Handler 运行时装配到已有 Scheduler,供无数据库的单元测试复用。 + * + * @param scheduler 已创建且未关闭的 Scheduler + * @param handlers 当前节点 Handler + * @param listeners 当前节点监听器 + * @return 尚未启动的调度服务 + * @throws ScheduleException 运行时装配失败时抛出 + */ + static QuartzScheduleService attach( + Scheduler scheduler, + Collection handlers, + Collection listeners + ) { + return attach(scheduler, true, 30_000L, handlers, listeners); + } + + /** + * 使用显式关闭策略装配已有 Scheduler。 + * + * @param scheduler 已创建的 Scheduler + * @param waitForJobsToCompleteOnShutdown 是否等待在途任务 + * @param shutdownWaitTimeoutMillis 最长等待毫秒数 + * @param handlers 当前节点 Handler + * @param listeners 当前节点监听器 + * @return 尚未启动的调度服务 + */ + static QuartzScheduleService attach( + Scheduler scheduler, + boolean waitForJobsToCompleteOnShutdown, + long shutdownWaitTimeoutMillis, + Collection handlers, + Collection listeners + ) { + try { + QuartzRuntime runtime = new QuartzRuntime( + handlers == null ? List.of() : handlers, + listeners == null ? List.of() : listeners + ); + scheduler.getContext().put(QuartzRuntime.SCHEDULER_CONTEXT_KEY, runtime); + scheduler.getListenerManager().addTriggerListener(new QuartzMisfireListener(runtime)); + return new QuartzScheduleService( + scheduler, + waitForJobsToCompleteOnShutdown, + shutdownWaitTimeoutMillis + ); + } catch (SchedulerException exception) { + throw new ScheduleException( + ScheduleErrorCode.PROVIDER_FAILURE, + "failed to attach Easy Agents scheduler runtime", + exception + ); + } + } + + /** + * 构造隔离的原生 Quartz 配置。 + * + * @param config Provider 配置 + * @param dataSourceName Quartz 内部 DataSource 名称 + * @return Quartz 属性 + */ + private static Properties properties( + QuartzSchedulerConfig config, + String dataSourceName + ) { + Properties properties = new Properties(); + properties.setProperty("org.quartz.scheduler.instanceName", config.schedulerName()); + properties.setProperty("org.quartz.scheduler.instanceId", config.instanceId()); + properties.setProperty( + "org.quartz.threadPool.class", + "org.quartz.simpl.SimpleThreadPool" + ); + properties.setProperty( + "org.quartz.threadPool.threadCount", + Integer.toString(config.threadCount()) + ); + properties.setProperty( + "org.quartz.threadPool.threadPriority", + Integer.toString(config.threadPriority()) + ); + properties.setProperty( + "org.quartz.jobStore.class", + "org.quartz.impl.jdbcjobstore.JobStoreTX" + ); + properties.setProperty( + "org.quartz.jobStore.driverDelegateClass", + config.driverDelegateClass() + ); + properties.setProperty("org.quartz.jobStore.useProperties", "true"); + properties.setProperty("org.quartz.jobStore.dataSource", dataSourceName); + properties.setProperty("org.quartz.jobStore.tablePrefix", config.tablePrefix()); + properties.setProperty( + "org.quartz.jobStore.isClustered", + Boolean.toString(config.clustered()) + ); + properties.setProperty( + "org.quartz.jobStore.clusterCheckinInterval", + Long.toString(config.clusterCheckinIntervalMillis()) + ); + properties.setProperty( + "org.quartz.jobStore.misfireThreshold", + Long.toString(config.misfireThresholdMillis()) + ); + properties.setProperty("org.quartz.scheduler.interruptJobsOnShutdown", "true"); + return properties; + } + + /** + * 拒绝同 JVM 中仍存活的同名 Scheduler。 + * + * @param schedulerName Scheduler Name + */ + private static void rejectDuplicateSchedulerName(String schedulerName) { + Scheduler existing = SchedulerRepository.getInstance().lookup(schedulerName); + if (existing == null) { + return; + } + try { + if (existing.isShutdown()) { + SchedulerRepository.getInstance().remove(schedulerName); + return; + } + } catch (SchedulerException exception) { + throw new ScheduleException( + ScheduleErrorCode.PROVIDER_FAILURE, + "failed to inspect existing Quartz scheduler " + schedulerName, + exception + ); + } + throw new ScheduleException( + ScheduleErrorCode.PROVIDER_FAILURE, + "Quartz scheduler name is already in use in this JVM: " + schedulerName + ); + } + + /** + * 清理初始化失败留下的 Scheduler 与 DataSource 引用。 + * + * @param scheduler 已创建的 Scheduler,可为空 + * @param dataSourceName Quartz 内部 DataSource 名称 + * @param originalFailure 原始失败 + */ + private static void cleanupAfterFailure( + Scheduler scheduler, + String dataSourceName, + Exception originalFailure + ) { + if (scheduler != null) { + try { + if (!scheduler.isShutdown()) { + scheduler.shutdown(false); + } + } catch (SchedulerException cleanupFailure) { + originalFailure.addSuppressed(cleanupFailure); + } + } + try { + DBConnectionManager.getInstance().shutdown(dataSourceName); + } catch (SQLException cleanupFailure) { + originalFailure.addSuppressed(cleanupFailure); + } + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzSchemaValidator.java b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzSchemaValidator.java new file mode 100644 index 0000000..927f2cd --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/java/com/easyagents/scheduler/quartz/QuartzSchemaValidator.java @@ -0,0 +1,261 @@ +package com.easyagents.scheduler.quartz; + +import com.easyagents.scheduler.ScheduleErrorCode; +import com.easyagents.scheduler.ScheduleException; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Quartz JDBC JobStore 必需结构的只读校验器。 + */ +public final class QuartzSchemaValidator { + + private static final List REQUIRED_SUFFIXES = List.of( + "JOB_DETAILS", + "TRIGGERS", + "SIMPLE_TRIGGERS", + "CRON_TRIGGERS", + "SIMPROP_TRIGGERS", + "BLOB_TRIGGERS", + "CALENDARS", + "PAUSED_TRIGGER_GRPS", + "FIRED_TRIGGERS", + "SCHEDULER_STATE", + "LOCKS" + ); + + private static final Map> REQUIRED_COLUMNS = Map.ofEntries( + Map.entry("JOB_DETAILS", Set.of( + "SCHED_NAME", "JOB_NAME", "JOB_GROUP", "DESCRIPTION", "JOB_CLASS_NAME", + "IS_DURABLE", "IS_NONCONCURRENT", "IS_UPDATE_DATA", "REQUESTS_RECOVERY", + "JOB_DATA" + )), + Map.entry("TRIGGERS", Set.of( + "SCHED_NAME", "TRIGGER_NAME", "TRIGGER_GROUP", "JOB_NAME", "JOB_GROUP", + "DESCRIPTION", "NEXT_FIRE_TIME", "PREV_FIRE_TIME", "PRIORITY", "TRIGGER_STATE", + "TRIGGER_TYPE", "START_TIME", "END_TIME", "CALENDAR_NAME", "MISFIRE_INSTR", + "JOB_DATA" + )), + Map.entry("SIMPLE_TRIGGERS", Set.of( + "SCHED_NAME", "TRIGGER_NAME", "TRIGGER_GROUP", "REPEAT_COUNT", + "REPEAT_INTERVAL", "TIMES_TRIGGERED" + )), + Map.entry("CRON_TRIGGERS", Set.of( + "SCHED_NAME", "TRIGGER_NAME", "TRIGGER_GROUP", "CRON_EXPRESSION", "TIME_ZONE_ID" + )), + Map.entry("SIMPROP_TRIGGERS", Set.of( + "SCHED_NAME", "TRIGGER_NAME", "TRIGGER_GROUP", "STR_PROP_1", "STR_PROP_2", + "STR_PROP_3", "INT_PROP_1", "INT_PROP_2", "LONG_PROP_1", "LONG_PROP_2", + "DEC_PROP_1", "DEC_PROP_2", "BOOL_PROP_1", "BOOL_PROP_2" + )), + Map.entry("BLOB_TRIGGERS", Set.of( + "SCHED_NAME", "TRIGGER_NAME", "TRIGGER_GROUP", "BLOB_DATA" + )), + Map.entry("CALENDARS", Set.of("SCHED_NAME", "CALENDAR_NAME", "CALENDAR")), + Map.entry("PAUSED_TRIGGER_GRPS", Set.of("SCHED_NAME", "TRIGGER_GROUP")), + Map.entry("FIRED_TRIGGERS", Set.of( + "SCHED_NAME", "ENTRY_ID", "TRIGGER_NAME", "TRIGGER_GROUP", "INSTANCE_NAME", + "FIRED_TIME", "SCHED_TIME", "PRIORITY", "STATE", "JOB_NAME", "JOB_GROUP", + "IS_NONCONCURRENT", "REQUESTS_RECOVERY" + )), + Map.entry("SCHEDULER_STATE", Set.of( + "SCHED_NAME", "INSTANCE_NAME", "LAST_CHECKIN_TIME", "CHECKIN_INTERVAL" + )), + Map.entry("LOCKS", Set.of("SCHED_NAME", "LOCK_NAME")) + ); + + private QuartzSchemaValidator() { + } + + /** + * 校验指定 DataSource 中的 Quartz 表和关键列。 + * + * @param dataSource 调用方 DataSource + * @param tablePrefix Quartz 表前缀,可包含 Schema + * @throws ScheduleException 表或关键列缺失、元数据读取失败时抛出 + */ + public static void validate(DataSource dataSource, String tablePrefix) { + Objects.requireNonNull(dataSource, "dataSource must not be null"); + PrefixParts prefixParts = PrefixParts.parse(tablePrefix); + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String databaseProduct = metadata.getDatabaseProductName(); + MetadataScope metadataScope = resolveMetadataScope( + connection, + metadata, + prefixParts.schema() + ); + Map actualTables = loadTables( + metadata, + metadataScope + ); + List missingTables = new ArrayList<>(); + for (String suffix : REQUIRED_SUFFIXES) { + String expected = normalize(prefixParts.tablePrefix() + suffix); + if (!actualTables.containsKey(expected)) { + missingTables.add(prefixParts.displayPrefix() + suffix); + } + } + if (!missingTables.isEmpty()) { + throw invalid( + "missing Quartz tables in " + databaseProduct + ": " + + String.join(", ", missingTables) + ); + } + validateColumns( + metadata, + metadataScope, + prefixParts, + actualTables, + databaseProduct + ); + } catch (SQLException exception) { + throw new ScheduleException( + ScheduleErrorCode.SCHEMA_INVALID, + "failed to inspect Quartz schema for prefix " + tablePrefix, + exception + ); + } + } + + /** + * 解析 JDBC 元数据查询使用的 Catalog 与 Schema。 + * + * @param connection 当前数据库连接 + * @param metadata JDBC 元数据 + * @param declaredSchema 表前缀中显式声明的 Schema,可为空 + * @return 仅覆盖 Quartz 实际查询范围的元数据作用域 + * @throws SQLException 读取连接或数据库能力失败时抛出 + */ + private static MetadataScope resolveMetadataScope( + Connection connection, + DatabaseMetaData metadata, + String declaredSchema + ) throws SQLException { + if ( + !metadata.supportsSchemasInTableDefinitions() + && metadata.supportsCatalogsInTableDefinitions() + ) { + String catalog = declaredSchema == null ? connection.getCatalog() : declaredSchema; + return new MetadataScope(catalog, null); + } + String schema = declaredSchema == null ? connection.getSchema() : declaredSchema; + return new MetadataScope(connection.getCatalog(), schema); + } + + /** + * 加载指定作用域内的数据库表。 + * + * @param metadata JDBC 元数据 + * @param metadataScope Catalog 与 Schema 作用域 + * @return 规范化表名到实际表名的映射 + * @throws SQLException 元数据读取失败时抛出 + */ + private static Map loadTables( + DatabaseMetaData metadata, + MetadataScope metadataScope + ) throws SQLException { + Map tables = new HashMap<>(); + try (ResultSet resultSet = metadata.getTables( + metadataScope.catalog(), + metadataScope.schema(), + null, + new String[]{"TABLE"} + )) { + while (resultSet.next()) { + String tableName = resultSet.getString("TABLE_NAME"); + if (tableName != null) { + tables.put(normalize(tableName), tableName); + } + } + } + return tables; + } + + /** + * 校验指定作用域内 Quartz 表的必需列。 + * + * @param metadata JDBC 元数据 + * @param metadataScope Catalog 与 Schema 作用域 + * @param prefixParts Quartz 表前缀 + * @param actualTables 已发现表 + * @param databaseProduct 数据库产品名 + * @throws SQLException 元数据读取失败时抛出 + */ + private static void validateColumns( + DatabaseMetaData metadata, + MetadataScope metadataScope, + PrefixParts prefixParts, + Map actualTables, + String databaseProduct + ) throws SQLException { + for (Map.Entry> requirement : REQUIRED_COLUMNS.entrySet()) { + String normalizedName = normalize(prefixParts.tablePrefix() + requirement.getKey()); + String actualName = actualTables.get(normalizedName); + Set actualColumns = new HashSet<>(); + try (ResultSet resultSet = metadata.getColumns( + metadataScope.catalog(), + metadataScope.schema(), + actualName, + null + )) { + while (resultSet.next()) { + actualColumns.add(normalize(resultSet.getString("COLUMN_NAME"))); + } + } + Set missingColumns = new HashSet<>(requirement.getValue()); + missingColumns.removeAll(actualColumns); + if (!missingColumns.isEmpty()) { + throw invalid( + "missing columns in " + databaseProduct + " table " + + prefixParts.displayPrefix() + requirement.getKey() + ": " + + String.join(", ", missingColumns) + ); + } + } + } + + private static ScheduleException invalid(String message) { + return new ScheduleException(ScheduleErrorCode.SCHEMA_INVALID, message); + } + + private static String normalize(String value) { + return value == null ? "" : value.toUpperCase(Locale.ROOT); + } + + /** JDBC 元数据的 Catalog 与 Schema 查询范围。 */ + private record MetadataScope(String catalog, String schema) { + } + + private record PrefixParts(String schema, String tablePrefix, String displayPrefix) { + + private static PrefixParts parse(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("tablePrefix must not be blank"); + } + String normalized = value.trim(); + int separator = normalized.lastIndexOf('.'); + if (separator < 0) { + return new PrefixParts(null, normalized, normalized); + } + String schema = normalized.substring(0, separator); + String prefix = normalized.substring(separator + 1); + if (schema.isBlank() || prefix.isBlank()) { + throw new IllegalArgumentException("invalid tablePrefix: " + value); + } + return new PrefixParts(schema, prefix, normalized); + } + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/resources/quartz-schema/h2-2.5.2.sql b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/resources/quartz-schema/h2-2.5.2.sql new file mode 100644 index 0000000..39ccec2 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/resources/quartz-schema/h2-2.5.2.sql @@ -0,0 +1,249 @@ +-- Quartz 2.5.2 JDBC JobStore 建表脚本。 +-- 上游来源:https://raw.githubusercontent.com/quartz-scheduler/quartz/v2.5.2/quartz/src/main/resources/org/quartz/impl/jdbcjobstore/tables_h2.sql +-- 本脚本仅创建结构,不包含 DROP;请由调用方纳入自己的数据库迁移流程。 + +-- Thanks to Amir Kibbar and Peter Rietzler for contributing the schema for H2 database, +-- and verifying that it works with Quartz's StdJDBCDelegate +-- +-- H2 2.x 已移除 MVCC URL 参数;默认 MVStore 提供行级锁。 +-- +-- +-- In your Quartz properties file, you'll need to set +-- org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.StdJDBCDelegate + +CREATE TABLE QRTZ_CALENDARS ( + SCHED_NAME VARCHAR(120) NOT NULL, + CALENDAR_NAME VARCHAR (200) NOT NULL , + CALENDAR BLOB NOT NULL +); + +CREATE TABLE QRTZ_CRON_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR (200) NOT NULL , + TRIGGER_GROUP VARCHAR (200) NOT NULL , + CRON_EXPRESSION VARCHAR (120) NOT NULL , + TIME_ZONE_ID VARCHAR (80) +); + +CREATE TABLE QRTZ_FIRED_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + ENTRY_ID VARCHAR (95) NOT NULL , + TRIGGER_NAME VARCHAR (200) NOT NULL , + TRIGGER_GROUP VARCHAR (200) NOT NULL , + INSTANCE_NAME VARCHAR (200) NOT NULL , + FIRED_TIME BIGINT NOT NULL , + SCHED_TIME BIGINT NOT NULL , + PRIORITY INTEGER NOT NULL , + STATE VARCHAR (16) NOT NULL, + JOB_NAME VARCHAR (200) NULL , + JOB_GROUP VARCHAR (200) NULL , + IS_NONCONCURRENT BOOLEAN NULL , + REQUESTS_RECOVERY BOOLEAN NULL +); + +CREATE TABLE QRTZ_PAUSED_TRIGGER_GRPS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_GROUP VARCHAR (200) NOT NULL +); + +CREATE TABLE QRTZ_SCHEDULER_STATE ( + SCHED_NAME VARCHAR(120) NOT NULL, + INSTANCE_NAME VARCHAR (200) NOT NULL , + LAST_CHECKIN_TIME BIGINT NOT NULL , + CHECKIN_INTERVAL BIGINT NOT NULL +); + +CREATE TABLE QRTZ_LOCKS ( + SCHED_NAME VARCHAR(120) NOT NULL, + LOCK_NAME VARCHAR (40) NOT NULL +); + +CREATE TABLE QRTZ_JOB_DETAILS ( + SCHED_NAME VARCHAR(120) NOT NULL, + JOB_NAME VARCHAR (200) NOT NULL , + JOB_GROUP VARCHAR (200) NOT NULL , + DESCRIPTION VARCHAR (250) NULL , + JOB_CLASS_NAME VARCHAR (250) NOT NULL , + IS_DURABLE BOOLEAN NOT NULL , + IS_NONCONCURRENT BOOLEAN NOT NULL , + IS_UPDATE_DATA BOOLEAN NOT NULL , + REQUESTS_RECOVERY BOOLEAN NOT NULL , + JOB_DATA BLOB NULL +); + +CREATE TABLE QRTZ_SIMPLE_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR (200) NOT NULL , + TRIGGER_GROUP VARCHAR (200) NOT NULL , + REPEAT_COUNT BIGINT NOT NULL , + REPEAT_INTERVAL BIGINT NOT NULL , + TIMES_TRIGGERED BIGINT NOT NULL +); + +CREATE TABLE QRTZ_SIMPROP_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + STR_PROP_1 VARCHAR(512) NULL, + STR_PROP_2 VARCHAR(512) NULL, + STR_PROP_3 VARCHAR(512) NULL, + INT_PROP_1 INTEGER NULL, + INT_PROP_2 INTEGER NULL, + LONG_PROP_1 BIGINT NULL, + LONG_PROP_2 BIGINT NULL, + DEC_PROP_1 NUMERIC(13,4) NULL, + DEC_PROP_2 NUMERIC(13,4) NULL, + BOOL_PROP_1 BOOLEAN NULL, + BOOL_PROP_2 BOOLEAN NULL +); + +CREATE TABLE QRTZ_BLOB_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR (200) NOT NULL , + TRIGGER_GROUP VARCHAR (200) NOT NULL , + BLOB_DATA BLOB NULL +); + +CREATE TABLE QRTZ_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR (200) NOT NULL , + TRIGGER_GROUP VARCHAR (200) NOT NULL , + JOB_NAME VARCHAR (200) NOT NULL , + JOB_GROUP VARCHAR (200) NOT NULL , + DESCRIPTION VARCHAR (250) NULL , + NEXT_FIRE_TIME BIGINT NULL , + PREV_FIRE_TIME BIGINT NULL , + PRIORITY INTEGER NULL , + TRIGGER_STATE VARCHAR (16) NOT NULL , + TRIGGER_TYPE VARCHAR (8) NOT NULL , + START_TIME BIGINT NOT NULL , + END_TIME BIGINT NULL , + CALENDAR_NAME VARCHAR (200) NULL , + MISFIRE_INSTR SMALLINT NULL , + JOB_DATA BLOB NULL +); + +ALTER TABLE QRTZ_CALENDARS ADD + CONSTRAINT PK_QRTZ_CALENDARS PRIMARY KEY + ( + SCHED_NAME, + CALENDAR_NAME + ); + +ALTER TABLE QRTZ_CRON_TRIGGERS ADD + CONSTRAINT PK_QRTZ_CRON_TRIGGERS PRIMARY KEY + ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ); + +ALTER TABLE QRTZ_FIRED_TRIGGERS ADD + CONSTRAINT PK_QRTZ_FIRED_TRIGGERS PRIMARY KEY + ( + SCHED_NAME, + ENTRY_ID + ); + +ALTER TABLE QRTZ_PAUSED_TRIGGER_GRPS ADD + CONSTRAINT PK_QRTZ_PAUSED_TRIGGER_GRPS PRIMARY KEY + ( + SCHED_NAME, + TRIGGER_GROUP + ); + +ALTER TABLE QRTZ_SCHEDULER_STATE ADD + CONSTRAINT PK_QRTZ_SCHEDULER_STATE PRIMARY KEY + ( + SCHED_NAME, + INSTANCE_NAME + ); + +ALTER TABLE QRTZ_LOCKS ADD + CONSTRAINT PK_QRTZ_LOCKS PRIMARY KEY + ( + SCHED_NAME, + LOCK_NAME + ); + +ALTER TABLE QRTZ_JOB_DETAILS ADD + CONSTRAINT PK_QRTZ_JOB_DETAILS PRIMARY KEY + ( + SCHED_NAME, + JOB_NAME, + JOB_GROUP + ); + +ALTER TABLE QRTZ_SIMPLE_TRIGGERS ADD + CONSTRAINT PK_QRTZ_SIMPLE_TRIGGERS PRIMARY KEY + ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ); + +ALTER TABLE QRTZ_SIMPROP_TRIGGERS ADD + CONSTRAINT PK_QRTZ_SIMPROP_TRIGGERS PRIMARY KEY + ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ); + +ALTER TABLE QRTZ_TRIGGERS ADD + CONSTRAINT PK_QRTZ_TRIGGERS PRIMARY KEY + ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ); + +ALTER TABLE QRTZ_CRON_TRIGGERS ADD + CONSTRAINT FK_QRTZ_CRON_TRIGGERS_QRTZ_TRIGGERS FOREIGN KEY + ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ) REFERENCES QRTZ_TRIGGERS ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ) ON DELETE CASCADE; + + +ALTER TABLE QRTZ_SIMPLE_TRIGGERS ADD + CONSTRAINT FK_QRTZ_SIMPLE_TRIGGERS_QRTZ_TRIGGERS FOREIGN KEY + ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ) REFERENCES QRTZ_TRIGGERS ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ) ON DELETE CASCADE; + +ALTER TABLE QRTZ_SIMPROP_TRIGGERS ADD + CONSTRAINT FK_QRTZ_SIMPROP_TRIGGERS_QRTZ_TRIGGERS FOREIGN KEY + ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ) REFERENCES QRTZ_TRIGGERS ( + SCHED_NAME, + TRIGGER_NAME, + TRIGGER_GROUP + ) ON DELETE CASCADE; + + +ALTER TABLE QRTZ_TRIGGERS ADD + CONSTRAINT FK_QRTZ_TRIGGERS_QRTZ_JOB_DETAILS FOREIGN KEY + ( + SCHED_NAME, + JOB_NAME, + JOB_GROUP + ) REFERENCES QRTZ_JOB_DETAILS ( + SCHED_NAME, + JOB_NAME, + JOB_GROUP + ); diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/resources/quartz-schema/mysql-2.5.2.sql b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/resources/quartz-schema/mysql-2.5.2.sql new file mode 100644 index 0000000..25ffd6d --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/resources/quartz-schema/mysql-2.5.2.sql @@ -0,0 +1,170 @@ +-- Quartz 2.5.2 JDBC JobStore 建表脚本。 +-- 上游来源:https://raw.githubusercontent.com/quartz-scheduler/quartz/v2.5.2/quartz/src/main/resources/org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql +-- 本脚本仅创建结构,不包含 DROP;请由调用方纳入自己的数据库迁移流程。 + +-- +-- In your Quartz properties file, you'll need to set +-- org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.StdJDBCDelegate +-- +-- +-- By: Ron Cordell - roncordell +-- I didn't see this anywhere, so I thought I'd post it here. This is the script from Quartz to create the tables in a MySQL database, modified to use INNODB instead of MYISAM. + + +CREATE TABLE QRTZ_JOB_DETAILS( +SCHED_NAME VARCHAR(120) NOT NULL, +JOB_NAME VARCHAR(190) NOT NULL, +JOB_GROUP VARCHAR(190) NOT NULL, +DESCRIPTION VARCHAR(250) NULL, +JOB_CLASS_NAME VARCHAR(250) NOT NULL, +IS_DURABLE VARCHAR(1) NOT NULL, +IS_NONCONCURRENT VARCHAR(1) NOT NULL, +IS_UPDATE_DATA VARCHAR(1) NOT NULL, +REQUESTS_RECOVERY VARCHAR(1) NOT NULL, +JOB_DATA BLOB NULL, +PRIMARY KEY (SCHED_NAME,JOB_NAME,JOB_GROUP)) +ENGINE=InnoDB; + +CREATE TABLE QRTZ_TRIGGERS ( +SCHED_NAME VARCHAR(120) NOT NULL, +TRIGGER_NAME VARCHAR(190) NOT NULL, +TRIGGER_GROUP VARCHAR(190) NOT NULL, +JOB_NAME VARCHAR(190) NOT NULL, +JOB_GROUP VARCHAR(190) NOT NULL, +DESCRIPTION VARCHAR(250) NULL, +NEXT_FIRE_TIME BIGINT(13) NULL, +PREV_FIRE_TIME BIGINT(13) NULL, +PRIORITY INTEGER NULL, +TRIGGER_STATE VARCHAR(16) NOT NULL, +TRIGGER_TYPE VARCHAR(8) NOT NULL, +START_TIME BIGINT(13) NOT NULL, +END_TIME BIGINT(13) NULL, +CALENDAR_NAME VARCHAR(190) NULL, +MISFIRE_INSTR SMALLINT(2) NULL, +JOB_DATA BLOB NULL, +PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP), +FOREIGN KEY (SCHED_NAME,JOB_NAME,JOB_GROUP) +REFERENCES QRTZ_JOB_DETAILS(SCHED_NAME,JOB_NAME,JOB_GROUP)) +ENGINE=InnoDB; + +CREATE TABLE QRTZ_SIMPLE_TRIGGERS ( +SCHED_NAME VARCHAR(120) NOT NULL, +TRIGGER_NAME VARCHAR(190) NOT NULL, +TRIGGER_GROUP VARCHAR(190) NOT NULL, +REPEAT_COUNT BIGINT(7) NOT NULL, +REPEAT_INTERVAL BIGINT(12) NOT NULL, +TIMES_TRIGGERED BIGINT(10) NOT NULL, +PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP), +FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP) +REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)) +ENGINE=InnoDB; + +CREATE TABLE QRTZ_CRON_TRIGGERS ( +SCHED_NAME VARCHAR(120) NOT NULL, +TRIGGER_NAME VARCHAR(190) NOT NULL, +TRIGGER_GROUP VARCHAR(190) NOT NULL, +CRON_EXPRESSION VARCHAR(120) NOT NULL, +TIME_ZONE_ID VARCHAR(80), +PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP), +FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP) +REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)) +ENGINE=InnoDB; + +CREATE TABLE QRTZ_SIMPROP_TRIGGERS + ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(190) NOT NULL, + TRIGGER_GROUP VARCHAR(190) NOT NULL, + STR_PROP_1 VARCHAR(512) NULL, + STR_PROP_2 VARCHAR(512) NULL, + STR_PROP_3 VARCHAR(512) NULL, + INT_PROP_1 INT NULL, + INT_PROP_2 INT NULL, + LONG_PROP_1 BIGINT NULL, + LONG_PROP_2 BIGINT NULL, + DEC_PROP_1 NUMERIC(13,4) NULL, + DEC_PROP_2 NUMERIC(13,4) NULL, + BOOL_PROP_1 VARCHAR(1) NULL, + BOOL_PROP_2 VARCHAR(1) NULL, + PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)) +ENGINE=InnoDB; + +CREATE TABLE QRTZ_BLOB_TRIGGERS ( +SCHED_NAME VARCHAR(120) NOT NULL, +TRIGGER_NAME VARCHAR(190) NOT NULL, +TRIGGER_GROUP VARCHAR(190) NOT NULL, +BLOB_DATA BLOB NULL, +PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP), +INDEX (SCHED_NAME,TRIGGER_NAME, TRIGGER_GROUP), +FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP) +REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)) +ENGINE=InnoDB; + +CREATE TABLE QRTZ_CALENDARS ( +SCHED_NAME VARCHAR(120) NOT NULL, +CALENDAR_NAME VARCHAR(190) NOT NULL, +CALENDAR BLOB NOT NULL, +PRIMARY KEY (SCHED_NAME,CALENDAR_NAME)) +ENGINE=InnoDB; + +CREATE TABLE QRTZ_PAUSED_TRIGGER_GRPS ( +SCHED_NAME VARCHAR(120) NOT NULL, +TRIGGER_GROUP VARCHAR(190) NOT NULL, +PRIMARY KEY (SCHED_NAME,TRIGGER_GROUP)) +ENGINE=InnoDB; + +CREATE TABLE QRTZ_FIRED_TRIGGERS ( +SCHED_NAME VARCHAR(120) NOT NULL, +ENTRY_ID VARCHAR(95) NOT NULL, +TRIGGER_NAME VARCHAR(190) NOT NULL, +TRIGGER_GROUP VARCHAR(190) NOT NULL, +INSTANCE_NAME VARCHAR(190) NOT NULL, +FIRED_TIME BIGINT(13) NOT NULL, +SCHED_TIME BIGINT(13) NOT NULL, +PRIORITY INTEGER NOT NULL, +STATE VARCHAR(16) NOT NULL, +JOB_NAME VARCHAR(190) NULL, +JOB_GROUP VARCHAR(190) NULL, +IS_NONCONCURRENT VARCHAR(1) NULL, +REQUESTS_RECOVERY VARCHAR(1) NULL, +PRIMARY KEY (SCHED_NAME,ENTRY_ID)) +ENGINE=InnoDB; + +CREATE TABLE QRTZ_SCHEDULER_STATE ( +SCHED_NAME VARCHAR(120) NOT NULL, +INSTANCE_NAME VARCHAR(190) NOT NULL, +LAST_CHECKIN_TIME BIGINT(13) NOT NULL, +CHECKIN_INTERVAL BIGINT(13) NOT NULL, +PRIMARY KEY (SCHED_NAME,INSTANCE_NAME)) +ENGINE=InnoDB; + +CREATE TABLE QRTZ_LOCKS ( +SCHED_NAME VARCHAR(120) NOT NULL, +LOCK_NAME VARCHAR(40) NOT NULL, +PRIMARY KEY (SCHED_NAME,LOCK_NAME)) +ENGINE=InnoDB; + +CREATE INDEX IDX_QRTZ_J_REQ_RECOVERY ON QRTZ_JOB_DETAILS(SCHED_NAME,REQUESTS_RECOVERY); +CREATE INDEX IDX_QRTZ_J_GRP ON QRTZ_JOB_DETAILS(SCHED_NAME,JOB_GROUP); + +CREATE INDEX IDX_QRTZ_T_J ON QRTZ_TRIGGERS(SCHED_NAME,JOB_NAME,JOB_GROUP); +CREATE INDEX IDX_QRTZ_T_JG ON QRTZ_TRIGGERS(SCHED_NAME,JOB_GROUP); +CREATE INDEX IDX_QRTZ_T_C ON QRTZ_TRIGGERS(SCHED_NAME,CALENDAR_NAME); +CREATE INDEX IDX_QRTZ_T_G ON QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_GROUP); +CREATE INDEX IDX_QRTZ_T_STATE ON QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_STATE); +CREATE INDEX IDX_QRTZ_T_N_STATE ON QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP,TRIGGER_STATE); +CREATE INDEX IDX_QRTZ_T_N_G_STATE ON QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_GROUP,TRIGGER_STATE); +CREATE INDEX IDX_QRTZ_T_NEXT_FIRE_TIME ON QRTZ_TRIGGERS(SCHED_NAME,NEXT_FIRE_TIME); +CREATE INDEX IDX_QRTZ_T_NFT_ST ON QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_STATE,NEXT_FIRE_TIME); +CREATE INDEX IDX_QRTZ_T_NFT_MISFIRE ON QRTZ_TRIGGERS(SCHED_NAME,MISFIRE_INSTR,NEXT_FIRE_TIME); +CREATE INDEX IDX_QRTZ_T_NFT_ST_MISFIRE ON QRTZ_TRIGGERS(SCHED_NAME,MISFIRE_INSTR,NEXT_FIRE_TIME,TRIGGER_STATE); +CREATE INDEX IDX_QRTZ_T_NFT_ST_MISFIRE_GRP ON QRTZ_TRIGGERS(SCHED_NAME,MISFIRE_INSTR,NEXT_FIRE_TIME,TRIGGER_GROUP,TRIGGER_STATE); + +CREATE INDEX IDX_QRTZ_FT_TRIG_INST_NAME ON QRTZ_FIRED_TRIGGERS(SCHED_NAME,INSTANCE_NAME); +CREATE INDEX IDX_QRTZ_FT_INST_JOB_REQ_RCVRY ON QRTZ_FIRED_TRIGGERS(SCHED_NAME,INSTANCE_NAME,REQUESTS_RECOVERY); +CREATE INDEX IDX_QRTZ_FT_J_G ON QRTZ_FIRED_TRIGGERS(SCHED_NAME,JOB_NAME,JOB_GROUP); +CREATE INDEX IDX_QRTZ_FT_JG ON QRTZ_FIRED_TRIGGERS(SCHED_NAME,JOB_GROUP); +CREATE INDEX IDX_QRTZ_FT_T_G ON QRTZ_FIRED_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP); +CREATE INDEX IDX_QRTZ_FT_TG ON QRTZ_FIRED_TRIGGERS(SCHED_NAME,TRIGGER_GROUP); diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/resources/quartz-schema/postgresql-2.5.2.sql b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/resources/quartz-schema/postgresql-2.5.2.sql new file mode 100644 index 0000000..b79d95c --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/main/resources/quartz-schema/postgresql-2.5.2.sql @@ -0,0 +1,197 @@ +-- Quartz 2.5.2 JDBC JobStore 建表脚本。 +-- 上游来源:https://raw.githubusercontent.com/quartz-scheduler/quartz/v2.5.2/quartz/src/main/resources/org/quartz/impl/jdbcjobstore/tables_postgres.sql +-- 本脚本仅创建结构,不包含 DROP;请由调用方纳入自己的数据库迁移流程。 + +-- Thanks to Patrick Lightbody for submitting this... +-- +-- In your Quartz properties file, you'll need to set +-- org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.PostgreSQLDelegate + + +CREATE TABLE QRTZ_JOB_DETAILS +( + SCHED_NAME VARCHAR(120) NOT NULL, + JOB_NAME VARCHAR(200) NOT NULL, + JOB_GROUP VARCHAR(200) NOT NULL, + DESCRIPTION VARCHAR(250) NULL, + JOB_CLASS_NAME VARCHAR(250) NOT NULL, + IS_DURABLE BOOL NOT NULL, + IS_NONCONCURRENT BOOL NOT NULL, + IS_UPDATE_DATA BOOL NOT NULL, + REQUESTS_RECOVERY BOOL NOT NULL, + JOB_DATA BYTEA NULL, + PRIMARY KEY (SCHED_NAME, JOB_NAME, JOB_GROUP) +); + +CREATE TABLE QRTZ_TRIGGERS +( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + JOB_NAME VARCHAR(200) NOT NULL, + JOB_GROUP VARCHAR(200) NOT NULL, + DESCRIPTION VARCHAR(250) NULL, + NEXT_FIRE_TIME BIGINT NULL, + PREV_FIRE_TIME BIGINT NULL, + PRIORITY INTEGER NULL, + TRIGGER_STATE VARCHAR(16) NOT NULL, + TRIGGER_TYPE VARCHAR(8) NOT NULL, + START_TIME BIGINT NOT NULL, + END_TIME BIGINT NULL, + CALENDAR_NAME VARCHAR(200) NULL, + MISFIRE_INSTR SMALLINT NULL, + JOB_DATA BYTEA NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, JOB_NAME, JOB_GROUP) + REFERENCES QRTZ_JOB_DETAILS (SCHED_NAME, JOB_NAME, JOB_GROUP) +); + +CREATE TABLE QRTZ_SIMPLE_TRIGGERS +( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + REPEAT_COUNT BIGINT NOT NULL, + REPEAT_INTERVAL BIGINT NOT NULL, + TIMES_TRIGGERED BIGINT NOT NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +); + +CREATE TABLE QRTZ_CRON_TRIGGERS +( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + CRON_EXPRESSION VARCHAR(120) NOT NULL, + TIME_ZONE_ID VARCHAR(80), + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +); + +CREATE TABLE QRTZ_SIMPROP_TRIGGERS +( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + STR_PROP_1 VARCHAR(512) NULL, + STR_PROP_2 VARCHAR(512) NULL, + STR_PROP_3 VARCHAR(512) NULL, + INT_PROP_1 INT NULL, + INT_PROP_2 INT NULL, + LONG_PROP_1 BIGINT NULL, + LONG_PROP_2 BIGINT NULL, + DEC_PROP_1 NUMERIC(13, 4) NULL, + DEC_PROP_2 NUMERIC(13, 4) NULL, + BOOL_PROP_1 BOOL NULL, + BOOL_PROP_2 BOOL NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +); + +CREATE TABLE QRTZ_BLOB_TRIGGERS +( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + BLOB_DATA BYTEA NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +); + +CREATE TABLE QRTZ_CALENDARS +( + SCHED_NAME VARCHAR(120) NOT NULL, + CALENDAR_NAME VARCHAR(200) NOT NULL, + CALENDAR BYTEA NOT NULL, + PRIMARY KEY (SCHED_NAME, CALENDAR_NAME) +); + + +CREATE TABLE QRTZ_PAUSED_TRIGGER_GRPS +( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_GROUP) +); + +CREATE TABLE QRTZ_FIRED_TRIGGERS +( + SCHED_NAME VARCHAR(120) NOT NULL, + ENTRY_ID VARCHAR(95) NOT NULL, + TRIGGER_NAME VARCHAR(200) NOT NULL, + TRIGGER_GROUP VARCHAR(200) NOT NULL, + INSTANCE_NAME VARCHAR(200) NOT NULL, + FIRED_TIME BIGINT NOT NULL, + SCHED_TIME BIGINT NOT NULL, + PRIORITY INTEGER NOT NULL, + STATE VARCHAR(16) NOT NULL, + JOB_NAME VARCHAR(200) NULL, + JOB_GROUP VARCHAR(200) NULL, + IS_NONCONCURRENT BOOL NULL, + REQUESTS_RECOVERY BOOL NULL, + PRIMARY KEY (SCHED_NAME, ENTRY_ID) +); + +CREATE TABLE QRTZ_SCHEDULER_STATE +( + SCHED_NAME VARCHAR(120) NOT NULL, + INSTANCE_NAME VARCHAR(200) NOT NULL, + LAST_CHECKIN_TIME BIGINT NOT NULL, + CHECKIN_INTERVAL BIGINT NOT NULL, + PRIMARY KEY (SCHED_NAME, INSTANCE_NAME) +); + +CREATE TABLE QRTZ_LOCKS +( + SCHED_NAME VARCHAR(120) NOT NULL, + LOCK_NAME VARCHAR(40) NOT NULL, + PRIMARY KEY (SCHED_NAME, LOCK_NAME) +); + +CREATE INDEX IDX_QRTZ_J_REQ_RECOVERY + ON QRTZ_JOB_DETAILS (SCHED_NAME, REQUESTS_RECOVERY); +CREATE INDEX IDX_QRTZ_J_GRP + ON QRTZ_JOB_DETAILS (SCHED_NAME, JOB_GROUP); + +CREATE INDEX IDX_QRTZ_T_J + ON QRTZ_TRIGGERS (SCHED_NAME, JOB_NAME, JOB_GROUP); +CREATE INDEX IDX_QRTZ_T_JG + ON QRTZ_TRIGGERS (SCHED_NAME, JOB_GROUP); +CREATE INDEX IDX_QRTZ_T_C + ON QRTZ_TRIGGERS (SCHED_NAME, CALENDAR_NAME); +CREATE INDEX IDX_QRTZ_T_G + ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_GROUP); +CREATE INDEX IDX_QRTZ_T_STATE + ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_STATE); +CREATE INDEX IDX_QRTZ_T_N_STATE + ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP, TRIGGER_STATE); +CREATE INDEX IDX_QRTZ_T_N_G_STATE + ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_GROUP, TRIGGER_STATE); +CREATE INDEX IDX_QRTZ_T_NEXT_FIRE_TIME + ON QRTZ_TRIGGERS (SCHED_NAME, NEXT_FIRE_TIME); +CREATE INDEX IDX_QRTZ_T_NFT_ST + ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_STATE, NEXT_FIRE_TIME); +CREATE INDEX IDX_QRTZ_T_NFT_MISFIRE + ON QRTZ_TRIGGERS (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME); +CREATE INDEX IDX_QRTZ_T_NFT_ST_MISFIRE + ON QRTZ_TRIGGERS (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_STATE); +CREATE INDEX IDX_QRTZ_T_NFT_ST_MISFIRE_GRP + ON QRTZ_TRIGGERS (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_GROUP, TRIGGER_STATE); + +CREATE INDEX IDX_QRTZ_FT_TRIG_INST_NAME + ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, INSTANCE_NAME); +CREATE INDEX IDX_QRTZ_FT_INST_JOB_REQ_RCVRY + ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, INSTANCE_NAME, REQUESTS_RECOVERY); +CREATE INDEX IDX_QRTZ_FT_J_G + ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, JOB_NAME, JOB_GROUP); +CREATE INDEX IDX_QRTZ_FT_JG + ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, JOB_GROUP); +CREATE INDEX IDX_QRTZ_FT_T_G + ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP); +CREATE INDEX IDX_QRTZ_FT_TG + ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, TRIGGER_GROUP); diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/test/java/com/easyagents/scheduler/quartz/QuartzJdbcIntegrationTest.java b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/test/java/com/easyagents/scheduler/quartz/QuartzJdbcIntegrationTest.java new file mode 100644 index 0000000..c7d9fac --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/test/java/com/easyagents/scheduler/quartz/QuartzJdbcIntegrationTest.java @@ -0,0 +1,283 @@ +package com.easyagents.scheduler.quartz; + +import com.easyagents.scheduler.ConcurrencyPolicy; +import com.easyagents.scheduler.MisfirePolicy; +import com.easyagents.scheduler.OnceSchedulePlan; +import com.easyagents.scheduler.ScheduleDefinition; +import com.easyagents.scheduler.ScheduleErrorCode; +import com.easyagents.scheduler.ScheduleException; +import com.easyagents.scheduler.ScheduleFireContext; +import com.easyagents.scheduler.ScheduleHandler; +import com.easyagents.scheduler.ScheduleId; +import org.h2.jdbcx.JdbcDataSource; +import org.h2.tools.RunScript; +import org.junit.Test; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Quartz JDBC JobStore、建表资源和外部 DataSource 所有权的集成测试。 + */ +public class QuartzJdbcIntegrationTest { + + /** + * 验证 H2 建表脚本、结构校验和 JDBC JobStore 实际触发链路。 + */ + @Test + public void shouldRunWithCallerManagedDataSource() throws Exception { + JdbcDataSource dataSource = dataSource(); + executeSchema(dataSource); + QuartzSchemaValidator.validate(dataSource, "QRTZ_"); + + CountDownLatch latch = new CountDownLatch(1); + QuartzSchedulerConfig config = jdbcConfig(); + QuartzScheduleService service = QuartzSchedulerFactory.createJdbc( + dataSource, + config, + List.of(handler(latch)), + List.of() + ); + try { + service.start(); + service.create(new ScheduleDefinition( + new ScheduleId("jdbc", "once"), + "jdbc-handler", + new OnceSchedulePlan(Instant.now().plusMillis(500)), + MisfirePolicy.FIRE_ONCE_NOW, + ConcurrencyPolicy.DISALLOW, + true, + Map.of("source", "h2"), + "jdbc integration" + )); + assertTrue("JDBC handler did not execute", latch.await(8, TimeUnit.SECONDS)); + } finally { + service.close(); + } + + // 同一 JVM 使用相同 Scheduler Name 重建时覆盖已释放的内部 Provider 引用。 + CountDownLatch restartedLatch = new CountDownLatch(1); + QuartzScheduleService restarted = QuartzSchedulerFactory.createJdbc( + dataSource, + config, + List.of(handler(restartedLatch)), + List.of() + ); + try { + restarted.start(); + assertTrue(restarted.get(new ScheduleId("jdbc", "once")).isPresent()); + restarted.triggerNow( + new ScheduleId("jdbc", "once"), + "restart-trigger", + Map.of() + ); + assertTrue( + "rebuilt scheduler did not trigger persisted job", + restartedLatch.await(5, TimeUnit.SECONDS) + ); + } finally { + restarted.close(); + } + + // Provider 只释放内部强引用,调用方 DataSource 仍可继续使用。 + try ( + Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) FROM QRTZ_JOB_DETAILS") + ) { + assertTrue(resultSet.next()); + assertEquals(1, resultSet.getInt(1)); + } + } + + /** + * 验证缺少 Quartz 表时结构校验拒绝启动。 + */ + @Test + public void shouldRejectMissingSchema() { + try { + QuartzSchemaValidator.validate(dataSource(), "QRTZ_"); + fail("expected schema validation failure"); + } catch (ScheduleException exception) { + assertEquals(ScheduleErrorCode.SCHEMA_INVALID, exception.errorCode()); + } + } + + /** + * 验证关键列缺失时结构校验不会把不兼容结构误判为可用。 + */ + @Test + public void shouldRejectMissingCriticalColumn() throws Exception { + JdbcDataSource dataSource = dataSource(); + executeSchema(dataSource); + try ( + Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement() + ) { + statement.execute("ALTER TABLE QRTZ_CRON_TRIGGERS DROP COLUMN CRON_EXPRESSION"); + } + + try { + QuartzSchemaValidator.validate(dataSource, "QRTZ_"); + fail("expected critical column validation failure"); + } catch (ScheduleException exception) { + assertEquals(ScheduleErrorCode.SCHEMA_INVALID, exception.errorCode()); + assertTrue(exception.getMessage().contains("CRON_EXPRESSION")); + } + } + + /** + * 验证未限定前缀只校验当前 Schema,不会接受其他 Schema 的同名表。 + */ + @Test + public void shouldRejectQuartzTablesOutsideCurrentSchema() throws Exception { + JdbcDataSource dataSource = dataSource(); + try ( + Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement() + ) { + statement.execute("CREATE SCHEMA OTHER"); + statement.execute("SET SCHEMA OTHER"); + executeSchema(connection); + } + + try { + QuartzSchemaValidator.validate(dataSource, "QRTZ_"); + fail("expected current schema validation failure"); + } catch (ScheduleException exception) { + assertEquals(ScheduleErrorCode.SCHEMA_INVALID, exception.errorCode()); + } + QuartzSchemaValidator.validate(dataSource, "OTHER.QRTZ_"); + } + + /** + * 验证 JDBC JobStore 能原子删除仍在执行立即触发的任务。 + */ + @Test + public void shouldDeleteRunningScheduleAtomicallyInJdbcStore() throws Exception { + JdbcDataSource dataSource = dataSource(); + executeSchema(dataSource); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch completed = new CountDownLatch(1); + ScheduleHandler blockingHandler = new ScheduleHandler() { + @Override + public String code() { + return "delete-handler"; + } + + @Override + public void execute(ScheduleFireContext context) throws InterruptedException { + started.countDown(); + release.await(); + completed.countDown(); + } + }; + QuartzScheduleService service = QuartzSchedulerFactory.createJdbc( + dataSource, + jdbcConfig(), + List.of(blockingHandler), + List.of() + ); + ScheduleId scheduleId = new ScheduleId("jdbc", "delete-running"); + try { + service.start(); + service.create(new ScheduleDefinition( + scheduleId, + blockingHandler.code(), + new OnceSchedulePlan(Instant.parse("2099-01-01T00:00:00Z")), + MisfirePolicy.FIRE_ONCE_NOW, + ConcurrencyPolicy.DISALLOW, + true, + Map.of(), + "delete running" + )); + service.triggerNow(scheduleId, "delete-running-1", Map.of()); + assertTrue("JDBC handler did not start", started.await(5, TimeUnit.SECONDS)); + + assertTrue(service.delete(scheduleId)); + assertTrue(service.get(scheduleId).isEmpty()); + } finally { + release.countDown(); + completed.await(5, TimeUnit.SECONDS); + service.close(); + } + } + + private static JdbcDataSource dataSource() { + JdbcDataSource dataSource = new JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:scheduler-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); + dataSource.setUser("sa"); + dataSource.setPassword(""); + return dataSource; + } + + private static void executeSchema(JdbcDataSource dataSource) throws Exception { + try (Connection connection = dataSource.getConnection()) { + executeSchema(connection); + } + } + + /** + * 在指定连接的当前 Schema 中执行 H2 Quartz 建表资源。 + * + * @param connection 目标数据库连接 + * @throws Exception 资源读取或脚本执行失败时抛出 + */ + private static void executeSchema(Connection connection) throws Exception { + InputStream stream = QuartzJdbcIntegrationTest.class + .getClassLoader() + .getResourceAsStream("quartz-schema/h2-2.5.2.sql"); + if (stream == null) { + throw new IllegalStateException("H2 Quartz schema resource not found"); + } + try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { + RunScript.execute(connection, reader); + } + } + + private static QuartzSchedulerConfig jdbcConfig() { + return new QuartzSchedulerConfig( + "jdbc-scheduler-" + UUID.randomUUID(), + "NON_CLUSTERED", + "QRTZ_", + QuartzSchedulerConfig.STANDARD_JDBC_DELEGATE, + false, + 2, + Thread.NORM_PRIORITY, + 15_000L, + 1_000L, + true, + 30_000L, + true + ); + } + + private static ScheduleHandler handler(CountDownLatch latch) { + return new ScheduleHandler() { + @Override + public String code() { + return "jdbc-handler"; + } + + @Override + public void execute(ScheduleFireContext context) { + latch.countDown(); + } + }; + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/test/java/com/easyagents/scheduler/quartz/QuartzScheduleMapperTest.java b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/test/java/com/easyagents/scheduler/quartz/QuartzScheduleMapperTest.java new file mode 100644 index 0000000..2304637 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/test/java/com/easyagents/scheduler/quartz/QuartzScheduleMapperTest.java @@ -0,0 +1,94 @@ +package com.easyagents.scheduler.quartz; + +import com.easyagents.scheduler.ConcurrencyPolicy; +import com.easyagents.scheduler.MisfirePolicy; +import com.easyagents.scheduler.OnceSchedulePlan; +import com.easyagents.scheduler.ScheduleDefinition; +import com.easyagents.scheduler.ScheduleErrorCode; +import com.easyagents.scheduler.ScheduleException; +import com.easyagents.scheduler.ScheduleId; +import org.junit.Test; +import org.quartz.JobDataMap; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +/** + * {@link QuartzScheduleMapper} 持久字段严格性测试。 + */ +public class QuartzScheduleMapperTest { + + /** + * 验证立即触发数据保留 Misfire 监听所需的调度标识。 + */ + @Test + public void shouldCarryScheduleIdentityForImmediateTrigger() { + ScheduleId scheduleId = new ScheduleId("mapper", "immediate"); + JobDataMap data = QuartzScheduleMapper.immediateData( + scheduleId, + "invocation", + Map.of("key", "value") + ); + + assertEquals(scheduleId, QuartzScheduleMapper.toScheduleId(data)); + } + + /** + * 验证损坏的恢复布尔值不会静默解释为 false。 + */ + @Test + public void shouldRejectCorruptedBooleanValue() { + JobDataMap data = QuartzScheduleMapper.toJobDetail(definition()).getJobDataMap(); + data.put(QuartzScheduleMapper.KEY_RECOVER, "corrupted"); + + ScheduleException exception = assertThrows( + ScheduleException.class, + () -> QuartzScheduleMapper.toDefinition(data) + ); + assertEquals(ScheduleErrorCode.PROVIDER_FAILURE, exception.errorCode()); + } + + /** + * 验证持久参数损坏会归类为 Provider 数据错误。 + */ + @Test + public void shouldClassifyCorruptedPersistedParametersAsProviderFailure() { + JobDataMap data = QuartzScheduleMapper.toJobDetail(definition()).getJobDataMap(); + String encodedKey = Base64.getUrlEncoder().withoutPadding().encodeToString( + "payload".getBytes(StandardCharsets.UTF_8) + ); + data.put( + QuartzScheduleMapper.PARAMETER_PREFIX + encodedKey, + "x".repeat(ScheduleDefinition.MAX_PARAMETER_BYTES) + ); + + ScheduleException exception = assertThrows( + ScheduleException.class, + () -> QuartzScheduleMapper.toDefinition(data) + ); + assertEquals(ScheduleErrorCode.PROVIDER_FAILURE, exception.errorCode()); + } + + /** + * 创建映射测试定义。 + * + * @return 测试定义 + */ + private static ScheduleDefinition definition() { + return new ScheduleDefinition( + new ScheduleId("mapper", "strict"), + "handler", + new OnceSchedulePlan(Instant.parse("2099-01-01T00:00:00Z")), + MisfirePolicy.FIRE_ONCE_NOW, + ConcurrencyPolicy.DISALLOW, + true, + Map.of(), + "strict mapper" + ); + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-quartz/src/test/java/com/easyagents/scheduler/quartz/QuartzScheduleServiceTest.java b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/test/java/com/easyagents/scheduler/quartz/QuartzScheduleServiceTest.java new file mode 100644 index 0000000..238c0ed --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-quartz/src/test/java/com/easyagents/scheduler/quartz/QuartzScheduleServiceTest.java @@ -0,0 +1,471 @@ +package com.easyagents.scheduler.quartz; + +import com.easyagents.scheduler.ConcurrencyPolicy; +import com.easyagents.scheduler.CronSchedulePlan; +import com.easyagents.scheduler.MisfirePolicy; +import com.easyagents.scheduler.OnceSchedulePlan; +import com.easyagents.scheduler.ScheduleDefinition; +import com.easyagents.scheduler.ScheduleErrorCode; +import com.easyagents.scheduler.ScheduleException; +import com.easyagents.scheduler.ScheduleFireContext; +import com.easyagents.scheduler.ScheduleHandler; +import com.easyagents.scheduler.ScheduleId; +import com.easyagents.scheduler.ScheduleStatus; +import org.junit.After; +import org.junit.Test; +import org.quartz.JobDetail; +import org.quartz.Scheduler; +import org.quartz.impl.StdSchedulerFactory; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Base64; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * {@link QuartzScheduleService} 的无数据库行为测试。 + */ +public class QuartzScheduleServiceTest { + + private QuartzScheduleService service; + + /** + * 关闭每个测试创建的 Scheduler。 + */ + @After + public void tearDown() { + if (service != null) { + service.close(); + } + } + + /** + * 验证创建幂等、冲突检测、暂停恢复、原子替换和删除。 + */ + @Test + public void shouldManageScheduleLifecycle() throws Exception { + service = newRamService(context -> { }); + ScheduleDefinition original = cronDefinition("handler", "0 0 0 1 1 ? 2099"); + + assertEquals(original, service.create(original).definition()); + assertEquals(original, service.create(original).definition()); + + ScheduleDefinition conflicting = cronDefinition("handler", "0 0 0 2 1 ? 2099"); + try { + service.create(conflicting); + fail("expected schedule conflict"); + } catch (ScheduleException exception) { + assertEquals(ScheduleErrorCode.SCHEDULE_CONFLICT, exception.errorCode()); + } + + assertEquals(ScheduleStatus.PAUSED, service.pause(original.id()).status()); + assertEquals(ScheduleStatus.SCHEDULED, service.resume(original.id()).status()); + assertEquals(conflicting, service.replace(conflicting).definition()); + assertTrue(service.delete(original.id())); + assertFalse(service.delete(original.id())); + } + + /** + * 验证立即触发会覆盖同名基础参数并透传调用标识。 + */ + @Test + public void shouldDispatchImmediateTriggerWithOverrideParameters() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference captured = new AtomicReference<>(); + service = newRamService(context -> { + captured.set(context); + latch.countDown(); + }); + ScheduleDefinition definition = new ScheduleDefinition( + new ScheduleId("test", "immediate"), + "handler", + new OnceSchedulePlan(Instant.parse("2099-01-01T00:00:00Z")), + MisfirePolicy.FIRE_ONCE_NOW, + ConcurrencyPolicy.DISALLOW, + true, + Map.of("same", "base", "stable", "value"), + "immediate test" + ); + service.create(definition); + + service.triggerNow(definition.id(), "invoke-1", Map.of("same", "override")); + + assertTrue("handler did not execute", latch.await(5, TimeUnit.SECONDS)); + assertNotNull(captured.get()); + assertEquals("invoke-1", captured.get().invocationId()); + assertEquals("override", captured.get().parameters().get("same")); + assertEquals("value", captured.get().parameters().get("stable")); + } + + /** + * 验证立即触发会在返回回执前校验基础参数与覆盖参数的合并结果。 + */ + @Test + public void shouldRejectOversizedMergedImmediateParameters() throws Exception { + AtomicInteger executions = new AtomicInteger(); + service = newRamService(context -> executions.incrementAndGet()); + ScheduleDefinition definition = new ScheduleDefinition( + new ScheduleId("test", "oversized-immediate"), + "handler", + new OnceSchedulePlan(Instant.parse("2099-01-01T00:00:00Z")), + MisfirePolicy.FIRE_ONCE_NOW, + ConcurrencyPolicy.DISALLOW, + true, + Map.of("base", "x".repeat(9_000)), + "oversized immediate test" + ); + service.create(definition); + + try { + service.triggerNow( + definition.id(), + "oversized-invocation", + Map.of("override", "y".repeat(9_000)) + ); + fail("expected oversized merged parameters"); + } catch (ScheduleException exception) { + assertEquals(ScheduleErrorCode.INVALID_DEFINITION, exception.errorCode()); + } + assertEquals(0, executions.get()); + } + + /** + * 验证立即触发读取到损坏持久参数时返回 Provider 错误。 + */ + @Test + public void shouldClassifyCorruptedPersistedParametersBeforeImmediateTrigger() + throws Exception { + service = newRamService(context -> { }); + ScheduleDefinition definition = onceDefinition( + "corrupted-immediate", + MisfirePolicy.FIRE_ONCE_NOW, + ConcurrencyPolicy.DISALLOW, + Instant.parse("2099-01-01T00:00:00Z") + ); + service.create(definition); + JobDetail job = service.quartzScheduler().getJobDetail( + QuartzScheduleMapper.jobKey(definition.id()) + ); + String encodedKey = Base64.getUrlEncoder().withoutPadding().encodeToString( + "payload".getBytes(StandardCharsets.UTF_8) + ); + job.getJobDataMap().put( + QuartzScheduleMapper.PARAMETER_PREFIX + encodedKey, + "x".repeat(ScheduleDefinition.MAX_PARAMETER_BYTES) + ); + service.quartzScheduler().addJob(job, true); + + try { + service.triggerNow(definition.id(), "corrupted-invocation", Map.of()); + fail("expected corrupted persisted parameters"); + } catch (ScheduleException exception) { + assertEquals(ScheduleErrorCode.PROVIDER_FAILURE, exception.errorCode()); + } + } + + /** + * 验证预览不会落库且非法 Cron 返回稳定错误码。 + */ + @Test + public void shouldPreviewFireTimesWithoutPersisting() throws Exception { + service = newRamService(context -> { }); + + assertEquals( + 3, + service.nextFireTimes( + new CronSchedulePlan("0 0/5 * * * ?", ZoneId.of("Asia/Shanghai")), + 3 + ).size() + ); + try { + service.nextFireTimes(new CronSchedulePlan("bad cron", ZoneId.of("UTC")), 1); + fail("expected invalid cron"); + } catch (ScheduleException exception) { + assertEquals(ScheduleErrorCode.INVALID_DEFINITION, exception.errorCode()); + } + } + + /** + * 验证 DISALLOW 策略会串行执行同一 Schedule 的立即触发。 + */ + @Test + public void shouldDisallowConcurrentExecutionForSameSchedule() throws Exception { + CountDownLatch firstStarted = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch completed = new CountDownLatch(2); + AtomicInteger active = new AtomicInteger(); + AtomicInteger maximum = new AtomicInteger(); + service = newRamService(context -> { + int current = active.incrementAndGet(); + maximum.accumulateAndGet(current, Math::max); + firstStarted.countDown(); + await(release); + active.decrementAndGet(); + completed.countDown(); + }); + ScheduleDefinition definition = onceDefinition( + "serial", + MisfirePolicy.FIRE_ONCE_NOW, + ConcurrencyPolicy.DISALLOW, + Instant.parse("2099-01-01T00:00:00Z") + ); + service.create(definition); + + service.triggerNow(definition.id(), "serial-1", Map.of()); + assertTrue(firstStarted.await(5, TimeUnit.SECONDS)); + service.triggerNow(definition.id(), "serial-2", Map.of()); + Thread.sleep(250L); + assertEquals(1, maximum.get()); + release.countDown(); + assertTrue(completed.await(5, TimeUnit.SECONDS)); + assertEquals(1, maximum.get()); + } + + /** + * 验证 ALLOW 策略允许同一 Schedule 按 Worker 容量并发执行。 + */ + @Test + public void shouldAllowConcurrentExecutionForSameSchedule() throws Exception { + CountDownLatch bothStarted = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch completed = new CountDownLatch(2); + AtomicInteger active = new AtomicInteger(); + AtomicInteger maximum = new AtomicInteger(); + service = newRamService(context -> { + int current = active.incrementAndGet(); + maximum.accumulateAndGet(current, Math::max); + bothStarted.countDown(); + await(release); + active.decrementAndGet(); + completed.countDown(); + }); + ScheduleDefinition definition = onceDefinition( + "parallel", + MisfirePolicy.FIRE_ONCE_NOW, + ConcurrencyPolicy.ALLOW, + Instant.parse("2099-01-01T00:00:00Z") + ); + service.create(definition); + + service.triggerNow(definition.id(), "parallel-1", Map.of()); + service.triggerNow(definition.id(), "parallel-2", Map.of()); + assertTrue("both handlers did not start", bothStarted.await(5, TimeUnit.SECONDS)); + assertEquals(2, maximum.get()); + release.countDown(); + assertTrue(completed.await(5, TimeUnit.SECONDS)); + } + + /** + * 验证删除运行中任务不会与 Quartz 临时 Trigger 的完成清理发生竞态。 + */ + @Test + public void shouldDeleteScheduleWhileImmediateTriggerIsRunning() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch completed = new CountDownLatch(1); + service = newRamService(context -> { + started.countDown(); + await(release); + completed.countDown(); + }); + ScheduleDefinition definition = onceDefinition( + "delete-running", + MisfirePolicy.FIRE_ONCE_NOW, + ConcurrencyPolicy.DISALLOW, + Instant.parse("2099-01-01T00:00:00Z") + ); + service.create(definition); + service.triggerNow(definition.id(), "delete-running-1", Map.of()); + assertTrue(started.await(5, TimeUnit.SECONDS)); + + try { + assertTrue(service.delete(definition.id())); + assertTrue(service.get(definition.id()).isEmpty()); + } finally { + release.countDown(); + } + assertTrue(completed.await(5, TimeUnit.SECONDS)); + } + + /** + * 验证一次性任务的 SKIP 与 FIRE_ONCE_NOW Misfire 映射。 + */ + @Test + public void shouldApplyOnceMisfirePolicies() throws Exception { + CountDownLatch fired = new CountDownLatch(1); + AtomicInteger executions = new AtomicInteger(); + service = newRamService(context -> { + executions.incrementAndGet(); + if (context.scheduleId().name().equals("fire-misfire")) { + fired.countDown(); + } + }); + Instant missedAt = Instant.now().minusSeconds(2); + + service.create(onceDefinition( + "skip-misfire", + MisfirePolicy.SKIP, + ConcurrencyPolicy.DISALLOW, + missedAt + )); + service.create(onceDefinition( + "fire-misfire", + MisfirePolicy.FIRE_ONCE_NOW, + ConcurrencyPolicy.DISALLOW, + missedAt + )); + + assertTrue("FIRE_ONCE_NOW did not execute", fired.await(5, TimeUnit.SECONDS)); + Thread.sleep(250L); + assertEquals(1, executions.get()); + assertEquals( + ScheduleStatus.COMPLETE, + service.get(new ScheduleId("test", "skip-misfire")).orElseThrow().status() + ); + } + + /** + * 验证关闭等待超过上限后会有界返回并中断内部 Handler 线程。 + */ + @Test + public void shouldBoundShutdownWaitAndInterruptHandler() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch interrupted = new CountDownLatch(1); + service = newRamService(context -> { + started.countDown(); + try { + Thread.sleep(60_000L); + } catch (InterruptedException exception) { + interrupted.countDown(); + Thread.currentThread().interrupt(); + } + }, 200L); + ScheduleDefinition definition = onceDefinition( + "bounded-close", + MisfirePolicy.FIRE_ONCE_NOW, + ConcurrencyPolicy.DISALLOW, + Instant.parse("2099-01-01T00:00:00Z") + ); + service.create(definition); + service.triggerNow(definition.id(), "bounded-close-1", Map.of()); + assertTrue(started.await(5, TimeUnit.SECONDS)); + + long startedAt = System.nanoTime(); + try { + service.close(); + fail("expected bounded shutdown timeout"); + } catch (ScheduleException exception) { + assertEquals(ScheduleErrorCode.PROVIDER_FAILURE, exception.errorCode()); + } + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); + assertTrue("shutdown exceeded bound: " + elapsedMillis, elapsedMillis < 2_000L); + assertTrue("handler thread was not interrupted", interrupted.await(2, TimeUnit.SECONDS)); + } + + private QuartzScheduleService newRamService(Consumer handler) + throws Exception { + return newRamService(handler, 30_000L); + } + + private QuartzScheduleService newRamService( + Consumer handler, + long shutdownWaitTimeoutMillis + ) throws Exception { + Properties properties = new Properties(); + properties.setProperty( + "org.quartz.scheduler.instanceName", + "ram-scheduler-" + UUID.randomUUID() + ); + properties.setProperty("org.quartz.scheduler.instanceId", "NON_CLUSTERED"); + properties.setProperty("org.quartz.scheduler.interruptJobsOnShutdown", "true"); + properties.setProperty("org.quartz.threadPool.class", "org.quartz.simpl.SimpleThreadPool"); + properties.setProperty("org.quartz.threadPool.threadCount", "2"); + properties.setProperty("org.quartz.jobStore.class", "org.quartz.simpl.RAMJobStore"); + properties.setProperty("org.quartz.jobStore.misfireThreshold", "100"); + Scheduler scheduler = new StdSchedulerFactory(properties).getScheduler(); + QuartzScheduleService result = QuartzSchedulerFactory.attach( + scheduler, + true, + shutdownWaitTimeoutMillis, + java.util.List.of(new ScheduleHandler() { + @Override + public String code() { + return "handler"; + } + + @Override + public void execute(ScheduleFireContext context) { + handler.accept(context); + } + }), + java.util.List.of() + ); + result.start(); + assertSame(scheduler, result.quartzScheduler()); + return result; + } + + private static ScheduleDefinition cronDefinition(String handlerCode, String expression) { + return new ScheduleDefinition( + new ScheduleId("test", "lifecycle"), + handlerCode, + new CronSchedulePlan(expression, ZoneId.of("UTC")), + MisfirePolicy.SKIP, + ConcurrencyPolicy.DISALLOW, + true, + Map.of("key", "value"), + "lifecycle test" + ); + } + + private static ScheduleDefinition onceDefinition( + String name, + MisfirePolicy misfirePolicy, + ConcurrencyPolicy concurrencyPolicy, + Instant fireAt + ) { + return new ScheduleDefinition( + new ScheduleId("test", name), + "handler", + new OnceSchedulePlan(fireAt), + misfirePolicy, + concurrencyPolicy, + true, + Map.of(), + name + ); + } + + private static void await(CountDownLatch latch) { + boolean interrupted = false; + try { + while (true) { + try { + latch.await(); + return; + } catch (InterruptedException exception) { + interrupted = true; + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/pom.xml b/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/pom.xml new file mode 100644 index 0000000..2054a77 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + + com.easyagents + easy-agents-scheduler + ${revision} + + + easy-agents-scheduler-spring-boot-starter + easy-agents-scheduler-spring-boot-starter + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + com.easyagents + easy-agents-scheduler-core + + + com.easyagents + easy-agents-scheduler-quartz + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + org.springframework.boot + spring-boot-configuration-processor + true + + + com.h2database + h2 + test + + + junit + junit + test + + + diff --git a/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/src/main/java/com/easyagents/scheduler/spring/boot/EasyAgentsSchedulerAutoConfiguration.java b/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/src/main/java/com/easyagents/scheduler/spring/boot/EasyAgentsSchedulerAutoConfiguration.java new file mode 100644 index 0000000..c12eb80 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/src/main/java/com/easyagents/scheduler/spring/boot/EasyAgentsSchedulerAutoConfiguration.java @@ -0,0 +1,142 @@ +package com.easyagents.scheduler.spring.boot; + +import com.easyagents.scheduler.ScheduleErrorCode; +import com.easyagents.scheduler.ScheduleException; +import com.easyagents.scheduler.ScheduleExecutionListener; +import com.easyagents.scheduler.ScheduleHandler; +import com.easyagents.scheduler.ScheduleService; +import com.easyagents.scheduler.quartz.QuartzScheduleService; +import com.easyagents.scheduler.quartz.QuartzSchedulerConfig; +import com.easyagents.scheduler.quartz.QuartzSchedulerFactory; +import org.quartz.impl.StdSchedulerFactory; +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; + +/** + * Easy Agents 独立调度器自动配置。 + */ +@Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(DataSourceAutoConfiguration.class) +@ConditionalOnClass({ScheduleService.class, StdSchedulerFactory.class, DataSource.class}) +@ConditionalOnProperty( + prefix = "easy-agents.scheduler", + name = "enabled", + havingValue = "true" +) +@EnableConfigurationProperties(EasyAgentsSchedulerProperties.class) +public class EasyAgentsSchedulerAutoConfiguration { + + /** Easy Agents Quartz Scheduler 的明确 Bean 名称。 */ + public static final String SCHEDULER_BEAN_NAME = "easyAgentsQuartzScheduleService"; + + /** + * 创建调度器自动配置。 + */ + public EasyAgentsSchedulerAutoConfiguration() { + } + + /** + * 创建与调用方其他 Quartz Scheduler 隔离的调度服务。 + * + * @param properties 调度器配置 + * @param beanFactory 用于明确选择 DataSource + * @param handlers 调度 Handler Bean + * @param listeners 调度监听器 Bean + * @return 尚未启动、由 Spring 生命周期启动的调度服务 + * @throws ScheduleException DataSource 选择或 Provider 初始化失败时抛出 + */ + @Bean(name = SCHEDULER_BEAN_NAME, initMethod = "start", destroyMethod = "close") + @ConditionalOnMissingBean(ScheduleService.class) + public QuartzScheduleService easyAgentsQuartzScheduleService( + EasyAgentsSchedulerProperties properties, + ListableBeanFactory beanFactory, + ObjectProvider handlers, + ObjectProvider listeners + ) { + DataSource dataSource = selectDataSource( + beanFactory, + properties.getDataSourceBeanName() + ); + EasyAgentsSchedulerProperties.Quartz quartz = properties.getQuartz(); + if (quartz == null) { + throw providerFailure("easy-agents.scheduler.quartz must not be null"); + } + QuartzSchedulerConfig config = new QuartzSchedulerConfig( + quartz.getSchedulerName(), + quartz.getInstanceId(), + quartz.getTablePrefix(), + quartz.getDriverDelegateClass(), + quartz.isClustered(), + quartz.getThreadCount(), + quartz.getThreadPriority(), + quartz.getClusterCheckinIntervalMillis(), + quartz.getMisfireThresholdMillis(), + quartz.isWaitForJobsToCompleteOnShutdown(), + quartz.getShutdownWaitTimeoutMillis(), + quartz.isValidateSchema() + ); + return QuartzSchedulerFactory.createJdbc( + dataSource, + config, + handlers.orderedStream().toList(), + listeners.orderedStream().toList() + ); + } + + private static DataSource selectDataSource( + ListableBeanFactory beanFactory, + String requestedBeanName + ) { + if (requestedBeanName != null && !requestedBeanName.isBlank()) { + String normalizedName = requestedBeanName.trim(); + try { + return beanFactory.getBean(normalizedName, DataSource.class); + } catch (RuntimeException exception) { + throw new ScheduleException( + ScheduleErrorCode.PROVIDER_FAILURE, + "configured scheduler DataSource bean is unavailable: " + normalizedName, + exception + ); + } + } + Map candidates = BeanFactoryUtils.beansOfTypeIncludingAncestors( + beanFactory, + DataSource.class, + true, + false + ); + Collection uniqueCandidates = candidates.values().stream().distinct().toList(); + if (uniqueCandidates.size() == 1) { + return uniqueCandidates.iterator().next(); + } + String names = Arrays.toString(candidates.keySet().toArray(String[]::new)); + if (uniqueCandidates.isEmpty()) { + throw providerFailure( + "scheduler is enabled but no DataSource bean exists; configure " + + "easy-agents.scheduler.data-source-bean-name or provide one DataSource" + ); + } + throw providerFailure( + "multiple DataSource beans found " + names + "; configure " + + "easy-agents.scheduler.data-source-bean-name" + ); + } + + private static ScheduleException providerFailure(String message) { + return new ScheduleException(ScheduleErrorCode.PROVIDER_FAILURE, message); + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/src/main/java/com/easyagents/scheduler/spring/boot/EasyAgentsSchedulerProperties.java b/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/src/main/java/com/easyagents/scheduler/spring/boot/EasyAgentsSchedulerProperties.java new file mode 100644 index 0000000..0d1d055 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/src/main/java/com/easyagents/scheduler/spring/boot/EasyAgentsSchedulerProperties.java @@ -0,0 +1,346 @@ +package com.easyagents.scheduler.spring.boot; + +import com.easyagents.scheduler.quartz.QuartzSchedulerConfig; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Easy Agents 嵌入式调度器配置。 + */ +@ConfigurationProperties(prefix = "easy-agents.scheduler") +public class EasyAgentsSchedulerProperties { + + /** 是否显式启用调度器。 */ + private boolean enabled; + + /** 多 DataSource 场景下选用的 Bean 名称。 */ + private String dataSourceBeanName; + + /** Quartz Provider 配置。 */ + private Quartz quartz = new Quartz(); + + /** + * 创建默认调度器配置。 + */ + public EasyAgentsSchedulerProperties() { + } + + /** + * 返回是否启用调度器。 + * + * @return 启用状态 + */ + public boolean isEnabled() { + return enabled; + } + + /** + * 设置是否启用调度器。 + * + * @param enabled 启用状态 + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + /** + * 返回指定 DataSource Bean 名称。 + * + * @return Bean 名称,可为空 + */ + public String getDataSourceBeanName() { + return dataSourceBeanName; + } + + /** + * 设置指定 DataSource Bean 名称。 + * + * @param dataSourceBeanName Bean 名称 + */ + public void setDataSourceBeanName(String dataSourceBeanName) { + this.dataSourceBeanName = dataSourceBeanName; + } + + /** + * 返回 Quartz Provider 配置。 + * + * @return Quartz 配置 + */ + public Quartz getQuartz() { + return quartz; + } + + /** + * 设置 Quartz Provider 配置。 + * + * @param quartz Quartz 配置 + */ + public void setQuartz(Quartz quartz) { + this.quartz = quartz; + } + + /** + * Quartz Provider 属性集合。 + */ + public static class Quartz { + + /** Scheduler Name,同一集群保持一致。 */ + private String schedulerName = "easyAgentsScheduler"; + + /** 当前实例标识。 */ + private String instanceId = "AUTO"; + + /** Quartz 表前缀,可包含 Schema。 */ + private String tablePrefix = "QRTZ_"; + + /** Quartz JDBC DriverDelegate 类名。 */ + private String driverDelegateClass = QuartzSchedulerConfig.STANDARD_JDBC_DELEGATE; + + /** 是否启用 JDBC 集群。 */ + private boolean clustered = true; + + /** Quartz Worker 线程数。 */ + private int threadCount = 8; + + /** Quartz Worker 线程优先级。 */ + private int threadPriority = Thread.NORM_PRIORITY; + + /** 集群心跳间隔,单位毫秒。 */ + private long clusterCheckinIntervalMillis = 15_000L; + + /** Misfire 判定阈值,单位毫秒。 */ + private long misfireThresholdMillis = 60_000L; + + /** 关闭时是否等待运行中任务完成。 */ + private boolean waitForJobsToCompleteOnShutdown = true; + + /** 关闭等待运行中任务的最长时间,单位毫秒。 */ + private long shutdownWaitTimeoutMillis = 30_000L; + + /** 启动前是否只读校验 Quartz 表结构。 */ + private boolean validateSchema = true; + + /** + * 创建默认 Quartz Provider 配置。 + */ + public Quartz() { + } + + /** + * 返回 Scheduler Name。 + * + * @return Scheduler Name + */ + public String getSchedulerName() { + return schedulerName; + } + + /** + * 设置 Scheduler Name。 + * + * @param schedulerName Scheduler Name + */ + public void setSchedulerName(String schedulerName) { + this.schedulerName = schedulerName; + } + + /** + * 返回实例标识。 + * + * @return 实例标识 + */ + public String getInstanceId() { + return instanceId; + } + + /** + * 设置实例标识。 + * + * @param instanceId 实例标识 + */ + public void setInstanceId(String instanceId) { + this.instanceId = instanceId; + } + + /** + * 返回表前缀。 + * + * @return 表前缀 + */ + public String getTablePrefix() { + return tablePrefix; + } + + /** + * 设置表前缀。 + * + * @param tablePrefix 表前缀 + */ + public void setTablePrefix(String tablePrefix) { + this.tablePrefix = tablePrefix; + } + + /** + * 返回 JDBC Delegate 类名。 + * + * @return Delegate 类名 + */ + public String getDriverDelegateClass() { + return driverDelegateClass; + } + + /** + * 设置 JDBC Delegate 类名。 + * + * @param driverDelegateClass Delegate 类名 + */ + public void setDriverDelegateClass(String driverDelegateClass) { + this.driverDelegateClass = driverDelegateClass; + } + + /** + * 返回是否启用集群。 + * + * @return 集群状态 + */ + public boolean isClustered() { + return clustered; + } + + /** + * 设置是否启用集群。 + * + * @param clustered 集群状态 + */ + public void setClustered(boolean clustered) { + this.clustered = clustered; + } + + /** + * 返回 Worker 线程数。 + * + * @return 线程数 + */ + public int getThreadCount() { + return threadCount; + } + + /** + * 设置 Worker 线程数。 + * + * @param threadCount 线程数 + */ + public void setThreadCount(int threadCount) { + this.threadCount = threadCount; + } + + /** + * 返回 Worker 线程优先级。 + * + * @return 线程优先级 + */ + public int getThreadPriority() { + return threadPriority; + } + + /** + * 设置 Worker 线程优先级。 + * + * @param threadPriority 线程优先级 + */ + public void setThreadPriority(int threadPriority) { + this.threadPriority = threadPriority; + } + + /** + * 返回集群心跳间隔。 + * + * @return 毫秒间隔 + */ + public long getClusterCheckinIntervalMillis() { + return clusterCheckinIntervalMillis; + } + + /** + * 设置集群心跳间隔。 + * + * @param clusterCheckinIntervalMillis 毫秒间隔 + */ + public void setClusterCheckinIntervalMillis(long clusterCheckinIntervalMillis) { + this.clusterCheckinIntervalMillis = clusterCheckinIntervalMillis; + } + + /** + * 返回 Misfire 判定阈值。 + * + * @return 毫秒阈值 + */ + public long getMisfireThresholdMillis() { + return misfireThresholdMillis; + } + + /** + * 设置 Misfire 判定阈值。 + * + * @param misfireThresholdMillis 毫秒阈值 + */ + public void setMisfireThresholdMillis(long misfireThresholdMillis) { + this.misfireThresholdMillis = misfireThresholdMillis; + } + + /** + * 返回关闭时是否等待在途任务。 + * + * @return 等待状态 + */ + public boolean isWaitForJobsToCompleteOnShutdown() { + return waitForJobsToCompleteOnShutdown; + } + + /** + * 设置关闭时是否等待在途任务。 + * + * @param waitForJobsToCompleteOnShutdown 等待状态 + */ + public void setWaitForJobsToCompleteOnShutdown( + boolean waitForJobsToCompleteOnShutdown + ) { + this.waitForJobsToCompleteOnShutdown = waitForJobsToCompleteOnShutdown; + } + + /** + * 返回关闭等待的最长时间。 + * + * @return 毫秒数 + */ + public long getShutdownWaitTimeoutMillis() { + return shutdownWaitTimeoutMillis; + } + + /** + * 设置关闭等待的最长时间。 + * + * @param shutdownWaitTimeoutMillis 毫秒数 + */ + public void setShutdownWaitTimeoutMillis(long shutdownWaitTimeoutMillis) { + this.shutdownWaitTimeoutMillis = shutdownWaitTimeoutMillis; + } + + /** + * 返回是否校验数据库结构。 + * + * @return 校验状态 + */ + public boolean isValidateSchema() { + return validateSchema; + } + + /** + * 设置是否校验数据库结构。 + * + * @param validateSchema 校验状态 + */ + public void setValidateSchema(boolean validateSchema) { + this.validateSchema = validateSchema; + } + } +} diff --git a/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/src/main/resources/META-INF/spring.factories b/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..22eb87b --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ + com.easyagents.scheduler.spring.boot.EasyAgentsSchedulerAutoConfiguration diff --git a/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..3eea305 --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.easyagents.scheduler.spring.boot.EasyAgentsSchedulerAutoConfiguration diff --git a/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/src/test/java/com/easyagents/scheduler/spring/boot/EasyAgentsSchedulerAutoConfigurationTest.java b/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/src/test/java/com/easyagents/scheduler/spring/boot/EasyAgentsSchedulerAutoConfigurationTest.java new file mode 100644 index 0000000..debcfef --- /dev/null +++ b/easy-agents-scheduler/easy-agents-scheduler-spring-boot-starter/src/test/java/com/easyagents/scheduler/spring/boot/EasyAgentsSchedulerAutoConfigurationTest.java @@ -0,0 +1,237 @@ +package com.easyagents.scheduler.spring.boot; + +import com.easyagents.scheduler.ConcurrencyPolicy; +import com.easyagents.scheduler.MisfirePolicy; +import com.easyagents.scheduler.OnceSchedulePlan; +import com.easyagents.scheduler.ScheduleDefinition; +import com.easyagents.scheduler.ScheduleException; +import com.easyagents.scheduler.ScheduleFireContext; +import com.easyagents.scheduler.ScheduleHandler; +import com.easyagents.scheduler.ScheduleId; +import com.easyagents.scheduler.ScheduleService; +import org.h2.jdbcx.JdbcDataSource; +import org.h2.tools.RunScript; +import org.junit.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.env.MapPropertySource; + +import javax.sql.DataSource; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * {@link EasyAgentsSchedulerAutoConfiguration} 的 Spring Boot 2.7/3.5 装配测试。 + */ +public class EasyAgentsSchedulerAutoConfigurationTest { + + /** + * 验证默认关闭时不创建调度器,也不要求 DataSource。 + */ + @Test + public void shouldStayDisabledByDefault() { + try (AnnotationConfigApplicationContext context = newContext(Map.of())) { + assertFalse(context.containsBean( + EasyAgentsSchedulerAutoConfiguration.SCHEDULER_BEAN_NAME + )); + } + } + + /** + * 验证显式启用后完成属性绑定、Handler 收集、启动和关闭。 + */ + @Test + public void shouldConfigureAndStartScheduler() throws Exception { + JdbcDataSource dataSource = dataSource(); + executeSchema(dataSource); + CountDownLatch latch = new CountDownLatch(1); + Map properties = enabledProperties(); + properties.put("easy-agents.scheduler.data-source-bean-name", "schedulerDataSource"); + + SpringApplication application = new SpringApplication(AutoDiscoveryApplication.class); + application.setWebApplicationType(WebApplicationType.NONE); + application.setDefaultProperties(properties); + application.addInitializers(applicationContext -> { + GenericApplicationContext genericContext = + (GenericApplicationContext) applicationContext; + genericContext.registerBean( + "schedulerDataSource", + DataSource.class, + () -> dataSource + ); + genericContext.registerBean( + "unrelatedDataSource", + DataSource.class, + () -> dataSource() + ); + genericContext.registerBean( + "testScheduleHandler", + ScheduleHandler.class, + () -> handler(latch) + ); + }); + ConfigurableApplicationContext context = application.run(); + try { + ScheduleService service = context.getBean(ScheduleService.class); + assertNotNull(service); + ScheduleDefinition definition = definition(); + service.create(definition); + service.triggerNow(definition.id(), "starter-invocation", Map.of()); + assertTrue("starter handler did not execute", latch.await(5, TimeUnit.SECONDS)); + } finally { + context.close(); + } + + // Spring 销毁 Scheduler 后,调用方持有的 DataSource 仍然可用。 + try ( + Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) FROM QRTZ_JOB_DETAILS") + ) { + assertTrue(resultSet.next()); + assertEquals(1, resultSet.getInt(1)); + } + } + + /** + * 验证多个 DataSource 未明确选择时启动失败并提供可操作信息。 + */ + @Test + public void shouldRejectAmbiguousDataSources() { + AnnotationConfigApplicationContext context = context(enabledProperties()); + context.registerBean("firstDataSource", DataSource.class, () -> dataSource()); + context.registerBean("secondDataSource", DataSource.class, () -> dataSource()); + context.register(EasyAgentsSchedulerAutoConfiguration.class); + try { + context.refresh(); + fail("expected ambiguous DataSource failure"); + } catch (RuntimeException exception) { + ScheduleException scheduleException = findCause(exception, ScheduleException.class); + assertNotNull(scheduleException); + assertTrue(scheduleException.getMessage().contains("data-source-bean-name")); + } finally { + context.close(); + } + } + + private static AnnotationConfigApplicationContext newContext( + Map properties + ) { + AnnotationConfigApplicationContext context = context(properties); + context.register(EasyAgentsSchedulerAutoConfiguration.class); + context.refresh(); + return context; + } + + private static AnnotationConfigApplicationContext context(Map properties) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.getEnvironment().getPropertySources().addFirst( + new MapPropertySource("scheduler-test", properties) + ); + return context; + } + + private static Map enabledProperties() { + Map properties = new HashMap<>(); + properties.put("easy-agents.scheduler.enabled", "true"); + properties.put( + "easy-agents.scheduler.quartz.scheduler-name", + "starter-scheduler-" + UUID.randomUUID() + ); + properties.put("easy-agents.scheduler.quartz.instance-id", "NON_CLUSTERED"); + properties.put("easy-agents.scheduler.quartz.clustered", "false"); + properties.put("easy-agents.scheduler.quartz.thread-count", "2"); + properties.put("easy-agents.scheduler.quartz.misfire-threshold-millis", "1000"); + return properties; + } + + private static JdbcDataSource dataSource() { + JdbcDataSource dataSource = new JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:starter-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); + dataSource.setUser("sa"); + dataSource.setPassword(""); + return dataSource; + } + + private static void executeSchema(JdbcDataSource dataSource) throws Exception { + InputStream stream = EasyAgentsSchedulerAutoConfigurationTest.class + .getClassLoader() + .getResourceAsStream("quartz-schema/h2-2.5.2.sql"); + if (stream == null) { + throw new IllegalStateException("H2 Quartz schema resource not found"); + } + try ( + Connection connection = dataSource.getConnection(); + InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8) + ) { + RunScript.execute(connection, reader); + } + } + + private static ScheduleHandler handler(CountDownLatch latch) { + return new ScheduleHandler() { + @Override + public String code() { + return "starter-handler"; + } + + @Override + public void execute(ScheduleFireContext context) { + latch.countDown(); + } + }; + } + + private static ScheduleDefinition definition() { + return new ScheduleDefinition( + new ScheduleId("starter", "trigger"), + "starter-handler", + new OnceSchedulePlan(Instant.parse("2099-01-01T00:00:00Z")), + MisfirePolicy.FIRE_ONCE_NOW, + ConcurrencyPolicy.DISALLOW, + true, + Map.of("source", "starter"), + "starter test" + ); + } + + private static T findCause(Throwable failure, Class type) { + Throwable current = failure; + while (current != null) { + if (type.isInstance(current)) { + return type.cast(current); + } + current = current.getCause(); + } + return null; + } + + /** + * 只启用 Spring Boot 自动配置发现的测试应用。 + */ + @Configuration(proxyBeanMethods = false) + @EnableAutoConfiguration + static class AutoDiscoveryApplication { + } +} diff --git a/easy-agents-scheduler/pom.xml b/easy-agents-scheduler/pom.xml new file mode 100644 index 0000000..7f4bd66 --- /dev/null +++ b/easy-agents-scheduler/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + + + com.easyagents + easy-agents + ${revision} + + + easy-agents-scheduler + pom + easy-agents-scheduler + + + easy-agents-scheduler-core + easy-agents-scheduler-quartz + easy-agents-scheduler-spring-boot-starter + + diff --git a/pom.xml b/pom.xml index 5d77001..2412653 100644 --- a/pom.xml +++ b/pom.xml @@ -32,6 +32,7 @@ easy-agents-agui easy-agents-flow easy-agents-federation-sql + easy-agents-scheduler easy-agents-support @@ -52,6 +53,7 @@ 1.28.0 1.42.0 2.3.232 + 2.5.2
@@ -144,6 +146,11 @@ ${h2.version} + + org.quartz-scheduler + quartz + ${quartz.version} + @@ -230,8 +237,23 @@ ${revision} + + com.easyagents + easy-agents-scheduler-core + ${revision} + + + com.easyagents + easy-agents-scheduler-quartz + ${revision} + + + com.easyagents + easy-agents-scheduler-spring-boot-starter + ${revision} +