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 extends ScheduleHandler> handlers,
+ Collection extends ScheduleExecutionListener> 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 extends ScheduleHandler> handlers,
+ Collection extends ScheduleExecutionListener> 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 extends ScheduleHandler> handlers,
+ Collection extends ScheduleExecutionListener> 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