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

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

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

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

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-scheduler</artifactId>
<version>${revision}</version>
</parent>
<artifactId>easy-agents-scheduler-spring-boot-starter</artifactId>
<name>easy-agents-scheduler-spring-boot-starter</name>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-scheduler-core</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-scheduler-quartz</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -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<ScheduleHandler> handlers,
ObjectProvider<ScheduleExecutionListener> 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<String, DataSource> candidates = BeanFactoryUtils.beansOfTypeIncludingAncestors(
beanFactory,
DataSource.class,
true,
false
);
Collection<DataSource> 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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.easyagents.scheduler.spring.boot.EasyAgentsSchedulerAutoConfiguration

View File

@@ -0,0 +1 @@
com.easyagents.scheduler.spring.boot.EasyAgentsSchedulerAutoConfiguration

View File

@@ -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<String, Object> 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<String, Object> properties
) {
AnnotationConfigApplicationContext context = context(properties);
context.register(EasyAgentsSchedulerAutoConfiguration.class);
context.refresh();
return context;
}
private static AnnotationConfigApplicationContext context(Map<String, Object> properties) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
context.getEnvironment().getPropertySources().addFirst(
new MapPropertySource("scheduler-test", properties)
);
return context;
}
private static Map<String, Object> enabledProperties() {
Map<String, Object> 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 extends Throwable> T findCause(Throwable failure, Class<T> 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 {
}
}