Skip to content

Commit b0e8106

Browse files
authored
[astro] Refactor event scheduling (#20078)
* Refactor event scheduling to handle if the event already exists Signed-off-by: Ravi Nadahar <nadahar@rediffmail.com>
1 parent c5b618c commit b0e8106

7 files changed

Lines changed: 260 additions & 192 deletions

File tree

bundles/org.openhab.binding.astro/src/main/java/org/openhab/binding/astro/internal/AstroBindingConstants.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,11 @@ private AstroBindingConstants() {
163163
public static final String EVENT_CHANNEL_ID_CIVIL_DUSK = "civilDusk#event";
164164
public static final String EVENT_CHANNEL_ID_EVENING_NIGHT = "eveningNight#event";
165165
public static final String EVENT_CHANNEL_ID_DAYLIGHT = "daylight#event";
166+
167+
// job identifiers
168+
169+
public static final String DAILY_JOB = "daily";
170+
public static final String POSITIONAL_JOB = "positional";
171+
public static final String PUBLISH_ZODIAC_JOB = "publishZodiac";
172+
public static final String PUBLISH_SEASON_JOB = "publishSeason";
166173
}

bundles/org.openhab.binding.astro/src/main/java/org/openhab/binding/astro/internal/handler/AstroThingHandler.java

Lines changed: 90 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@
2828
import java.util.Calendar;
2929
import java.util.Collection;
3030
import java.util.GregorianCalendar;
31-
import java.util.HashSet;
31+
import java.util.HashMap;
3232
import java.util.Iterator;
3333
import java.util.List;
3434
import java.util.Locale;
35-
import java.util.Set;
35+
import java.util.Map;
3636
import java.util.TimeZone;
3737
import java.util.concurrent.ScheduledFuture;
3838
import java.util.concurrent.TimeUnit;
@@ -41,6 +41,7 @@
4141

4242
import org.eclipse.jdt.annotation.NonNullByDefault;
4343
import org.eclipse.jdt.annotation.Nullable;
44+
import org.openhab.binding.astro.internal.AstroBindingConstants;
4445
import org.openhab.binding.astro.internal.action.AstroActions;
4546
import org.openhab.binding.astro.internal.config.AstroChannelConfig;
4647
import org.openhab.binding.astro.internal.config.AstroThingConfig;
@@ -55,7 +56,6 @@
5556
import org.openhab.core.library.types.DecimalType;
5657
import org.openhab.core.library.types.StringType;
5758
import org.openhab.core.scheduler.CronScheduler;
58-
import org.openhab.core.scheduler.ScheduledCompletableFuture;
5959
import org.openhab.core.thing.Channel;
6060
import org.openhab.core.thing.ChannelUID;
6161
import org.openhab.core.thing.Thing;
@@ -71,13 +71,73 @@
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
78120
public 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();

bundles/org.openhab.binding.astro/src/main/java/org/openhab/binding/astro/internal/job/CompositeJob.java

Lines changed: 0 additions & 65 deletions
This file was deleted.

0 commit comments

Comments
 (0)