基于 Spring Boot 的轻量化动态调度线程池,支持运行时动态添加、移除、替换定时任务。
| Files | Last commit | Last update |
|---|---|---|
| 1 month ago | ||
| 7 days ago | ||
| 6 days ago | ||
| 7 days ago | ||
| 6 days ago | ||
| 6 days ago | ||
| 7 days ago | ||
| 5 days ago | ||
| 3 years ago | ||
| 3 days ago | ||
| 3 days ago | ||
| 1 month ago |
FlexSchedule(灵动调度)Spring Boot Starter
A lightweight flex scheduling thread pool for Spring Boot — add, remove, and replace scheduled tasks at runtime.
Features
- Flex task management — Add, cancel, replace, pause, and resume tasks at runtime
- 3 scheduling modes — Cron, FixedDelay, FixedRate
- Timezone support — Cron tasks with custom timezone (
addCronTask(name, cron, ZoneId, runnable)) - One-shot tasks — Delayed single-execution with auto-removal
- Retry on failure — Fixed or exponential backoff with configurable max delay
- Misfire handling — Opt-in late-fire detection and pause-gap catch-up per task via
MisfirePolicy(SKIP / FIRE_ONCE / FIRE_IMMEDIATELY) - Execution timeout — Automatically interrupt tasks that exceed their time limit
- Task dependency chains — Sequential task execution with
TaskChainbuilder - Execution history — In-memory ring buffer of execution records per task
- Cluster-aware scheduling —
DistributedLockinterface for multi-node coordination (ShedLock/Redis/ZooKeeper) - Thread-safe —
ConcurrentHashMapwith atomic operations - Auto-configuration — Zero-config with starter dependency
- Graceful shutdown — Configurable wait for in-flight tasks
- Lifecycle listeners — Before / after / error callbacks
- Micrometer metrics — Execution counters, timers, gauges (optional)
- Health indicator — Reports scheduler health to
/actuator/health(optional) - Actuator endpoint — REST CRUD at
/actuator/flexschedulewith access control (optional) - Reflective scheduling —
BeanMethodRunnablewith AOP proxy support
Installation
Maven
<dependency>
<groupId>io.github.wb04307201</groupId>
<artifactId>flex-schedule-spring-boot-starter</artifactId>
<version>1.3.0</version>
</dependency>
Gradle
implementation 'io.github.wb04307201:flex-schedule-spring-boot-starter:1.2.2'
Quick Start
@Service
public class DemoService {
@Autowired
private FlexScheduledTaskService taskService;
public void demo() {
// Cron task
taskService.add("cronJob", "0/5 * * * * ?", () -> doWork());
// Fixed delay (seconds)
taskService.addFixedDelayTask("delayJob", 10, 0, () -> doWork());
// Fixed rate (Duration precision)
taskService.addFixedRateTask("rateJob", Duration.ofSeconds(5), Duration.ZERO, () -> doWork());
// One-shot delayed task (auto-removes after execution)
taskService.schedule("notify", Duration.ofMinutes(5), () -> sendEmail());
// Replace existing task or add new
taskService.replaceCronTask("cronJob", "0/10 * * * * ?", () -> doNewWork());
// Cancel
taskService.cancel("cronJob");
// Query
taskService.exists("rateJob"); // true
taskService.listTasks(); // List<TaskInfo>
taskService.getTaskDetail("rateJob"); // Optional<TaskDetail>
}
}
Configuration
flex:
schedule:
enabled: true # Enable auto-configuration (default: true)
pool-size: 16 # Thread pool size
thread-name-prefix: "FlexScheduleThreadPool-" # Thread name prefix
remove-on-cancel: true # Remove cancelled tasks from pool
await-termination-seconds: 30 # Graceful shutdown timeout (seconds)
API Reference
Core Operations
| Method | Description |
|---|---|
task(name) |
Create a fluent TaskBuilder |
add(name, cron, runnable [, retryPolicy]) |
Register a Cron task |
add(name, cron, ZoneId, runnable) |
Register a Cron task with timezone |
add(name, cron, ZoneId, retryPolicy, runnable) |
Register a Cron task with timezone and retry policy |
add(name, cron, timeout, runnable) |
Register a Cron task with execution timeout |
addFixedDelayTask(name, interval, initialDelay, runnable [, retryPolicy]) |
Register a fixed-delay task |
addFixedRateTask(name, interval, initialDelay, runnable [, retryPolicy]) |
Register a fixed-rate task |
schedule(name, delay, runnable) |
Schedule a one-shot delayed task |
cancel(name) |
Cancel a task |
pause(name) |
Pause a task (skip execution until resumed) |
resume(name) |
Resume a paused task |
isPaused(name) |
Check if a task is paused |
setCreatedAt(name, instant) |
Override a task's logical createdAt. Useful for persistence-aware consumers that rehydrate tasks at startup with the original creation instant so the max-lifetime ceiling keeps working across restarts. No-op if the task isn't registered or instant is null. |
exists(name) |
Check if a task exists |
listTasks() |
List all tasks (List<TaskInfo>) |
getTaskDetail(name) |
Get task detail (Optional<TaskDetail>) |
replaceCronTask / replaceFixedDelayTask / replaceFixedRateTask(...) |
Replace or add a task |
addListener / removeListener(listener) |
Manage lifecycle listeners |
setExecutionHistory(history) |
Replace the execution-history backend |
getExecutionHistory(name, limit) |
Get execution history for a task |
getAllExecutionHistory(limit) |
Get all execution history |
clearExecutionHistory(name) |
Clear one task's history; pass null to clear all history |
getTaskStatistics(name) |
Get aggregated execution statistics for one task |
getAllTaskStatistics() |
Get aggregated execution statistics for all tasks |
setDistributedLock(lock) |
Set cluster-aware distributed lock |
interval/initialDelay/delayacceptlong(seconds) orDuration(any precision)retryPolicyis optional; see Retry Policy
TaskInfo & TaskDetail
record TaskInfo(String taskName, String taskType, String schedule) {}
// taskType: CRON | FIXED_DELAY | FIXED_RATE | ONE_SHOT
record TaskDetail(String taskName, String taskType, String schedule,
boolean oneShot, RetryPolicy retryPolicy, Instant createdAt,
boolean paused, MisfirePolicy misfirePolicy,
Duration misfireThreshold) {}
// misfirePolicy / misfireThreshold: null when misfire handling is not configured
Fluent Task Builder
taskService.task("cleanup")
.fixedDelay(Duration.ofMinutes(10))
.timeout(Duration.ofSeconds(30))
.retry(RetryPolicy.fixed(3, Duration.ofSeconds(2)))
.register(this::cleanup);
Choose exactly one scheduling type with cron, fixedDelay, fixedRate, or oneShot. Options such as timezone, timeout, retry, createdAt, misfirePolicy, and misfireThreshold can be composed where applicable.
Advanced Usage
Task Execution Listeners
@Component
public class MyListener implements TaskExecutionListener {
@Override public void beforeExecution(String name) { /* ... */ }
@Override public void afterExecution(String name) { /* ... */ }
@Override public void onError(String name, Throwable error) { /* ... */ }
}
// Register
taskService.addListener(myListener);
taskService.removeListener(myListener);
All methods have default no-op implementations. Listener exceptions are caught and logged.
Retry Policy
// Fixed: retry 3 times, 5s between attempts
RetryPolicy fixed = RetryPolicy.fixed(3, Duration.ofSeconds(5));
taskService.add("task", "0 * * * * ?", runnable, fixed);
// Exponential backoff: delay doubles each attempt (1s → 2s → 4s)
RetryPolicy exponential = RetryPolicy.exponential(3, Duration.ofSeconds(1));
taskService.addFixedDelayTask("task", Duration.ofMinutes(1), Duration.ZERO, runnable, exponential);
| Backoff | Delay for attempt N |
|---|---|
FIXED |
delay |
EXPONENTIAL |
delay × 2^(N-1) |
Each retry triggers the full listener lifecycle. If all retries fail, the exception propagates.
Reflective Bean Method Scheduling
// Invoke bean method by name
Runnable runnable = new BeanMethodRunnable("myService", "doWork");
taskService.add("task", "0 0/1 * * * ?", runnable);
// With parameters
Runnable withParams = new BeanMethodRunnable("myService", "process", List.of("hello", 42));
taskService.add("task", "0 0/5 * * * ?", withParams);
Supports automatic primitive-to-wrapper type matching (Integer → int). Also supports AOP-proxied beans (CGLIB/JDK dynamic proxies).
Pause / Resume
// Pause a running task (skips execution but keeps it registered)
taskService.pause("myTask");
// Check if paused
taskService.isPaused("myTask"); // true
// Resume
taskService.resume("myTask");
Misfire Handling (Late Fires & Pause-Gap Catch-Up)
Recurring tasks can opt in to misfire handling, which covers two situations:
a fire that starts more than misfireThreshold later than expected (thread-pool
starvation, GC pause, system overload), and firings missed while the task was paused.
taskService.task("report")
.cron("0 0 * * * *")
.misfirePolicy(MisfirePolicy.FIRE_ONCE) // opt in
.misfireThreshold(Duration.ofSeconds(30)) // lateness tolerance
.register(this::generateReport);
// Or configure an already-registered task at runtime:
taskService.setMisfireConfig("report", MisfirePolicy.SKIP, Duration.ofSeconds(10));
| Policy | Late fire (> threshold) | Catch-up on resume() |
|---|---|---|
SKIP |
Skipped — recorded as MISFIRE_SKIPPED in history + recordMisfire metric |
None |
FIRE_ONCE |
Executes normally (logged + counted) | One execution — only if the pause gap actually missed a firing |
FIRE_IMMEDIATELY |
Executes normally (logged + counted) | min(missed, 10) executions |
Key semantics:
- Opt-in: a task that never receives a misfire configuration keeps the classic
behavior — no late-fire detection, no catch-up. This includes tasks restored from
1.2.x-persisted
TaskDefinitions (both misfire fieldsnull). Setting only one of policy/threshold fills the other from the documented defaults (FIRE_ONCE/ 30s). - Delegate runtime is never lateness: the expected-fire baseline follows how Spring
actually schedules the next fire — completion-based for
FIXED_DELAYandCRON, grid-based forFIXED_RATE. - Catch-up runs serially through the full instrumented pipeline (distributed lock, listeners, history, metrics) and never overlaps a regular fire of the same task. Re-pausing during a burst aborts the remaining executions.
- Restore after restart: persisted misfire settings are re-applied by
restoreTasks(), but the downtime gap itself is never compensated — the first fire after restart re-seeds the baseline. - One-shot tasks ignore the misfire policy entirely.
GET /actuator/flexschedule/{name}reports the configuredmisfirePolicy/misfireThreshold(nullwhen the task is unconfigured).
Persistence
flex-schedule ships no persistence default — the scheduler core is pure in-memory
(InMemoryTaskRepository). When tasks need to survive restarts, consumers provide their
own TaskRepository bean.
Two trigger shapes, two ways to persist:
-
Bean-method triggers (you already have
TaskDefinition):- Persist
TaskDefinition(includingbeanName/methodName/createdAt, and optionallymisfirePolicy/misfireThreshold) to your store. Keeping both misfire fieldsnullrestores the task exempt from misfire handling (pre-1.3.0 behavior); persisted values are re-applied byrestoreTasks(). - On startup, call
FlexScheduledTaskRegistrar.restoreTasks()— it readsTaskRepository.findAll()and rehydrates each row honoring the persistedcreatedAt.
- Persist
-
Lambda / prompt-driven triggers (you have raw runnables only):
- Persist whatever you need.
- On startup, re-register via
TaskBuilder.createdAt(Instant)before.register(...)(or callFlexScheduledTaskService.setCreatedAt(name, instant)afterregister(...)) so themax-lifetimeceiling keeps working across restarts. Example:
@Component class ScheduleRestoreListener { @EventListener(ApplicationReadyEvent.class) void onReady(FlexScheduledTaskService svc, PromptTaskStore store) { store.allOwnedTasks().forEach(rec -> svc.task(rec.name()) .cron(rec.cron()) .createdAt(rec.createdAt()) // preserve original lifetime .register(() -> runPrompt(rec)) ); } }
Historical note: earlier versions auto-registered a
JdbcTaskRepositorybean (H2). It created aflex_scheduled_tasktable but never wrote any row — effectively dead code that misled advanced users into thinking H2 was the recommended default. It has been removed.
Execution Timeout
// Task will be interrupted if it takes longer than 30 seconds
taskService.add("longTask", "0 * * * * ?", Duration.ofSeconds(30), () -> {
// If this takes > 30s, ExecutionTimeoutException is thrown
doLongRunningWork();
});
// Also works with fixed-delay and fixed-rate
taskService.addFixedRateTask("rateTask", Duration.ofMinutes(1), Duration.ZERO,
Duration.ofSeconds(45), () -> doWork());
Task Dependency Chains
// Execute tasks sequentially
TaskChain.create(registrar)
.then("extract", () -> extractData())
.then("transform", Duration.ofSeconds(30), () -> transformData()) // with timeout
.then("load", () -> loadData())
.execute(); // returns CompletableFuture<Void>
// Schedule a chain to run after a delay
TaskChain.create(registrar)
.then("step1", () -> step1())
.then("step2", () -> step2())
.schedule("nightlyPipeline", Duration.ofHours(6));
If any step fails, the chain stops and the error propagates.
Execution History
When the Spring Boot starter is used, execution history is enabled automatically with an InMemoryExecutionHistory that keeps the latest 100 records per task. Define your own ExecutionHistory bean to replace the default backend.
For manual registrar or service construction only:
taskService.setExecutionHistory(new InMemoryExecutionHistory(100));
// Query history
List<ExecutionRecord> records = taskService.getExecutionHistory("myTask", 10);
// Each record contains: taskName, taskType, startTime, duration, success, error
// Get all history across tasks
List<ExecutionRecord> all = taskService.getAllExecutionHistory(50);
// Aggregate statistics from recorded history
Optional<TaskStatistics> stats = taskService.getTaskStatistics("myTask");
List<TaskStatistics> allStats = taskService.getAllTaskStatistics();
// Clear history
taskService.clearExecutionHistory("myTask"); // specific task
taskService.clearExecutionHistory(null); // all tasks
Cluster-Aware Scheduling (Distributed Lock)
In multi-instance deployments, use a DistributedLock to ensure only one node executes a task. Register a DistributedLock bean or call taskService.setDistributedLock(lock) when constructing the scheduler manually.
Custom implementations must attach an ownership token to each acquired lock and verify ownership before release. Avoid unconditional key deletion: an expired lock may already have been acquired by another instance. For Redis, prefer the provided implementation below.
The lock is acquired before each execution and released after (even on failure). If another node holds the lock, execution is skipped.
Ready-made Redis implementation
The flex-schedule-redis module ships with RedisDistributedLock, a Redis SETNX-based
implementation that is safe by default (per-instance UUID ownership, TTL expiry).
Add the dependency and the lock is auto-wired:
<dependency>
<groupId>io.github.wb04307201</groupId>
<artifactId>flex-schedule-redis</artifactId>
</dependency>
The lock is auto-wired when Spring Data Redis is available. To declare it explicitly:
@Bean
public DistributedLock redisDistributedLock(StringRedisTemplate redisTemplate) {
return new RedisDistributedLock(redisTemplate);
}
See flex-schedule-redis/README.md for details and manual override examples.
Micrometer Metrics
Automatically active when spring-boot-starter-actuator is on the classpath:
| Metric | Type | Tags |
|---|---|---|
flex.schedule.task.executions |
Counter | taskName, taskType, result |
flex.schedule.task.duration |
Timer | taskName, taskType |
flex.schedule.active.tasks |
Gauge | — |
Integrates with Prometheus, Grafana, and any Micrometer-compatible system. Zero overhead when Micrometer is absent.
Task Scheduling Limits
Constrain trigger frequency and lifetime globally via flex.schedule.limits, to prevent thread pool exhaustion or stale temporary tasks.
flex:
schedule:
limits:
min-interval: PT10M # Minimum trigger interval; null = no limit
max-lifetime: P7D # Maximum task lifetime; null = no limit
mode: strict # strict | warn | off; default strict
| Mode | On violation |
|---|---|
strict |
Throws TaskLimitExceededException for a registration violation; expired recurring tasks are logged and auto-cancelled |
warn |
Logs WARN and allows the registration or expired task to continue |
off |
Skips all checks |
Scope:
| Limit | Applies to |
|---|---|
min-interval |
FIXED_DELAY / FIXED_RATE / ONE_SHOT (cron exempt) |
max-lifetime |
FIXED_DELAY / FIXED_RATE / CRON (one-shot exempt — runs once) |
Key behaviors:
- Lazy expiry check: every fire compares
now - createdAtagainstmax-lifetime; instrictmode, an expired task skips the current fire and cancels future fires, whilewarnmode logs and continues - Paused task expiry: tasks paused past their lifetime are checked on the next
resume();strictmode cancels them, whilewarnmode logs and resumes them replaceXxxTaskresetscreatedAt: lifetime clock restartscreatedAtpersistence is the consumer's responsibility: flex-schedule no longer ships a JDBC default (see Persistence). For lifetime to survive a restart, the consumer must persist the originalcreatedAtand either (a) provide it viaTaskDefinition.createdAttorestoreTasks()or (b) pass it directly toTaskBuilder.createdAt(Instant).
Actuator Endpoint
Active when spring-boot-starter-actuator + spring-boot-starter-webmvc are on the classpath (Boot 4 renamed starter-web → starter-webmvc).
management:
endpoints:
web:
exposure:
include: flexschedule
| Method | Path | Description |
|---|---|---|
GET |
/actuator/flexschedule |
List all tasks |
GET |
/actuator/flexschedule/{name} |
Get task detail |
POST |
/actuator/flexschedule |
Add a task |
DELETE |
/actuator/flexschedule/{name} |
Cancel a task |
POST body:
{
"taskName": "cleanupJob",
"taskType": "CRON",
"cron": "0 0 * * * *",
"beanName": "cleanupService",
"methodName": "run",
"methodParams": []
}
For FIXED_DELAY / FIXED_RATE, use intervalSeconds (+ optional initialDelaySeconds) instead of cron.
Only BeanMethodRunnable style tasks are supported via REST.
Endpoint Security
Write operations (POST/DELETE) are disabled by default to prevent unauthorized task manipulation:
flex:
schedule:
endpoint:
write-enabled: true # Enable POST/DELETE (default: false)
allowed-beans: # Optional bean allowlist (empty = all beans)
- cleanupService
- reportService
| Setting | Default | Description |
|---|---|---|
write-enabled |
false |
Must be true for POST/DELETE operations |
allowed-beans |
[] |
Only these beans can be scheduled via REST. Empty = unrestricted |
For custom authorization logic, implement EndpointAccessControl and register it as a Spring bean.
Warning: Enabling write operations without proper security (Spring Security, network restrictions) may expose your application to remote code execution risks via reflective bean method invocation.
Exceptions
| Exception | When |
|---|---|
TaskAlreadyExistsException |
Adding a task with a duplicate name |
BeanMethodRunnableException |
Reflective bean method invocation fails |
ExecutionTimeoutException |
Task execution exceeds its configured timeout |
TaskLimitExceededException |
Strict scheduling-limit validation fails |
IllegalArgumentException |
Parameter validation (null/empty) |
TaskAlreadyExistsException, BeanMethodRunnableException, ExecutionTimeoutException, and TaskLimitExceededException extend FlexScheduleException. Parameter validation uses the standard IllegalArgumentException.
Modules
| Module | artifactId | Purpose |
|---|---|---|
flex-schedule/ |
flex-schedule |
Core library |
flex-schedule-spring-boot-autoconfigure/ |
flex-schedule-spring-boot-autoconfigure |
Auto-config, metrics, endpoint |
flex-schedule-spring-boot-starter/ |
flex-schedule-spring-boot-starter |
Starter (dependencies only) |
flex-schedule-redis/ |
flex-schedule-redis |
Redis-backed distributed lock implementation |
flex-schedule-test/ |
flex-schedule-test |
Unit and integration tests |
Build
./mvnw clean install
FAQ
Task names must be unique?
Yes. Use replace*() methods to atomically swap, or cancel() then add().
Time units?
long parameters = seconds. Duration parameters = any precision.
Cancel a non-existent task? Logs a WARN, no exception thrown.
How do I make tasks survive restarts? flex-schedule ships no persistence default — see Persistence. The shape depends on your trigger type:
- Bean-method triggers: persist
TaskDefinition(beanName/methodName/createdAt) and callFlexScheduledTaskRegistrar.restoreTasks()on startup. - Lambda / prompt-driven triggers: persist whatever you need, then re-register with
service.task(name)...createdAt(storedInstant).register(runnable)so themax-lifetimeclock survives the restart. The newsetCreatedAt(name, instant)API onFlexScheduledTaskServicelets you override the defaultInstant.now()stamp anytime afterregister(...).
What happens on shutdown?
All tasks cancelled → shutdown() → waits up to await-termination-seconds (default 30s) → shutdownNow() fallback.
How to disable?
Set flex.schedule.enabled=false.