001/**
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.activemq.store.kahadb.scheduler;
018
019import java.io.DataInput;
020import java.io.DataOutput;
021import java.io.IOException;
022import java.util.ArrayList;
023import java.util.HashMap;
024import java.util.Iterator;
025import java.util.List;
026import java.util.Map;
027import java.util.concurrent.CopyOnWriteArrayList;
028import java.util.concurrent.atomic.AtomicBoolean;
029
030import javax.jms.MessageFormatException;
031
032import org.apache.activemq.broker.scheduler.CronParser;
033import org.apache.activemq.broker.scheduler.Job;
034import org.apache.activemq.broker.scheduler.JobListener;
035import org.apache.activemq.broker.scheduler.JobScheduler;
036import org.apache.activemq.protobuf.Buffer;
037import org.apache.activemq.store.kahadb.data.KahaAddScheduledJobCommand;
038import org.apache.activemq.store.kahadb.data.KahaRemoveScheduledJobCommand;
039import org.apache.activemq.store.kahadb.data.KahaRemoveScheduledJobsCommand;
040import org.apache.activemq.store.kahadb.data.KahaRescheduleJobCommand;
041import org.apache.activemq.store.kahadb.disk.index.BTreeIndex;
042import org.apache.activemq.store.kahadb.disk.journal.Location;
043import org.apache.activemq.store.kahadb.disk.page.Transaction;
044import org.apache.activemq.store.kahadb.disk.util.LongMarshaller;
045import org.apache.activemq.util.ByteSequence;
046import org.apache.activemq.util.IdGenerator;
047import org.apache.activemq.util.ServiceStopper;
048import org.apache.activemq.util.ServiceSupport;
049import org.slf4j.Logger;
050import org.slf4j.LoggerFactory;
051
052public class JobSchedulerImpl extends ServiceSupport implements Runnable, JobScheduler {
053
054    private static final Logger LOG = LoggerFactory.getLogger(JobSchedulerImpl.class);
055    private final JobSchedulerStoreImpl store;
056    private final AtomicBoolean running = new AtomicBoolean();
057    private String name;
058    private BTreeIndex<Long, List<JobLocation>> index;
059    private Thread thread;
060    private final AtomicBoolean started = new AtomicBoolean(false);
061    private final List<JobListener> jobListeners = new CopyOnWriteArrayList<>();
062    private static final IdGenerator ID_GENERATOR = new IdGenerator();
063    private final ScheduleTime scheduleTime = new ScheduleTime();
064
065    JobSchedulerImpl(JobSchedulerStoreImpl store) {
066        this.store = store;
067    }
068
069    public void setName(String name) {
070        this.name = name;
071    }
072
073    @Override
074    public String getName() {
075        return this.name;
076    }
077
078    @Override
079    public void addListener(JobListener l) {
080        this.jobListeners.add(l);
081    }
082
083    @Override
084    public void removeListener(JobListener l) {
085        this.jobListeners.remove(l);
086    }
087
088    @Override
089    public void schedule(final String jobId, final ByteSequence payload, final long delay) throws IOException {
090        doSchedule(jobId, payload, "", 0, delay, 0);
091    }
092
093    @Override
094    public void schedule(final String jobId, final ByteSequence payload, final String cronEntry) throws Exception {
095        doSchedule(jobId, payload, cronEntry, 0, 0, 0);
096    }
097
098    @Override
099    public void schedule(final String jobId, final ByteSequence payload, final String cronEntry, final long delay, final long period, final int repeat) throws IOException {
100        doSchedule(jobId, payload, cronEntry, delay, period, repeat);
101    }
102
103    @Override
104    public void remove(final long time) throws IOException {
105        doRemoveRange(time, time);
106    }
107
108    @Override
109    public void remove(final String jobId) throws IOException {
110        doRemove(-1, jobId);
111    }
112
113    @Override
114    public void removeAllJobs() throws IOException {
115        doRemoveRange(0, Long.MAX_VALUE);
116    }
117
118    @Override
119    public void removeAllJobs(final long start, final long finish) throws IOException {
120        doRemoveRange(start, finish);
121    }
122
123    @Override
124    public long getNextScheduleTime() throws IOException {
125        this.store.readLockIndex();
126        try {
127            Map.Entry<Long, List<JobLocation>> first = this.index.getFirst(this.store.getPageFile().tx());
128            return first != null ? first.getKey() : -1l;
129        } finally {
130            this.store.readUnlockIndex();
131        }
132    }
133
134    @Override
135    public List<Job> getNextScheduleJobs() throws IOException {
136        final List<Job> result = new ArrayList<>();
137        this.store.readLockIndex();
138        try {
139            this.store.getPageFile().tx().execute(new Transaction.Closure<IOException>() {
140                @Override
141                public void execute(Transaction tx) throws IOException {
142                    Map.Entry<Long, List<JobLocation>> first = index.getFirst(tx);
143                    if (first != null) {
144                        for (JobLocation jl : first.getValue()) {
145                            ByteSequence bs = getPayload(jl.getLocation());
146                            Job job = new JobImpl(jl, bs);
147                            result.add(job);
148                        }
149                    }
150                }
151            });
152        } finally {
153            this.store.readUnlockIndex();
154        }
155        return result;
156    }
157
158    @Override
159    public List<Job> getAllJobs() throws IOException {
160        final List<Job> result = new ArrayList<>();
161        this.store.readLockIndex();
162        try {
163            this.store.getPageFile().tx().execute(new Transaction.Closure<IOException>() {
164                @Override
165                public void execute(Transaction tx) throws IOException {
166                    Iterator<Map.Entry<Long, List<JobLocation>>> iter = index.iterator(store.getPageFile().tx());
167                    while (iter.hasNext()) {
168                        Map.Entry<Long, List<JobLocation>> next = iter.next();
169                        if (next != null) {
170                            for (JobLocation jl : next.getValue()) {
171                                ByteSequence bs = getPayload(jl.getLocation());
172                                Job job = new JobImpl(jl, bs);
173                                result.add(job);
174                            }
175                        } else {
176                            break;
177                        }
178                    }
179                }
180            });
181        } finally {
182            this.store.readUnlockIndex();
183        }
184        return result;
185    }
186
187    @Override
188    public List<Job> getAllJobs(final long start, final long finish) throws IOException {
189        final List<Job> result = new ArrayList<>();
190        this.store.readLockIndex();
191        try {
192            this.store.getPageFile().tx().execute(new Transaction.Closure<IOException>() {
193                @Override
194                public void execute(Transaction tx) throws IOException {
195                    Iterator<Map.Entry<Long, List<JobLocation>>> iter = index.iterator(tx, start);
196                    while (iter.hasNext()) {
197                        Map.Entry<Long, List<JobLocation>> next = iter.next();
198                        if (next != null && next.getKey().longValue() <= finish) {
199                            for (JobLocation jl : next.getValue()) {
200                                ByteSequence bs = getPayload(jl.getLocation());
201                                Job job = new JobImpl(jl, bs);
202                                result.add(job);
203                            }
204                        } else {
205                            break;
206                        }
207                    }
208                }
209            });
210        } finally {
211            this.store.readUnlockIndex();
212        }
213        return result;
214    }
215
216    private void doSchedule(final String jobId, final ByteSequence payload, final String cronEntry, long delay, long period, int repeat) throws IOException {
217        long startTime = System.currentTimeMillis();
218        // round startTime - so we can schedule more jobs at the same time
219        startTime = ((startTime + 500) / 500) * 500;
220
221        long time = 0;
222        if (cronEntry != null && cronEntry.length() > 0) {
223            try {
224                time = CronParser.getNextScheduledTime(cronEntry, startTime);
225            } catch (MessageFormatException e) {
226                throw new IOException(e.getMessage());
227            }
228        }
229
230        if (time == 0) {
231            // start time not set by CRON - so it it to the current time
232            time = startTime;
233        }
234
235        if (delay > 0) {
236            time += delay;
237        } else {
238            time += period;
239        }
240
241        KahaAddScheduledJobCommand newJob = new KahaAddScheduledJobCommand();
242        newJob.setScheduler(name);
243        newJob.setJobId(jobId);
244        newJob.setStartTime(startTime);
245        newJob.setCronEntry(cronEntry);
246        newJob.setDelay(delay);
247        newJob.setPeriod(period);
248        newJob.setRepeat(repeat);
249        newJob.setNextExecutionTime(time);
250        newJob.setPayload(new Buffer(payload.getData(), payload.getOffset(), payload.getLength()));
251
252        this.store.store(newJob);
253    }
254
255    private void doReschedule(List<Closure> toReschedule) throws IOException {
256        for (Closure closure : toReschedule) {
257            closure.run();
258        }
259    }
260
261    private void doReschedule(final String jobId, long executionTime, long nextExecutionTime, int rescheduledCount) throws IOException {
262        KahaRescheduleJobCommand update = new KahaRescheduleJobCommand();
263        update.setScheduler(name);
264        update.setJobId(jobId);
265        update.setExecutionTime(executionTime);
266        update.setNextExecutionTime(nextExecutionTime);
267        update.setRescheduledCount(rescheduledCount);
268        this.store.store(update);
269    }
270
271    private void doRemove(final List<Closure> toRemove) throws IOException {
272        for (Closure closure : toRemove) {
273            closure.run();
274        }
275    }
276
277    private void doRemove(long executionTime, final String jobId) throws IOException {
278        KahaRemoveScheduledJobCommand remove = new KahaRemoveScheduledJobCommand();
279        remove.setScheduler(name);
280        remove.setJobId(jobId);
281        remove.setNextExecutionTime(executionTime);
282        this.store.store(remove);
283    }
284
285    private void doRemoveRange(long start, long end) throws IOException {
286        KahaRemoveScheduledJobsCommand destroy = new KahaRemoveScheduledJobsCommand();
287        destroy.setScheduler(name);
288        destroy.setStartTime(start);
289        destroy.setEndTime(end);
290        this.store.store(destroy);
291    }
292
293    /**
294     * Adds a new Scheduled job to the index.  Must be called under index lock.
295     *
296     * This method must ensure that a duplicate add is not processed into the scheduler.  On index
297     * recover some adds may be replayed and we don't allow more than one instance of a JobId to
298     * exist at any given scheduled time, so filter these out to ensure idempotence.
299     *
300     * @param tx
301     *      Transaction in which the update is performed.
302     * @param command
303     *      The new scheduled job command to process.
304     * @param location
305     *      The location where the add command is stored in the journal.
306     *
307     * @throws IOException if an error occurs updating the index.
308     */
309    protected void process(final Transaction tx, final KahaAddScheduledJobCommand command, Location location) throws IOException {
310        JobLocation jobLocation = new JobLocation(location);
311        jobLocation.setJobId(command.getJobId());
312        jobLocation.setStartTime(command.getStartTime());
313        jobLocation.setCronEntry(command.getCronEntry());
314        jobLocation.setDelay(command.getDelay());
315        jobLocation.setPeriod(command.getPeriod());
316        jobLocation.setRepeat(command.getRepeat());
317
318        long nextExecutionTime = command.getNextExecutionTime();
319
320        List<JobLocation> values = null;
321        jobLocation.setNextTime(nextExecutionTime);
322        if (this.index.containsKey(tx, nextExecutionTime)) {
323            values = this.index.remove(tx, nextExecutionTime);
324        }
325        if (values == null) {
326            values = new ArrayList<>();
327        }
328
329        // There can never be more than one instance of the same JobId scheduled at any
330        // given time, when it happens its probably the result of index recovery and this
331        // method must be idempotent so check for it first.
332        if (!values.contains(jobLocation)) {
333            values.add(jobLocation);
334
335            // Reference the log file where the add command is stored to prevent GC.
336            this.store.incrementJournalCount(tx, location);
337            this.index.put(tx, nextExecutionTime, values);
338            this.scheduleTime.newJob();
339        } else {
340            this.index.put(tx, nextExecutionTime, values);
341            LOG.trace("Job {} already in scheduler at this time {}",
342                      jobLocation.getJobId(), jobLocation.getNextTime());
343        }
344    }
345
346    /**
347     * Reschedules a Job after it has be fired.
348     *
349     * For jobs that are repeating this method updates the job in the index by adding it to the
350     * jobs list for the new execution time.  If the job is not a cron type job then this method
351     * will reduce the repeat counter if the job has a fixed number of repeats set.  The Job will
352     * be removed from the jobs list it just executed on.
353     *
354     * This method must also update the value of the last update location in the JobLocation
355     * instance so that the checkpoint worker doesn't drop the log file in which that command lives.
356     *
357     * This method must ensure that an reschedule command that references a job that doesn't exist
358     * does not cause an error since it's possible that on recover the original add might be gone
359     * and so the job should not reappear in the scheduler.
360     *
361     * @param tx
362     *      The TX under which the index is updated.
363     * @param command
364     *      The reschedule command to process.
365     * @param location
366     *      The location in the index where the reschedule command was stored.
367     *
368     * @throws IOException if an error occurs during the reschedule.
369     */
370    protected void process(final Transaction tx, final KahaRescheduleJobCommand command, Location location) throws IOException {
371        JobLocation result = null;
372        final List<JobLocation> current = this.index.remove(tx, command.getExecutionTime());
373        if (current != null) {
374            for (int i = 0; i < current.size(); i++) {
375                JobLocation jl = current.get(i);
376                if (jl.getJobId().equals(command.getJobId())) {
377                    current.remove(i);
378                    if (!current.isEmpty()) {
379                        this.index.put(tx, command.getExecutionTime(), current);
380                    }
381                    result = jl;
382                    break;
383                }
384            }
385        } else {
386            LOG.debug("Process reschedule command for job {} non-existent executime time {}.",
387                      command.getJobId(), command.getExecutionTime());
388        }
389
390        if (result != null) {
391            Location previousUpdate = result.getLastUpdate();
392
393            List<JobLocation> target = null;
394            result.setNextTime(command.getNextExecutionTime());
395            result.setLastUpdate(location);
396            result.setRescheduledCount(command.getRescheduledCount());
397            if (!result.isCron() && result.getRepeat() > 0) {
398                result.setRepeat(result.getRepeat() - 1);
399            }
400            if (this.index.containsKey(tx, command.getNextExecutionTime())) {
401                target = this.index.remove(tx, command.getNextExecutionTime());
402            }
403            if (target == null) {
404                target = new ArrayList<>();
405            }
406            target.add(result);
407
408            // Track the location of the last reschedule command and release the log file
409            // reference for the previous one if there was one.
410            this.store.incrementJournalCount(tx, location);
411            if (previousUpdate != null) {
412                this.store.decrementJournalCount(tx, previousUpdate);
413            }
414
415            this.index.put(tx, command.getNextExecutionTime(), target);
416            this.scheduleTime.newJob();
417        } else {
418            LOG.debug("Process reschedule command for non-scheduled job {} at executime time {}.",
419                      command.getJobId(), command.getExecutionTime());
420        }
421    }
422
423    /**
424     * Removes a scheduled job from the scheduler.
425     *
426     * The remove operation can be of two forms.  The first is that there is a job Id but no set time
427     * (-1) in which case the jobs index is searched until the target job Id is located.  The alternate
428     * form is that a job Id and execution time are both set in which case the given time is checked
429     * for a job matching that Id.  In either case once an execution time is identified the job is
430     * removed and the index updated.
431     *
432     * This method should ensure that if the matching job is not found that no error results as it
433     * is possible that on a recover the initial add command could be lost so the job may not be
434     * rescheduled.
435     *
436     * @param tx
437     *      The transaction under which the index is updated.
438     * @param command
439     *      The remove command to process.
440     * @param location
441     *      The location of the remove command in the Journal.
442     *
443     * @throws IOException if an error occurs while updating the scheduler index.
444     */
445    void process(final Transaction tx, final KahaRemoveScheduledJobCommand command, Location location) throws IOException {
446
447        // Case 1: JobId and no time value means find the job and remove it.
448        // Case 2: JobId and a time value means find exactly this scheduled job.
449
450        Long executionTime = command.getNextExecutionTime();
451
452        List<JobLocation> values = null;
453
454        if (executionTime == -1) {
455            for (Iterator<Map.Entry<Long, List<JobLocation>>> i = this.index.iterator(tx); i.hasNext();) {
456                Map.Entry<Long, List<JobLocation>> entry = i.next();
457                List<JobLocation> candidates = entry.getValue();
458                if (candidates != null) {
459                    for (JobLocation jl : candidates) {
460                        if (jl.getJobId().equals(command.getJobId())) {
461                            LOG.trace("Entry {} contains the remove target: {}", entry.getKey(), command.getJobId());
462                            executionTime = entry.getKey();
463                            values = this.index.remove(tx, executionTime);
464                            break;
465                        }
466                    }
467                }
468            }
469        } else {
470            values = this.index.remove(tx, executionTime);
471        }
472
473        JobLocation removed = null;
474
475        // Remove the job and update the index if there are any other jobs scheduled at this time.
476        if (values != null) {
477            for (JobLocation job : values) {
478                if (job.getJobId().equals(command.getJobId())) {
479                    removed = job;
480                    values.remove(removed);
481                    break;
482                }
483            }
484
485            if (!values.isEmpty()) {
486                this.index.put(tx, executionTime, values);
487            }
488        }
489
490        if (removed != null) {
491            LOG.trace("{} removed from scheduler {}", removed, this);
492
493            // Remove the references for add and reschedule commands for this job
494            // so that those logs can be GC'd when free.
495            this.store.decrementJournalCount(tx, removed.getLocation());
496            if (removed.getLastUpdate() != null) {
497                this.store.decrementJournalCount(tx, removed.getLastUpdate());
498            }
499
500            // now that the job is removed from the index we can store the remove info and
501            // then dereference the log files that hold the initial add command and the most
502            // recent update command.  If the remove and the add that created the job are in
503            // the same file we don't need to track it and just let a normal GC of the logs
504            // remove it when the log is unreferenced.
505            if (removed.getLocation().getDataFileId() != location.getDataFileId()) {
506                this.store.referenceRemovedLocation(tx, location, removed);
507            }
508        }
509    }
510
511    /**
512     * Removes all scheduled jobs within a given time range.
513     *
514     * The method can be used to clear the entire scheduler index by specifying a range that
515     * encompasses all time [0...Long.MAX_VALUE] or a single execution time can be removed by
516     * setting start and end time to the same value.
517     *
518     * @param tx
519     *      The transaction under which the index is updated.
520     * @param command
521     *      The remove command to process.
522     * @param location
523     *      The location of the remove command in the Journal.
524     *
525     * @throws IOException if an error occurs while updating the scheduler index.
526     */
527    protected void process(final Transaction tx, final KahaRemoveScheduledJobsCommand command, Location location) throws IOException {
528        removeInRange(tx, command.getStartTime(), command.getEndTime(), location);
529    }
530
531    /**
532     * Removes all jobs from the schedulers index.  Must be called with the index locked.
533     *
534     * @param tx
535     *      The transaction under which the index entries for this scheduler are removed.
536     *
537     * @throws IOException if an error occurs removing the jobs from the scheduler index.
538     */
539    protected void removeAll(Transaction tx) throws IOException {
540        this.removeInRange(tx, 0, Long.MAX_VALUE, null);
541    }
542
543    /**
544     * Removes all scheduled jobs within the target range.
545     *
546     * This method can be used to remove all the stored jobs by passing a range of [0...Long.MAX_VALUE]
547     * or it can be used to remove all jobs at a given scheduled time by passing the same time value
548     * for both start and end.  If the optional location parameter is set then this method will update
549     * the store's remove location tracker with the location value and the Jobs that are being removed.
550     *
551     * This method must be called with the store index locked for writes.
552     *
553     * @param tx
554     *      The transaction under which the index is to be updated.
555     * @param start
556     *      The start time for the remove operation.
557     * @param finish
558     *      The end time for the remove operation.
559     * @param location (optional)
560     *      The location of the remove command that triggered this remove.
561     *
562     * @throws IOException if an error occurs during the remove operation.
563     */
564    protected void removeInRange(Transaction tx, long start, long finish, Location location) throws IOException {
565        List<Long> keys = new ArrayList<>();
566        for (Iterator<Map.Entry<Long, List<JobLocation>>> i = this.index.iterator(tx, start); i.hasNext();) {
567            Map.Entry<Long, List<JobLocation>> entry = i.next();
568            if (entry.getKey().longValue() <= finish) {
569                keys.add(entry.getKey());
570            } else {
571                break;
572            }
573        }
574
575        List<Integer> removedJobFileIds = new ArrayList<>();
576        HashMap<Integer, Integer> decrementJournalCount = new HashMap<>();
577
578        for (Long executionTime : keys) {
579            List<JobLocation> values = this.index.remove(tx, executionTime);
580            if (location != null) {
581                for (JobLocation job : values) {
582                    LOG.trace("Removing {} scheduled at: {}", job, executionTime);
583
584                    // Remove the references for add and reschedule commands for this job
585                    // so that those logs can be GC'd when free.
586                    decrementJournalCount.compute(job.getLocation().getDataFileId(), (key, value) -> value == null ? 1 : value + 1);
587                    if (job.getLastUpdate() != null) {
588                        decrementJournalCount.compute(job.getLastUpdate().getDataFileId(), (key, value) -> value == null ? 1 : value + 1);
589                    }
590
591                    // now that the job is removed from the index we can store the remove info and
592                    // then dereference the log files that hold the initial add command and the most
593                    // recent update command.  If the remove and the add that created the job are in
594                    // the same file we don't need to track it and just let a normal GC of the logs
595                    // remove it when the log is unreferenced.
596                    if (job.getLocation().getDataFileId() != location.getDataFileId()) {
597                        removedJobFileIds.add(job.getLocation().getDataFileId());
598                    }
599                }
600            }
601        }
602
603        if (!removedJobFileIds.isEmpty()) {
604            this.store.referenceRemovedLocation(tx, location, removedJobFileIds);
605        }
606
607        if (decrementJournalCount.size() > 0) {
608            this.store.decrementJournalCount(tx, decrementJournalCount);
609        }
610    }
611
612    /**
613     * Removes a Job from the index using it's Id value and the time it is currently set to
614     * be executed.  This method will only remove the Job if it is found at the given execution
615     * time.
616     *
617     * This method must be called under index lock.
618     *
619     * @param tx
620     *        the transaction under which this method is being executed.
621     * @param jobId
622     *        the target Job Id to remove.
623     * @param executionTime
624     *        the scheduled time that for the Job Id that is being removed.
625     *
626     * @returns true if the Job was removed or false if not found at the given time.
627     *
628     * @throws IOException if an error occurs while removing the Job.
629     */
630    protected boolean removeJobAtTime(Transaction tx, String jobId, long executionTime) throws IOException {
631        boolean result = false;
632
633        List<JobLocation> jobs = this.index.remove(tx, executionTime);
634        Iterator<JobLocation> jobsIter = jobs.iterator();
635        while (jobsIter.hasNext()) {
636            JobLocation job = jobsIter.next();
637            if (job.getJobId().equals(jobId)) {
638                jobsIter.remove();
639                // Remove the references for add and reschedule commands for this job
640                // so that those logs can be GC'd when free.
641                this.store.decrementJournalCount(tx, job.getLocation());
642                if (job.getLastUpdate() != null) {
643                    this.store.decrementJournalCount(tx, job.getLastUpdate());
644                }
645                result = true;
646                break;
647            }
648        }
649
650        // Return the list to the index modified or unmodified.
651        this.index.put(tx, executionTime, jobs);
652
653        return result;
654    }
655
656    /**
657     * Walks the Scheduled Job Tree and collects the add location and last update location
658     * for all scheduled jobs.
659     *
660     * This method must be called with the index locked.
661     *
662     * @param tx
663     *        the transaction under which this operation was invoked.
664     *
665     * @return a iterator of all referenced Location values for this JobSchedulerImpl
666     *
667     * @throws IOException if an error occurs walking the scheduler tree.
668     */
669    protected Iterator<JobLocation> getAllScheduledJobs(Transaction tx) throws IOException {
670        return new Iterator<JobLocation>() {
671
672            final Iterator<Map.Entry<Long, List<JobLocation>>> mapIterator = index.iterator(tx);
673            Iterator<JobLocation> iterator;
674
675            @Override
676            public boolean hasNext() {
677
678                while (iterator == null || !iterator.hasNext()) {
679                    if (!mapIterator.hasNext()) {
680                        break;
681                    }
682
683                    iterator = new ArrayList<>(mapIterator.next().getValue()).iterator();
684                }
685
686                return iterator != null && iterator.hasNext();
687            }
688
689            @Override
690            public JobLocation next() {
691                return iterator.next();
692            }
693        };
694    }
695
696    @Override
697    public void run() {
698        try {
699            mainLoop();
700        } catch (Throwable e) {
701            if (this.running.get() && isStarted()) {
702                LOG.error("{} Caught exception in mainloop", this, e);
703            }
704        } finally {
705            if (running.get()) {
706                try {
707                    stop();
708                } catch (Exception e) {
709                    LOG.error("Failed to stop {}", this);
710                }
711            }
712        }
713    }
714
715    @Override
716    public String toString() {
717        return "JobScheduler: " + this.name;
718    }
719
720    protected void mainLoop() {
721        while (this.running.get()) {
722            this.scheduleTime.clearNewJob();
723            try {
724                long currentTime = System.currentTimeMillis();
725
726                // Read the list of scheduled events and fire the jobs, reschedule repeating jobs as
727                // needed before firing the job event.
728                List<Closure> toRemove = new ArrayList<>();
729                List<Closure> toReschedule = new ArrayList<>();
730                try {
731                    this.store.readLockIndex();
732
733                    Iterator<Map.Entry<Long, List<JobLocation>>> iterator = this.index.iterator(this.store.getPageFile().tx());
734                    while (iterator.hasNext()) {
735                        Map.Entry<Long, List<JobLocation>> next = iterator.next();
736                        if (next != null) {
737                            List<JobLocation> list = new ArrayList<>(next.getValue());
738                            final long executionTime = next.getKey();
739                            long nextExecutionTime = 0;
740
741                            if (executionTime <= currentTime) {
742                                for (final JobLocation job : list) {
743
744                                    if (!running.get()) {
745                                        break;
746                                    }
747
748                                    int repeat = job.getRepeat();
749                                    nextExecutionTime = calculateNextExecutionTime(job, currentTime, repeat);
750                                    long waitTime = nextExecutionTime - currentTime;
751                                    this.scheduleTime.setWaitTime(waitTime);
752                                    if (!job.isCron()) {
753                                        fireJob(job);
754                                        if (repeat != 0) {
755                                            // Reschedule for the next time, the scheduler will take care of
756                                            // updating the repeat counter on the update.
757                                            final long finalNextExecutionTime = nextExecutionTime;
758                                            toReschedule.add(() -> doReschedule(job.getJobId(), executionTime, finalNextExecutionTime, job.getRescheduledCount() + 1));
759                                        } else {
760                                            toRemove.add(() -> doRemove(executionTime, job.getJobId()));
761                                        }
762                                    } else {
763                                        if (repeat == 0) {
764                                            // This is a non-repeating Cron entry so we can fire and forget it.
765                                            fireJob(job);
766                                        }
767
768                                        if (nextExecutionTime > currentTime) {
769                                            // Reschedule the cron job as a new event, if the cron entry signals
770                                            // a repeat then it will be stored separately and fired as a normal
771                                            // event with decrementing repeat.
772                                            final long finalNextExecutionTime = nextExecutionTime;
773                                            toReschedule.add(() -> doReschedule(job.getJobId(), executionTime, finalNextExecutionTime, job.getRescheduledCount() + 1));
774
775                                            if (repeat != 0) {
776                                                // we have a separate schedule to run at this time
777                                                // so the cron job is used to set of a separate schedule
778                                                // hence we won't fire the original cron job to the
779                                                // listeners but we do need to start a separate schedule
780                                                String jobId = ID_GENERATOR.generateId();
781                                                ByteSequence payload = getPayload(job.getLocation());
782                                                schedule(jobId, payload, "", job.getDelay(), job.getPeriod(), job.getRepeat());
783                                                waitTime = job.getDelay() != 0 ? job.getDelay() : job.getPeriod();
784                                                this.scheduleTime.setWaitTime(waitTime);
785                                            }
786                                        } else {
787                                            toRemove.add(() -> doRemove(executionTime, job.getJobId()));
788                                        }
789                                    }
790                                }
791                            } else {
792                                this.scheduleTime.setWaitTime(executionTime - currentTime);
793                                break;
794                            }
795                        }
796                    }
797                } finally {
798                    this.store.readUnlockIndex();
799
800                    doReschedule(toReschedule);
801
802                    // now remove all jobs that have not been rescheduled,
803                    // if there are no more entries in that time it will be removed.
804                    doRemove(toRemove);
805                }
806
807                this.scheduleTime.pause();
808            } catch (Exception ioe) {
809                LOG.error("{} Failed to schedule job", this.name, ioe);
810                try {
811                    this.store.stop();
812                } catch (Exception e) {
813                    LOG.error("{} Failed to shutdown JobSchedulerStore", this.name, e);
814                }
815            }
816        }
817    }
818
819    void fireJob(JobLocation job) throws IllegalStateException, IOException {
820        LOG.debug("Firing: {}", job);
821        ByteSequence bs = this.store.getPayload(job.getLocation());
822        for (JobListener l : jobListeners) {
823            l.scheduledJob(job.getJobId(), bs);
824        }
825    }
826
827    @Override
828    public void startDispatching() throws Exception {
829        if (!this.running.get()) {
830            return;
831        }
832
833        if (started.compareAndSet(false, true)) {
834            this.thread = new Thread(this, "JobScheduler:" + this.name);
835            this.thread.setDaemon(true);
836            this.thread.start();
837        }
838    }
839
840    @Override
841    public void stopDispatching() throws Exception {
842        if (started.compareAndSet(true, false)) {
843            this.scheduleTime.wakeup();
844            Thread t = this.thread;
845            this.thread = null;
846            if (t != null) {
847                t.join(3000);
848            }
849        }
850    }
851
852    @Override
853    protected void doStart() throws Exception {
854        this.running.set(true);
855    }
856
857    @Override
858    protected void doStop(ServiceStopper stopper) throws Exception {
859        this.running.set(false);
860        stopDispatching();
861    }
862
863    private ByteSequence getPayload(Location location) throws IllegalStateException, IOException {
864        return this.store.getPayload(location);
865    }
866
867    long calculateNextExecutionTime(final JobLocation job, long currentTime, int repeat) throws MessageFormatException {
868        long result = currentTime;
869        String cron = job.getCronEntry();
870        if (cron != null && cron.length() > 0) {
871            result = CronParser.getNextScheduledTime(cron, result);
872        } else if (job.getRepeat() != 0) {
873            result += job.getPeriod();
874        }
875        return result;
876    }
877
878    void createIndexes(Transaction tx) throws IOException {
879        this.index = new BTreeIndex<>(this.store.getPageFile(), tx.allocate().getPageId());
880    }
881
882    void load(Transaction tx) throws IOException {
883        this.index.setKeyMarshaller(LongMarshaller.INSTANCE);
884        this.index.setValueMarshaller(JobLocationsMarshaller.INSTANCE);
885        this.index.load(tx);
886    }
887
888    void read(DataInput in) throws IOException {
889        this.name = in.readUTF();
890        this.index = new BTreeIndex<>(this.store.getPageFile(), in.readLong());
891        this.index.setKeyMarshaller(LongMarshaller.INSTANCE);
892        this.index.setValueMarshaller(JobLocationsMarshaller.INSTANCE);
893    }
894
895    public void write(DataOutput out) throws IOException {
896        out.writeUTF(name);
897        out.writeLong(this.index.getPageId());
898    }
899
900    private interface Closure {
901        void run() throws IOException;
902    }
903
904    static class ScheduleTime {
905        private final int DEFAULT_WAIT = 500;
906        private final int DEFAULT_NEW_JOB_WAIT = 100;
907        private boolean newJob;
908        private long waitTime = DEFAULT_WAIT;
909        private final Object mutex = new Object();
910
911        /**
912         * @return the waitTime
913         */
914        long getWaitTime() {
915            return this.waitTime;
916        }
917
918        /**
919         * @param waitTime
920         *            the waitTime to set
921         */
922        void setWaitTime(long waitTime) {
923            if (!this.newJob) {
924                this.waitTime = waitTime > 0 ? waitTime : DEFAULT_WAIT;
925            }
926        }
927
928        void pause() {
929            synchronized (mutex) {
930                try {
931                    mutex.wait(this.waitTime);
932                } catch (InterruptedException e) {
933                }
934            }
935        }
936
937        void newJob() {
938            this.newJob = true;
939            this.waitTime = DEFAULT_NEW_JOB_WAIT;
940            wakeup();
941        }
942
943        void clearNewJob() {
944            this.newJob = false;
945        }
946
947        void wakeup() {
948            synchronized (this.mutex) {
949                mutex.notifyAll();
950            }
951        }
952    }
953}