2828import java .util .Calendar ;
2929import java .util .Collection ;
3030import java .util .GregorianCalendar ;
31- import java .util .HashSet ;
31+ import java .util .HashMap ;
3232import java .util .Iterator ;
3333import java .util .List ;
3434import java .util .Locale ;
35- import java .util .Set ;
35+ import java .util .Map ;
3636import java .util .TimeZone ;
3737import java .util .concurrent .ScheduledFuture ;
3838import java .util .concurrent .TimeUnit ;
4141
4242import org .eclipse .jdt .annotation .NonNullByDefault ;
4343import org .eclipse .jdt .annotation .Nullable ;
44+ import org .openhab .binding .astro .internal .AstroBindingConstants ;
4445import org .openhab .binding .astro .internal .action .AstroActions ;
4546import org .openhab .binding .astro .internal .config .AstroChannelConfig ;
4647import org .openhab .binding .astro .internal .config .AstroThingConfig ;
5556import org .openhab .core .library .types .DecimalType ;
5657import org .openhab .core .library .types .StringType ;
5758import org .openhab .core .scheduler .CronScheduler ;
58- import org .openhab .core .scheduler .ScheduledCompletableFuture ;
5959import org .openhab .core .thing .Channel ;
6060import org .openhab .core .thing .ChannelUID ;
6161import org .openhab .core .thing .Thing ;
7171/**
7272 * Base ThingHandler for all Astro handlers.
7373 *
74+ * @implNote
75+ * The scheduling of events allows graceful handling of scheduling an event that is already scheduled,
76+ * to allow scheduling to take place at any time of day, even when while the rescheduling itself takes place.
77+ * <p>
78+ * This is achieved by storing all scheduled events in {@link #scheduledFutures}, which identifies "which
79+ * event" it is using a string identifier.
80+ * <p>
81+ * If an event is being scheduled and the same event already exists in {@link #scheduledFutures}, the
82+ * following
83+ * logic applies:
84+ * <ul>
85+ * <li>If the existing/old event is {@link ScheduledFuture#isDone()}, which means either completed or
86+ * cancelled,
87+ * the new event will be scheduled without further due.</li>
88+ * <li>If the existing/old event hasn't yet fired, it will be decided whether the existing/old event should
89+ * remain
90+ * scheduled, or if the new one should be scheduled. Under no circumstances will both be allowed. Which one
91+ * "wins"
92+ * is decided by the following logic:</li>
93+ * <ul>
94+ * <li>If the existing/old event is scheduled to fire at or later than {@link #MIN_TIME_TO_SCHEDULE_MS} from
95+ * the moment
96+ * the evaluation takes place, the existing/old event will be cancelled and the new one scheduled in its
97+ * place.</li>
98+ * <li>If the existing/old event is scheduled to fire in less than {@link #MIN_TIME_TO_SCHEDULE_MS} <i>and</i>
99+ * the
100+ * difference in scheduled time for the old and the new event is less than or equal to
101+ * {@link #MAX_SCHEDULE_DIFFERENCE_MS},
102+ * the existing/old event is allowed to remain scheduled and the new event is discarded. This is to make sure
103+ * that the
104+ * event doesn't fire twice in case the cancellation can't be executed in time to prevent execution. But, if
105+ * the
106+ * difference between the two schedules is too large, the existing/old schedule might be scheduled too
107+ * inaccurately,
108+ * in which case a (desperate) attempt it made at cancelling the old one regardless. This is an extremely
109+ * unlikely
110+ * scenario to actually occur.</li>
111+ * <li>In any other case, the existing/old event is cancelled and the new one scheduled instead.</li>
112+ * </ul>
113+ * </ul>
114+ *
74115 * @author Gerhard Riegler - Initial contribution
75116 * @author Amit Kumar Mondal - Implementation to be compliant with ESH Scheduler
117+ * @author Ravi Nadahar - Refactored scheduling
76118 */
77119@ NonNullByDefault
78120public abstract class AstroThingHandler extends BaseThingHandler {
79121 private static final String DAILY_MIDNIGHT = "30 0 0 * * ? *" ;
80122
123+ /**
124+ * Minimum delay (in milliseconds) that must remain until a job is executed before it will be
125+ * scheduled. This prevents attempting to schedule jobs that would effectively execute "now" or
126+ * in the past due to clock skew, rounding, or scheduler latency. The value is intentionally
127+ * very small compared to astro event intervals, while still providing a safety margin over
128+ * typical scheduler/resolution jitter.
129+ */
130+ private static final long MIN_TIME_TO_SCHEDULE_MS = 10L ;
131+
132+ /**
133+ * Maximum time difference (in milliseconds) between two candidate execution times for them to
134+ * be considered equivalent in the schedule deduplication logic. If two schedules differ by at
135+ * most this amount, they are treated as the same schedule and a new job is not created. This
136+ * tolerance compensates for small rounding differences and minor time calculation jitter
137+ * without affecting the semantics of astro events, where relevant time spans are much larger.
138+ */
139+ private static final long MAX_SCHEDULE_DIFFERENCE_MS = 20L ;
140+
81141 /** Logger Instance */
82142 private final Logger logger = LoggerFactory .getLogger (MethodHandles .lookup ().lookupClass ());
83143 private final SimpleDateFormat loggerFormatter = new SimpleDateFormat ("yyyy-MM-dd'T'HH:mm:ss.SSS" , Locale .ROOT );
@@ -92,16 +152,13 @@ public abstract class AstroThingHandler extends BaseThingHandler {
92152 private final Lock monitor = new ReentrantLock ();
93153
94154 // All access must be guarded by "monitor"
95- private final Set < ScheduledFuture <?>> scheduledFutures = new HashSet <>();
155+ private final Map < String , ScheduledFuture <?>> scheduledFutures = new HashMap <>();
96156
97157 // All access must be guarded by "monitor"
98158 private boolean linkedPositionalChannels ;
99159
100160 protected AstroThingConfig thingConfig = new AstroThingConfig ();
101161
102- // All access must be guarded by "monitor"
103- private @ Nullable ScheduledCompletableFuture <?> dailyJob ;
104-
105162 /** The source of the current time */
106163 protected final InstantSource instantSource ;
107164
@@ -230,8 +287,8 @@ private void restartJobs() {
230287 Locale locale = localeProvider .getLocale ();
231288 // Daily Job
232289 Job runnable = getDailyJob (zone , locale );
233- dailyJob = cronScheduler .schedule (runnable , DAILY_MIDNIGHT );
234- logger .debug ("Scheduled {} at midnight" , dailyJob );
290+ scheduledFutures . put ( AstroBindingConstants . DAILY_JOB , cronScheduler .schedule (runnable , DAILY_MIDNIGHT ) );
291+ logger .debug ("Scheduled daily '{}' job at midnight" , getThing (). getUID () );
235292 // Execute daily startup job immediately
236293 runnable .run ();
237294
@@ -242,7 +299,7 @@ private void restartJobs() {
242299 Job positionalJob = new PositionalJob (this );
243300 ScheduledFuture <?> future = scheduler .scheduleAtFixedRate (positionalJob , 0 , thingConfig .interval ,
244301 TimeUnit .SECONDS );
245- scheduledFutures .add ( future );
302+ scheduledFutures .put ( AstroBindingConstants . POSITIONAL_JOB , future );
246303 logger .info ("Scheduled {} every {} seconds" , positionalJob , thingConfig .interval );
247304 }
248305 }
@@ -258,12 +315,7 @@ private void stopJobs() {
258315 logger .debug ("Stopping scheduled jobs for thing {}" , getThing ().getUID ());
259316 monitor .lock ();
260317 try {
261- ScheduledCompletableFuture <?> job = dailyJob ;
262- if (job != null ) {
263- job .cancel (true );
264- }
265- dailyJob = null ;
266- for (ScheduledFuture <?> future : scheduledFutures ) {
318+ for (ScheduledFuture <?> future : scheduledFutures .values ()) {
267319 if (!future .isDone ()) {
268320 future .cancel (true );
269321 }
@@ -335,12 +387,24 @@ public void triggerEvent(String channelId, String event) {
335387 /**
336388 * Adds the provided {@link Job} to the queue (cannot be {@code null})
337389 */
338- private void schedule (Job job , long sleepTimeMs ) {
390+ private void schedule (String identifier , Job job , long sleepTimeMs ) {
339391 monitor .lock ();
340392 try {
341393 tidyScheduledFutures ();
342- ScheduledFuture <?> future = scheduler .schedule (job , sleepTimeMs , TimeUnit .MILLISECONDS );
343- scheduledFutures .add (future );
394+ ScheduledFuture <?> future = scheduledFutures .get (identifier );
395+ if (future != null && !future .isDone ()) {
396+ // The event is already scheduled
397+ long delay ;
398+ if ((delay = future .getDelay (TimeUnit .MILLISECONDS )) < MIN_TIME_TO_SCHEDULE_MS
399+ && Math .abs (delay - sleepTimeMs ) <= MAX_SCHEDULE_DIFFERENCE_MS ) {
400+ // if the previously scheduled event is about to run very soon and their schedules are similar,
401+ // we don't know if we can cancel it in time, so we let it run and don't schedule the new one.
402+ return ;
403+ }
404+ future .cancel (true );
405+ }
406+ future = scheduler .schedule (job , sleepTimeMs , TimeUnit .MILLISECONDS );
407+ scheduledFutures .put (identifier , future );
344408 } finally {
345409 monitor .unlock ();
346410 }
@@ -349,11 +413,11 @@ private void schedule(Job job, long sleepTimeMs) {
349413 /**
350414 * Adds the provided {@link Job} to the queue (cannot be {@code null})
351415 */
352- public void schedule (Job job , Calendar eventAt ) {
416+ public void schedule (String identifier , Job job , Calendar eventAt ) {
353417 // We don't use instantSource here, because we always want to schedule relative to the system clock
354418 long sleepTime = eventAt .getTimeInMillis () - System .currentTimeMillis ();
355419 if (sleepTime >= 0L ) {
356- schedule (job , sleepTime );
420+ schedule (identifier , job , sleepTime );
357421 if (logger .isDebugEnabled ()) {
358422 final String formattedDate = this .loggerFormatter .format (eventAt .getTime ());
359423 logger .debug ("Scheduled {} in {}ms (at {})" , job , sleepTime , formattedDate );
@@ -364,11 +428,11 @@ public void schedule(Job job, Calendar eventAt) {
364428 }
365429 }
366430
367- public void schedule (Job job , Instant eventAt ) {
431+ public void schedule (String identifier , Job job , Instant eventAt ) {
368432 // We don't use instantSource here, because we always want to schedule relative to the system clock
369433 long sleepTime = eventAt .toEpochMilli () + 1L - System .currentTimeMillis ();
370434 if (sleepTime >= 0L ) {
371- schedule (job , sleepTime );
435+ schedule (identifier , job , sleepTime );
372436 if (logger .isDebugEnabled ()) {
373437 logger .debug ("Scheduled {} in {}ms (at {})" , job , sleepTime , eventAt .atZone (ZoneId .systemDefault ()));
374438 }
@@ -381,8 +445,9 @@ public void schedule(Job job, Instant eventAt) {
381445 private void tidyScheduledFutures () {
382446 monitor .lock ();
383447 try {
384- for (Iterator <ScheduledFuture <?>> iterator = scheduledFutures .iterator (); iterator .hasNext ();) {
385- ScheduledFuture <?> future = iterator .next ();
448+ ScheduledFuture <?> future ;
449+ for (Iterator <ScheduledFuture <?>> iterator = scheduledFutures .values ().iterator (); iterator .hasNext ();) {
450+ future = iterator .next ();
386451 if (future .isDone ()) {
387452 logger .trace ("Tidying up done future {}" , future );
388453 iterator .remove ();
0 commit comments