Building Production-Grade Dynamic Cron Jobs in Spring Boot: A Complete Guide
Spring Boot
Learn how to build a robust, database-driven scheduling system in Spring Boot that allows cron jobs to be configured dynamically at runtime without redeployment.
Enterprise applications often require scheduled tasks that can be managed without redeploying the application. This article demonstrates how to build a robust, database-driven scheduling system in Spring Boot that allows cron jobs to be configured dynamically at runtime.
The Challenge: Static vs. Dynamic Scheduling
Traditional Spring Boot scheduling uses the @Scheduled annotation with hardcoded cron expressions. While this works for simple use cases, production systems need flexibility. Administrators should be able to modify schedules, enable or disable jobs, and add new tasks without touching code or restarting services. The solution presented here stores scheduler configurations in a database and dynamically registers jobs at application startup, providing centralized control over all scheduled tasks while maintaining type safety and proper error handling.
Step 1: Define the Database Entity
The SchedulerJob entity stores configuration for each scheduled task. The @Where annotation filters only active jobs automatically, while the soft-delete flag preserves historical configuration data.
@Entity
@Table(name = "tbl_scheduler")
@Where(clause = "status= 'Active'")
public class SchedulerJob extends Auditable implements IBase<Long> {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String cronExpression;
private Boolean isDeleted = false;
private String status;
private LocalDateTime scheduledTime;
private Long tenantId;
}
Key Points:
- The name field uniquely identifies each job in the system
- The cronExpression determines when the job executes (e.g., "0 0 12 * * ?" for daily at noon)
- Status and isDeleted flags enable/disable jobs without removing data
- The scheduledTime field tracks the last execution timestamp
- Multi-tenant support is provided through the tenantId field
Step 2: Create the Base Job Framework
The BaseScheduledJob abstract class implements both Runnable for execution and Trigger for dynamic scheduling. This dual implementation enables runtime schedule updates.
@Slf4j
public abstract class BaseScheduledJob implements Runnable, Trigger {
protected String MODULE;
protected String cronExp;
protected SchedulerDTO schedulerDTO;
protected SchedulerJobService service;
protected int failCount;
public BaseScheduledJob(SchedulerDTO schedulerDTO, SchedulerJobService service, String moduleName) {
this.MODULE = " [" + moduleName + "] ";
this.service = service;
if (schedulerDTO != null) {
schedulerDTO = service.getByName(schedulerDTO.getName());
this.schedulerDTO = schedulerDTO;
this.cronExp = schedulerDTO.getCronExpression();
}
}
}
Constructor Purpose:
- Fetches the latest configuration from the database to ensure current settings are used
- The module name parameter enables consistent logging prefixes across different job types
- Initializes the cron expression that determines scheduling intervals
The run method orchestrates job execution with comprehensive error handling and audit logging.
@Override
public void run() {
String session = CommonUtils.generateUniqueId(10);
schedulerDTO = service.getByName(schedulerDTO.getName());
try {
if (service.checkStatus(this.schedulerDTO)) {
log.info("{} :: {} started at {}", session, this.getClass().getSimpleName(), LocalDateTime.now());
try {
executeJobLogic();
} catch (Exception e) {
if (SchedulerException.checkException(e)) {
this.executeFailed();
}
log.error("{}Job logic error: {}", MODULE, e.getMessage(), e);
}
}
log.info("{} :: {} ended at {}", session, this.getClass().getSimpleName(), LocalDateTime.now());
service.updateScheduler(this.schedulerDTO);
} catch (Exception ex) {
log.error("{}Scheduler error: {}", MODULE, ex.getMessage(), ex);
}
}
Execution Flow:
- Session ID correlates all log entries from a single execution for easy troubleshooting
- Status check prevents disabled jobs from running even after scheduler initialization
- Separate try-catch blocks isolate business logic errors from framework errors
- The updateScheduler call records execution time and status in the database
The nextExecution method refreshes the cron expression before calculating the next run time, enabling dynamic schedule updates.
@Override
public Instant nextExecution(TriggerContext triggerContext) {
try {
schedulerDTO = service.getByName(schedulerDTO.getName());
this.cronExp = schedulerDTO.getCronExpression();
} catch (Exception e) {
log.error("{}Error fetching cron expression: {}", MODULE, e.getMessage(), e);
throw new CustomException(MessageConstants.ERROR_OCCURRED);
}
return new CronTrigger(this.cronExp).nextExecution(triggerContext);
}
Dynamic Scheduling:
- Re-fetches the cron expression from the database before each schedule calculation
- This allows schedule changes to take effect for future executions
- Falls back to exception handling if database access fails
The retry mechanism handles transient failures without manual intervention.
public void executeFailed() {
failCount++;
if (failCount < SchedulerConstants.failCount) {
this.run();
}
if (failCount == SchedulerConstants.failCount) {
this.failCount = 0;
}
}
protected abstract void executeJobLogic() throws Exception;
Retry Strategy:
- Automatically retries failed jobs up to a configured limit
- Prevents permanent failures from temporary issues like network blips or database locks
- Resets the counter after maximum attempts to avoid infinite loops
Step 3: Implement Specific Jobs
Individual job classes extend the base and implement only business logic. This example automatically closes RFQs that have passed their due date.
@Slf4j
public class AutoCloseDueRfqJob extends BaseScheduledJob {
public AutoCloseDueRfqJob(SchedulerDTO schedulerDTO, SchedulerJobService service) {
super(schedulerDTO, service, "AutoCloseDueRfqJob");
}
@Override
protected void executeJobLogic() throws Exception {
RfqRepository rfqRepository = SpringContext.getBean(RfqRepository.class);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String currentDate = LocalDate.now().format(formatter);
rfqRepository.autoCloseDueRfq(currentDate);
}
}
Implementation Details:
- Job instances are created directly, not through Spring's dependency injection
- SpringContext.getBean() retrieves required repositories and services manually
- Developers focus solely on business logic without worrying about scheduling mechanics
- All error handling, logging, and status management is inherited from the base class
Step 4: Configure the Scheduler
The CronConfig class orchestrates the entire scheduling system by implementing SchedulingConfigurer, which hooks into Spring's lifecycle to configure the task scheduler at startup.
@Slf4j
@Configuration
@EnableScheduling
public class CronConfig implements SchedulingConfigurer {
private SchedulerJobService service;
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
service = SpringContext.getBean(SchedulerJobService.class);
ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();
threadPoolTaskScheduler.setThreadNamePrefix("scheduler-thread");
threadPoolTaskScheduler.initialize();
try {
List<SchedulerDTO> schedulerDTOS = service.getAllEntities(null);
log.info("Fetched {} scheduler jobs from the service.", schedulerDTOS.size());
scheduleJobs(threadPoolTaskScheduler, schedulerDTOS);
} catch (Exception e) {
log.error("Error while fetching or scheduling jobs: {}", e.getMessage(), e);
throw new RuntimeException("Failed to configure tasks", e);
}
taskRegistrar.setTaskScheduler(threadPoolTaskScheduler);
}
}
Configuration Strategy:
- SchedulingConfigurer integration ensures jobs are registered before application fully starts
- Custom thread name prefix makes debugging easier in logs and thread dumps
- Failing fast with RuntimeException prevents inconsistent states where only some jobs are scheduled
- All active jobs are fetched from the database and registered during startup
The scheduleJobs method iterates through all active jobs and registers each one with isolated error handling.
private void scheduleJobs(TaskScheduler scheduler, List<SchedulerDTO> schedulerDTOS) {
for (SchedulerDTO data : schedulerDTOS) {
if (data == null) {
log.warn("Encountered null SchedulerDTO, skipping...");
continue;
}
try {
log.info("Attempting to schedule job '{}'", data.getName());
scheduleJob(scheduler, data);
log.info("Job '{}' successfully scheduled.", data.getName());
} catch (Exception ex) {
log.error("Error scheduling job '{}': {}", data.getName(), ex.getMessage(), ex);
}
}
}
Isolation Benefits:
- Null checking protects against database inconsistencies
- Individual try-catch blocks ensure one failed job does not prevent others from scheduling
- Detailed logging for each job provides clear diagnostics for troubleshooting
The scheduleJob method maps job names to their implementations using a switch statement, providing type safety and compile-time verification.
private void scheduleJob(TaskScheduler scheduler, SchedulerDTO data) {
switch (data.getName()) {
case SchedulerConstants.SYNC_COUNTRY:
log.info("Scheduling 'Sync Country' job.");
scheduler.schedule(new SyncCountryJob(data, service),
new SyncCountryJob(data, service));
break;
case SchedulerConstants.AUTO_CLOSE_DUE_RFQ:
log.info("Scheduling 'auto close due rfq' job.");
scheduler.schedule(new AutoCloseDueRfqJob(data, service),
new AutoCloseDueRfqJob(data, service));
break;
// Additional cases for other jobs...
default:
log.error("Unknown job name '{}'. Skipping scheduling.", data.getName());
}
}
Registration Pattern:
- The same instance is passed twice to serve as both Runnable (what to execute) and Trigger (when to execute)
- Switch statement provides complete type safety and compile-time verification
- Unknown job names are logged but do not crash the application
- Each case explicitly creates the job instance with required dependencies
Step 5: Optional REST API for Manual Triggers
For administrative purposes, jobs can be triggered manually through REST endpoints independent of their schedule.
@RestController
@RequestMapping(path = UrlConstants.BASEURL + "/cron-jobs")
public class CronJobController {
private final LogAPIService logAPIService;
public CronJobController(LogAPIService logAPIService) {
this.logAPIService = logAPIService;
}
@GetMapping(value = "/clear-log-record")
public void clearLogRecord() {
logAPIService.clearLogRecord();
}
}
Manual Control:
- REST endpoints allow administrators to trigger jobs on demand
- Useful for testing, debugging, or handling exceptional situations
- Operates independently of the scheduled execution cycle
Production Considerations
This architecture works well for systems where schedule changes are infrequent and planned. The current implementation requires an application restart to pick up configuration changes, which provides a controlled deployment process. For truly dynamic runtime changes without restart, you would need to implement a scheduler registry that can cancel and reschedule jobs on demand, though this adds significant complexity around thread safety and state management.
Thread Pool Management:
- Monitor pool size carefully as too few threads cause queuing and missed schedules
- Too many threads waste resources and increase context switching overhead
- Start with a pool size equal to expected concurrent jobs and adjust based on metrics
Distributed Systems:
- Multiple application instances require distributed locking to prevent duplicate execution
- Technologies like ShedLock integrate seamlessly with this pattern
- Coordinate job execution across nodes to ensure exactly-once semantics
Observability:
- Session IDs enable tracing complete execution lifecycles in high-volume logs
- Module prefixes facilitate filtering by specific jobs during troubleshooting
- Full exception stack traces provide developers with diagnostic information
Conclusion
Dynamic cron job scheduling provides the flexibility production systems need without sacrificing type safety or error handling. By storing configurations in the database and using Spring's SchedulingConfigurer interface, you achieve centralized management of all scheduled tasks. The base class pattern ensures consistent error handling and logging across all jobs, while individual job classes remain focused on business logic. This architecture scales from a handful of jobs to hundreds, providing the foundation for reliable background processing in enterprise applications.