Search code examples
javaspring-bootschedulerdsttimezone-offset

java.util.Timer scheduler daily goes wrong because daylight savings


My code from below works well, the job is executed daily at the right time with exception of daylight savings hour changes. It happens at the end of october and march. My server has a lightweight hardware and it is located in zone where it is used. How can I make it work, using minimal resources (without quartz or any similar)

// init daily scheduler 
final java.util.Timer timer = new java.util.Timer("MyTimer");
final String[] hourMinute = time.split(":");
final String hour = hourMinute[0];
final String minute = hourMinute[1];
String second = "0";
final Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
calendar.set(Calendar.HOUR_OF_DAY, Integer.parseInt(hour));
calendar.set(Calendar.MINUTE, Integer.parseInt(minute));
calendar.set(Calendar.SECOND, Integer.parseInt(second));
if(calendar.getTime().before(new Date())){
    calendar.add(Calendar.DATE, 1);
}

timer.schedule(job, calendar.getTime(), 
TimeUnit.MILLISECONDS.convert(1, TimeUnit.DAYS)); // period: 1 day

Solution

  • tl;dr

    Use only java.time classes, never Date/Calendar.

    Days vary in length, not always 24 hours long. So, running at a certain time-of-day means determining a particular moment for each and every day.

    public void scheduleNextRun ( LocalDate today )
    {
        LocalDate dateOfNextRun = today;
        if ( LocalTime.now( this.zoneId ).isAfter( this.timeToExecute ) )
        {
            dateOfNextRun = dateOfNextRun.plusDays( 1 );
        }
        ZonedDateTime zdt = ZonedDateTime.of( dateOfNextRun , this.timeToExecute , this.zoneId );  // Determine a moment, a point on the timeline.
        Instant instantOfNextTaskExecution = zdt.toInstant(); // Adjust from time zone to offset of zero hours-minutes-seconds from UTC.
        Duration d = Duration.between( Instant.now() , instantOfNextTaskExecution );
        this.ses.schedule( this , d.toNanos() , TimeUnit.NANOSECONDS );
        System.out.println( "DEBUG - Will run this task after duration of " + d + " at " + zdt );
    }
    

    Outmoded classes

    You are using terrible date-time classes that were years ago supplanted by the modern java.time classes defined in JSR 310.

    And you are using the Timer class that was supplanted years ago by the Executors framework, as noted in the Javadoc.

    Modern Java

    Scheduled executor service

    Somewhere in your app, initialize a scheduled executor service.

    ScheduledExecutorService see = Executors.newSingleThreadScheduledExecutor() ;
    

    Keep that scheduled executor service around. Be sure to shut it down before your app exits as otherwise its backing pool of threads may continue running like a zombie 🧟‍♂️.

    Runnable task

    Define your task as a Runnable or Callable.

    Days vary in length

    You want your task to run once per day at a designated time-of-day as seen in a particular time zone.

    public class DailyTask implements Runnable { … } 
    

    That does not mean every 24 hours. On some dates in some zones, days vary in length. They may be 23 hours, 25 hours, or 23.5 or some other number of hours long.

    Task re-schedules itself

    To determine when to run the task next, the task itself should make that calculation. The task will then re-schedule itself for its next execution. To do this, our task must keep a reference to the scheduled executor service on which it is executing. And our task must track the time of day at which you want to run. And the task must track the time zone in which we are to perceive this time-of-day. So we need to pass this info to the constructor of our task, and remember the values as private members.

    …
    private final ScheduledExecutorService ses;
    private final LocalTime timeToExecute;
    private final ZoneId zoneId;
    
    public DailyTask ( final ScheduledExecutorService ses , final LocalTime timeToExecute , final ZoneId zoneId )
    {
        this.ses = ses;
        this.timeToExecute = timeToExecute;
        this.zoneId = zoneId;
    }
    …
    

    To simulate work, in this example we simply print to console. We do this in the run method promised by the Runnable interface.

    @Override
    public void run ( )
    {
        // Perform the task’s work.
        System.out.println( Thread.currentThread().getId() + " is running at " + Instant.now() );
    }
    

    Calculating next run

    To that run method we must add the calculation of the next run. First we capture the current date just in case the clock were to run over to the next date while performing our task’s workload.

    LocalDate today = LocalDate.now( this.zoneId );
    

    Next we calculate the amount of time until the next run.

    LocalDate tomorrow = today.plusDays( 1  );
    ZonedDateTime zdt = ZonedDateTime.of( tomorrow, this.timeToExecute , this.zoneId );  // Determine a moment, a point on the timeline.
    Instant instantOfNextTaskExecution = zdt.toInstant(); // Adjust from time zone to offset of zero hours-minutes-seconds from UTC.
    Duration d = Duration.between( Instant.now() , instantOfNextTaskExecution );
    

    Scheduling task to run on executor service

    Lastly we schedule that work.

    this.ses.schedule( this , d.toNanos() , TimeUnit.NANOSECONDS );
    

    To get this task running the very first time, the calling app will need to schedule this task this first time. Rather than make the calling app do the duration calculation, let's make the duration calculation code seen here available as a method.

    public void scheduleNextRun( LocalDate today ) {
        LocalDate tomorrow = today.plusDays( 1 );
        ZonedDateTime zdt = ZonedDateTime.of( tomorrow , this.timeToExecute , this.zoneId );  // Determine a moment, a point on the timeline.
        Instant instantOfNextTaskExecution = zdt.toInstant(); // Adjust from time zone to offset of zero hours-minutes-seconds from UTC.
        Duration d = Duration.between( Instant.now() , instantOfNextTaskExecution );
        this.ses.schedule( this , d.toNanos() , TimeUnit.NANOSECONDS );
        System.out.println( "DEBUG - Will run this task after duration of " + d + " at " + zdt );
    }
    

    We have a subtle bug in that method. We should not assume the next run is for tomorrow. If, on the very first run, the current moment is not yet past the target time-of-day then we should not add a day to the current date. We should only call .plusDays( 1 ) if the time-of-day has passed for the current date.

    public void scheduleNextRun ( LocalDate today )
    {
        LocalDate dateOfNextRun = today;
        if ( LocalTime.now( this.zoneId ).isAfter( this.timeToExecute ) )
        {
            dateOfNextRun = dateOfNextRun.plusDays( 1 );
        }
        ZonedDateTime zdt = ZonedDateTime.of( dateOfNextRun , this.timeToExecute , this.zoneId );  // Determine a moment, a point on the timeline.
        Instant instantOfNextTaskExecution = zdt.toInstant(); // Adjust from time zone to offset of zero hours-minutes-seconds from UTC.
        Duration d = Duration.between( Instant.now() , instantOfNextTaskExecution );
        this.ses.schedule( this , d.toNanos() , TimeUnit.NANOSECONDS );
        System.out.println( "DEBUG - Will run this task after duration of " + d + " at " + zdt );
    }
    

    Final code

    Putting all that code together looks like this.

    package work.basil.timing;
    
    import java.time.*;
    import java.util.concurrent.ScheduledExecutorService;
    import java.util.concurrent.TimeUnit;
    
    public class DailyTask implements Runnable
    {
        private final ScheduledExecutorService ses;
        private final LocalTime timeToExecute;
        private final ZoneId zoneId;
    
        public DailyTask ( final ScheduledExecutorService ses , final LocalTime timeToExecute , final ZoneId zoneId )
        {
            this.ses = ses;
            this.timeToExecute = timeToExecute;
            System.out.println( "timeToExecute = " + timeToExecute );
            this.zoneId = zoneId;
        }
    
        @Override
        public void run ( )
        {
            // Capture the current moment as seen your desired time zone.
            // Do this *before* your task’s work, in case during that task work the clock runs over to the next day.
            // Tip: Avoid scheduling tasks for midnight, the witching hour.
            LocalDate today = LocalDate.now( this.zoneId );
            // Perform the task’s work.
            System.out.println( "Thread ID: " + Thread.currentThread().getId() + " is running at " + Instant.now() );
            // Schedule the next task.
            this.scheduleNextRun( today );
        }
    
        public void scheduleNextRun ( LocalDate today )
        {
            LocalDate dateOfNextRun = today;
            if ( LocalTime.now( this.zoneId ).isAfter( this.timeToExecute ) )
            {
                dateOfNextRun = dateOfNextRun.plusDays( 1 );
            }
            ZonedDateTime zdt = ZonedDateTime.of( dateOfNextRun , this.timeToExecute , this.zoneId );  // Determine a moment, a point on the timeline.
            Instant instantOfNextTaskExecution = zdt.toInstant(); // Adjust from time zone to offset of zero hours-minutes-seconds from UTC.
            Duration d = Duration.between( Instant.now() , instantOfNextTaskExecution );
            this.ses.schedule( this , d.toNanos() , TimeUnit.NANOSECONDS );
            System.out.println( "DEBUG - Will run this task after duration of " + d + " at " + zdt );
        }
    }
    

    Demo app

    Write a little demo app to use it.

    To make this demo work without us having to wait an entire day, let's calculate a minute from the current moment as our desired time-of-day. Then we need only wait a minute to see this code work.

    Actually, we will wait two minutes, enough time to ensure our principal code has completely finished before we shut down the demo app’s scheduled executor service.

    package work.basil.timing;
    
    import java.time.*;
    import java.time.temporal.ChronoUnit;
    import java.util.concurrent.Executors;
    import java.util.concurrent.ScheduledExecutorService;
    import java.util.concurrent.TimeUnit;
    
    public class App
    {
        public static void main ( String[] args )
        {
            System.out.println( "INFO - Demo start. " + Instant.now() );
    
            ScheduledExecutorService ses = Executors.newSingleThreadScheduledExecutor();
            ZoneId z = ZoneId.of( "Europe/Bucharest" );
            System.out.println( "Now in " + z + " is " + ZonedDateTime.now( z ) );
            DailyTask task = new DailyTask( ses , LocalTime.now( z ).truncatedTo( ChronoUnit.SECONDS ).plusMinutes( 1 ) , z );
            task.scheduleNextRun( LocalDate.now( z ) );
    
            try { Thread.sleep( Duration.ofMinutes( 2 ).toMillis() ); } catch ( InterruptedException e ) { throw new RuntimeException( e ); }
            ses.shutdownNow();
            System.out.println( "INFO - Waiting for scheduled executor service to shutdown now. " + Instant.now() );
            try { ses.awaitTermination( 5 , TimeUnit.SECONDS ); } catch ( InterruptedException e ) { throw new RuntimeException( e ); }
            System.out.println( "INFO - Demo end. " + Instant.now() );
        }
    }
    

    When run.

    INFO - Demo start. 2022-03-08T22:03:14.837676Z
    Now in Europe/Bucharest is 2022-03-09T00:03:14.848880+02:00[Europe/Bucharest]
    timeToExecute = 00:04:14
    DEBUG - Will run this task after duration of PT59.142979S at 2022-03-09T00:04:14+02:00[Europe/Bucharest]
    Thread ID: 15 is running at 2022-03-08T22:04:14.007322Z
    DEBUG - Will run this task after duration of PT23H59M59.979047S at 2022-03-10T00:04:14+02:00[Europe/Bucharest]
    INFO - Waiting for scheduled executor service to shutdown now. 2022-03-08T22:05:14.868608Z
    INFO - Demo end. 2022-03-08T22:05:14.871141Z
    

    Not so S.O.L.I.D.

    The code seen above is workable. I could imagine putting that into production. However, looking at the bigger picture, that code has a design flaw.

    The SOLID principles are generally recognized as a way of designing better software. The S stands for Single-responsibility principle. That means a class should focus on doing one main thing, and doing that one thing well. So different jobs should be handled by different classes.

    But in the code seen above we are mixing two different jobs.

    We have the original job, the task that needs to be performed routinely. Imagine that task is some business function such as compiling a Sales report, or calculating an accounting roll-up, or syncing inventory tallies. Such functions do not really care about when the run; they care about sales figures, accounting numbers, or inventory levels.

    Deciding when to run such a function is a different kind of job. This other job of capturing the current moment, applying time zones, calculating time to elapse, is entirely separate from sales, accounting, or inventory.

    Even the name of our Runnable class is a clue to this violation of the Single-Responsibility Principle: DailyTask has two parts, Task referring to the business function to be accomplished, and Daily referring to the scheduling chore.

    So ideally, rather than have our task reschedule itself, we should have two different classes. One handles the original job of business work, and the other handles the scheduling of execution.

    Implementing this goes far beyond the original Question. So I will leave this as an exercise for the reader. ;-)