Skip to content

Expose scheduled fire time to @Scheduled methods for reliable distributed execution #35977

@JiangYanLin

Description

@JiangYanLin

📄 Context / Problem Statement

In distributed systems, it's common to run Spring Boot applications with multiple instances. For cron-based scheduled tasks (e.g., @Scheduled(cron = "0 0 2 * * ?")), users often need to ensure that only one instance executes the task for a given scheduled time—a pattern known as "cluster singleton per schedule".

To implement this safely, we must construct a globally unique lock key based on:

  • The task identity (e.g., method name or explicit name)
  • The exact scheduled fire time (e.g., 2025-12-09T02:00:00Z)

However, Spring does not expose the scheduled fire time to the user method. Developers are forced to re-calculate it at execution time using libraries like cron-utils, like so:

@Scheduled(cron = "0 0 2 * * ?")
public void dailyReport() {
    // ❌ Dangerous: relies on current time to infer intended fire time
    LocalDateTime scheduledTime = inferScheduledTimeFromCron("0 0 2 * * ?", ZoneId.of("UTC"));
    distributedLock.execute("dailyReport", scheduledTime, () -> { ... });
}

This approach is fundamentally flawed:

  • If the system is under load or GC pause delays task execution (e.g., runs at 02:00:59 instead of 02:00:00), the inferred time may be wrong (e.g., resolved to next day).
  • This can cause task skipping or duplicate execution in edge cases.
  • It violates the principle that the scheduler already knows the correct fire time—users shouldn’t have to guess it.

Why This Matters

The Spring TaskScheduler already computes the exact next execution time via Trigger.nextExecutionTime(TriggerContext) during scheduling. This time is precise, deterministic, and aligned across all instances (assuming clock sync). Yet, this critical piece of metadata is not exposed to application code.

Without it, building reliable, failover-capable, cluster-safe cron jobs requires fragile workarounds or abandoning @Scheduled entirely in favor of Quartz or custom schedulers.

Proposed Solution

Introduce a way to inject the scheduled fire time into @Scheduled methods, similar to how Spring MVC injects path variables or authentication principals.

Option 1: Parameter injection (recommended)
@Scheduled(cron = "0 0 2 * * ?")
public void dailyReport(@ScheduledTime LocalDateTime scheduledFireTime) {
    // ✅ Safe: use the time the scheduler actually planned for this execution
    distributedLock.execute("dailyReport", scheduledFireTime, Duration.ofHours(1), () -> {
        // business logic
    });
}
  • @ScheduledTime would be a new stereotype annotation (e.g., org.springframework.scheduling.annotation.ScheduledTime)
  • Supported types: LocalDateTime, ZonedDateTime, Instant, Date
  • Time zone respects the zone attribute of @Scheduled
Option 2: ThreadLocal context (fallback)
@Scheduled(cron = "0 0 2 * * ?")
public void dailyReport() {
    LocalDateTime fireTime = ScheduledTaskContext.getCurrentFireTime();
}

But Option 1 is preferred for its clarity and testability.

Backward Compatibility

  • This is an opt-in feature: existing @Scheduled methods are unaffected.
  • No breaking changes to APIs or behavior.

Prior Art

  • Quartz Scheduler: Provides JobExecutionContext.getFireTime() — widely used for exactly this purpose.
  • AWS EventBridge / Cloud Scheduler: Always includes the scheduled invocation time in payload.
  • Kubernetes CronJob: Sets metadata.annotations["cronjob.kubernetes.io/scheduled"].

Conclusion

Exposing the scheduled fire time would:

  • Enable safe, standard implementation of distributed cron jobs
  • Eliminate error-prone time inference logic
  • Bring Spring’s scheduling model closer to production-grade reliability

This small addition would significantly improve Spring’s suitability for cloud-native, multi-instance deployments—without compromising its simplicity for single-node use cases.


Metadata

Metadata

Assignees

No one assigned

    Labels

    in: coreIssues in core modules (aop, beans, core, context, expression)status: waiting-for-triageAn issue we've not yet triaged or decided on

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions