灵动调度:基于 Spring Boot 的动态任务调度框架

基于 Spring Boot 的轻量化动态调度线程池,支持运行时动态添加、移除、替换定时任务。

Branch2Tags9
FilesLast commitLast update
1 month ago
7 days ago
6 days ago
7 days ago
6 days ago
6 days ago
7 days ago
6 days ago
3 years ago
3 days ago
3 days ago
1 month ago

FlexSchedule(灵动调度)Spring Boot Starter

中文 | English

A lightweight flex scheduling thread pool for Spring Boot — add, remove, and replace scheduled tasks at runtime.

Maven Central star star star License JDK SpringBoot


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 TaskChain builder
  • Execution history — In-memory ring buffer of execution records per task
  • Cluster-aware scheduling — DistributedLock interface for multi-node coordination (ShedLock/Redis/ZooKeeper)
  • Thread-safe — ConcurrentHashMap with 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/flexschedule with access control (optional)
  • Reflective scheduling — BeanMethodRunnable with 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 / delay accept long (seconds) or Duration (any precision)
  • retryPolicy is 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 fields null). 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_DELAY and CRON, grid-based for FIXED_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 configured misfirePolicy / misfireThreshold (null when 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:

  1. Bean-method triggers (you already have TaskDefinition):

    • Persist TaskDefinition (including beanName / methodName / createdAt, and optionally misfirePolicy / misfireThreshold) to your store. Keeping both misfire fields null restores the task exempt from misfire handling (pre-1.3.0 behavior); persisted values are re-applied by restoreTasks().
    • On startup, call FlexScheduledTaskRegistrar.restoreTasks() — it reads TaskRepository.findAll() and rehydrates each row honoring the persisted createdAt.
  2. Lambda / prompt-driven triggers (you have raw runnables only):

    • Persist whatever you need.
    • On startup, re-register via TaskBuilder.createdAt(Instant) before .register(...) (or call FlexScheduledTaskService.setCreatedAt(name, instant) after register(...)) so the max-lifetime ceiling 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 JdbcTaskRepository bean (H2). It created a flex_scheduled_task table 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 - createdAt against max-lifetime; in strict mode, an expired task skips the current fire and cancels future fires, while warn mode logs and continues
  • Paused task expiry: tasks paused past their lifetime are checked on the next resume(); strict mode cancels them, while warn mode logs and resumes them
  • replaceXxxTask resets createdAt: lifetime clock restarts
  • createdAt persistence 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 original createdAt and either (a) provide it via TaskDefinition.createdAt to restoreTasks() or (b) pass it directly to TaskBuilder.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 call FlexScheduledTaskRegistrar.restoreTasks() on startup.
  • Lambda / prompt-driven triggers: persist whatever you need, then re-register with service.task(name)...createdAt(storedInstant).register(runnable) so the max-lifetime clock survives the restart. The new setCreatedAt(name, instant) API on FlexScheduledTaskService lets you override the default Instant.now() stamp anytime after register(...).

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.

License

Apache License 2.0

Introduction

基于 Spring Boot 的轻量化动态调度线程池,支持运行时动态添加、移除、替换定时任务。

Customize your domain