/* * Copyright © 2015-2016 Cask Data, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ package co.cask.cdap.internal.app.runtime.schedule; import co.cask.cdap.api.app.ApplicationSpecification; import co.cask.cdap.api.schedule.SchedulableProgramType; import co.cask.cdap.api.schedule.Schedule; import co.cask.cdap.api.schedule.ScheduleSpecification; import co.cask.cdap.app.store.Store; import co.cask.cdap.common.NotFoundException; import co.cask.cdap.internal.app.services.ProgramLifecycleService; import co.cask.cdap.internal.app.services.PropertiesResolver; import co.cask.cdap.internal.schedule.TimeSchedule; import co.cask.cdap.proto.Id; import co.cask.cdap.proto.ProgramType; import co.cask.cdap.proto.ScheduledRuntime; import com.google.common.base.Preconditions; import com.google.common.base.Supplier; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import com.google.inject.Inject; import org.apache.twill.common.Threads; import org.quartz.CronScheduleBuilder; import org.quartz.Job; import org.quartz.JobBuilder; import org.quartz.JobDetail; import org.quartz.JobExecutionContext; import org.quartz.JobExecutionException; import org.quartz.JobKey; import org.quartz.ObjectAlreadyExistsException; import org.quartz.Trigger; import org.quartz.TriggerBuilder; import org.quartz.TriggerKey; import org.quartz.impl.matchers.GroupMatcher; import org.quartz.spi.JobFactory; import org.quartz.spi.TriggerFiredBundle; import org.quartz.utils.Key; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.Executors; /** * Class that wraps Quartz scheduler. Needed to delegate start stop operations to classes that extend * DefaultSchedulerService. */ final class TimeScheduler implements Scheduler { private static final Logger LOG = LoggerFactory.getLogger(TimeScheduler.class); private static final String PAUSED_NEW_TRIGGERS_GROUP = "NewPausedTriggers"; private org.quartz.Scheduler scheduler; private final Supplier<org.quartz.Scheduler> schedulerSupplier; private final ProgramLifecycleService lifecycleService; private final PropertiesResolver propertiesResolver; private ListeningExecutorService taskExecutorService; private boolean schedulerStarted; private final Store store; @Inject TimeScheduler(Supplier<org.quartz.Scheduler> schedulerSupplier, Store store, ProgramLifecycleService lifecycleService, PropertiesResolver propertiesResolver) { this.schedulerSupplier = schedulerSupplier; this.store = store; this.lifecycleService = lifecycleService; this.scheduler = null; this.propertiesResolver = propertiesResolver; this.schedulerStarted = false; } void init() throws SchedulerException { try { taskExecutorService = MoreExecutors.listeningDecorator( Executors.newCachedThreadPool(Threads.createDaemonThreadFactory("time-schedule-task"))); scheduler = schedulerSupplier.get(); scheduler.setJobFactory(createJobFactory(store)); } catch (org.quartz.SchedulerException e) { throw new SchedulerException(e); } } /** * Creates a paused group TimeScheduler#PAUSED_NEW_TRIGGERS_GROUP by adding a dummy job to it if it does not exists * already. This is needed so that we can add new triggers to this paused group and they will be paused too. * * @throws org.quartz.SchedulerException */ private void initNewPausedTriggersGroup() throws org.quartz.SchedulerException { // if the dummy job does not already exists in the TimeScheduler#PAUSED_NEW_TRIGGERS_GROUP then create a dummy job // which will create the TimeScheduler#PAUSED_NEW_TRIGGERS_GROUP if (!scheduler.checkExists(new JobKey(EmptyJob.class.getSimpleName(), PAUSED_NEW_TRIGGERS_GROUP))) { JobDetail job = JobBuilder.newJob(EmptyJob.class) .withIdentity(EmptyJob.class.getSimpleName(), PAUSED_NEW_TRIGGERS_GROUP) .storeDurably(true) .build(); scheduler.addJob(job, true); } // call pause on this group this ensures that all the new triggers added to this group will also be paused scheduler.pauseTriggers(GroupMatcher.triggerGroupEquals(PAUSED_NEW_TRIGGERS_GROUP)); } void start() throws SchedulerException { try { scheduler.start(); schedulerStarted = true; initNewPausedTriggersGroup(); } catch (org.quartz.SchedulerException e) { throw new SchedulerException(e); } } boolean isStarted() { return schedulerStarted; } void stop() throws SchedulerException { try { if (scheduler != null) { scheduler.shutdown(); } if (taskExecutorService != null) { taskExecutorService.shutdownNow(); } } catch (org.quartz.SchedulerException e) { throw new SchedulerException(e); } } @Override public void schedule(Id.Program program, SchedulableProgramType programType, Schedule schedule) throws SchedulerException { schedule(program, programType, schedule, ImmutableMap.<String, String>of()); } @Override public void schedule(Id.Program program, SchedulableProgramType programType, Schedule schedule, Map<String, String> properties) throws SchedulerException { schedule(program, programType, ImmutableList.of(schedule), properties); } @Override public void schedule(Id.Program programId, SchedulableProgramType programType, Iterable<Schedule> schedules) throws SchedulerException { schedule(programId, programType, schedules, ImmutableMap.<String, String>of()); } @Override public synchronized void schedule(Id.Program program, SchedulableProgramType programType, Iterable<Schedule> schedules, Map<String, String> properties) throws SchedulerException { checkInitialized(); try { validateSchedules(program, programType, schedules); } catch (org.quartz.SchedulerException e) { throw new SchedulerException(e); } String jobKey = jobKeyFor(program, programType).getName(); JobDetail job = JobBuilder.newJob(DefaultSchedulerService.ScheduledJob.class) .withIdentity(jobKey) .storeDurably(true) .build(); try { scheduler.addJob(job, true); } catch (org.quartz.SchedulerException e) { throw new SchedulerException(e); } for (Schedule schedule : schedules) { TimeSchedule timeSchedule = (TimeSchedule) schedule; String scheduleName = timeSchedule.getName(); String cronEntry = timeSchedule.getCronEntry(); try { TriggerKey triggerKey = getGroupedTriggerKey(program, programType, timeSchedule.getName()); LOG.debug("Scheduling job {} with cron {}", scheduleName, cronEntry); TriggerBuilder trigger = TriggerBuilder.newTrigger() // all new triggers are added to the paused group which will ensure that the triggers are paused too .withIdentity(triggerKey.getName(), PAUSED_NEW_TRIGGERS_GROUP) .forJob(job) .withSchedule(CronScheduleBuilder .cronSchedule(getQuartzCronExpression(cronEntry)) .withMisfireHandlingInstructionDoNothing()); addProperties(trigger, properties); scheduler.scheduleJob(trigger.build()); } catch (org.quartz.SchedulerException e) { throw new SchedulerException(e); } } } private void validateSchedules(Id.Program program, SchedulableProgramType programType, Iterable<Schedule> schedules) throws org.quartz.SchedulerException { Preconditions.checkNotNull(schedules); for (Schedule schedule : schedules) { Preconditions.checkArgument(schedule instanceof TimeSchedule); TimeSchedule timeSchedule = (TimeSchedule) schedule; TriggerKey triggerKey = getGroupedTriggerKey(program, programType, timeSchedule.getName()); // Once the schedule is resumed we move the trigger from TimeScheduler#PAUSED_NEW_TRIGGERS_GROUP to // Key#DEFAULT_GROUP so before adding check if this schedule does not exist. // We do not need to check for same schedule in the current list as its already checked in app configuration stage if (scheduler.checkExists(triggerKey)) { throw new ObjectAlreadyExistsException("Unable to store Trigger with name " + triggerKey.getName() + "because one already exists with this identification."); } } } @Override public List<ScheduledRuntime> previousScheduledRuntime(Id.Program program, SchedulableProgramType programType) throws SchedulerException { return getScheduledRuntime(program, programType, true); } @Override public List<ScheduledRuntime> nextScheduledRuntime(Id.Program program, SchedulableProgramType programType) throws SchedulerException { return getScheduledRuntime(program, programType, false); } private List<ScheduledRuntime> getScheduledRuntime(Id.Program program, SchedulableProgramType programType, boolean previousRuntimeRequested) throws SchedulerException { checkInitialized(); List<ScheduledRuntime> scheduledRuntimes = new ArrayList<>(); try { for (Trigger trigger : scheduler.getTriggersOfJob(jobKeyFor(program, programType))) { long time; if (previousRuntimeRequested) { if (trigger.getPreviousFireTime() == null) { // previous fire time can be null for the triggers which are not yet fired continue; } time = trigger.getPreviousFireTime().getTime(); } else { if (scheduler.getTriggerState(trigger.getKey()) == Trigger.TriggerState.PAUSED) { // if the trigger is paused, then skip getting the next fire time continue; } time = trigger.getNextFireTime().getTime(); } ScheduledRuntime runtime = new ScheduledRuntime(trigger.getKey().toString(), time); scheduledRuntimes.add(runtime); } } catch (org.quartz.SchedulerException e) { throw new SchedulerException(e); } return scheduledRuntimes; } @Override public List<String> getScheduleIds(Id.Program program, SchedulableProgramType programType) throws SchedulerException { checkInitialized(); List<String> scheduleIds = Lists.newArrayList(); try { for (Trigger trigger : scheduler.getTriggersOfJob(jobKeyFor(program, programType))) { scheduleIds.add(trigger.getKey().getName()); } } catch (org.quartz.SchedulerException e) { throw new SchedulerException(e); } return scheduleIds; } @Override public synchronized void suspendSchedule(Id.Program program, SchedulableProgramType programType, String scheduleName) throws NotFoundException, SchedulerException { checkInitialized(); try { scheduler.pauseTrigger(getGroupedTriggerKey(program, programType, scheduleName)); } catch (org.quartz.SchedulerException e) { throw new SchedulerException(e); } } @Override public synchronized void resumeSchedule(Id.Program program, SchedulableProgramType programType, String scheduleName) throws NotFoundException, SchedulerException { checkInitialized(); try { TriggerKey triggerKey = getGroupedTriggerKey(program, programType, scheduleName); if (triggerKey.getGroup().equals(PAUSED_NEW_TRIGGERS_GROUP)) { Trigger neverScheduledTrigger = scheduler.getTrigger(triggerKey); TriggerBuilder<? extends Trigger> triggerBuilder = neverScheduledTrigger.getTriggerBuilder(); // move this key from TimeScheduler#PAUSED_NEW_TRIGGERS_GROUP to the Key#DEFAULT_GROUP group // (when no group name is provided default is used) Trigger resumedTrigger = triggerBuilder.withIdentity(triggerKey.getName()).build(); scheduler.rescheduleJob(neverScheduledTrigger.getKey(), resumedTrigger); triggerKey = resumedTrigger.getKey(); } scheduler.resumeTrigger(triggerKey); } catch (org.quartz.SchedulerException e) { throw new SchedulerException(e); } } @Override public void updateSchedule(Id.Program program, SchedulableProgramType programType, Schedule schedule) throws NotFoundException, SchedulerException { updateSchedule(program, programType, schedule, ImmutableMap.<String, String>of()); } @Override public synchronized void updateSchedule(Id.Program program, SchedulableProgramType programType, Schedule schedule, Map<String, String> properties) throws NotFoundException, SchedulerException { checkInitialized(); try { Trigger trigger = getTrigger(program, programType, schedule.getName()); TriggerBuilder triggerBuilder = trigger.getTriggerBuilder(); String cronEntry = ((TimeSchedule) schedule).getCronEntry(); // create the new trigger with the new schedule schedule all other fields will remain unmodified triggerBuilder.withSchedule(CronScheduleBuilder .cronSchedule(getQuartzCronExpression(cronEntry)) .withMisfireHandlingInstructionDoNothing()); addProperties(triggerBuilder, properties); scheduler.rescheduleJob(trigger.getKey(), triggerBuilder.build()); } catch (org.quartz.SchedulerException e) { throw new SchedulerException(e); } } @Override public synchronized void deleteSchedule(Id.Program program, SchedulableProgramType programType, String scheduleName) throws NotFoundException, SchedulerException { checkInitialized(); try { Trigger trigger = getTrigger(program, programType, scheduleName); scheduler.unscheduleJob(trigger.getKey()); JobKey jobKey = trigger.getJobKey(); if (scheduler.getTriggersOfJob(jobKey).isEmpty()) { scheduler.deleteJob(jobKey); } } catch (org.quartz.SchedulerException e) { throw new SchedulerException(e); } } @Override public void deleteSchedules(Id.Program program, SchedulableProgramType programType) throws SchedulerException { checkInitialized(); try { scheduler.deleteJob(jobKeyFor(program, programType)); } catch (org.quartz.SchedulerException e) { throw new SchedulerException(e); } } @Override public void deleteAllSchedules(Id.Namespace namespaceId) throws SchedulerException { for (ApplicationSpecification appSpec : store.getAllApplications(namespaceId)) { deleteAllSchedules(namespaceId, appSpec); } } private void deleteAllSchedules(Id.Namespace namespaceId, ApplicationSpecification appSpec) throws SchedulerException { for (ScheduleSpecification scheduleSpec : appSpec.getSchedules().values()) { Id.Application appId = Id.Application.from(namespaceId.getId(), appSpec.getName()); ProgramType programType = ProgramType.valueOfSchedulableType(scheduleSpec.getProgram().getProgramType()); Id.Program programId = Id.Program.from(appId, programType, scheduleSpec.getProgram().getProgramName()); deleteSchedules(programId, scheduleSpec.getProgram().getProgramType()); } } @Override public synchronized ScheduleState scheduleState(Id.Program program, SchedulableProgramType programType, String scheduleName) throws SchedulerException { checkInitialized(); try { Trigger.TriggerState state = scheduler.getTriggerState(getGroupedTriggerKey(program, programType, scheduleName)); // Map trigger state to schedule state. // This method is only interested in returning if the scheduler is // Paused, Scheduled or NotFound. switch (state) { case NONE: return ScheduleState.NOT_FOUND; case PAUSED: return ScheduleState.SUSPENDED; default: return ScheduleState.SCHEDULED; } } catch (org.quartz.SchedulerException e) { throw new SchedulerException(e); } } private void checkInitialized() { Preconditions.checkNotNull(scheduler, "Scheduler not yet initialized"); } private static JobKey jobKeyFor(Id.Program program, SchedulableProgramType programType) { return new JobKey(AbstractSchedulerService.programIdFor(program, programType)); } //Helper function to adapt cron entry to a cronExpression that is usable by quartz. //1. Quartz doesn't support wild-carding of both day-of-the-week and day-of-the-month //2. Quartz resolution is in seconds which cron entry doesn't support. private String getQuartzCronExpression(String cronEntry) { // Checks if the cronEntry is quartz cron Expression or unix like cronEntry format. // CronExpression will directly be used for tests. String parts [] = cronEntry.split(" "); Preconditions.checkArgument(parts.length >= 5 , "Invalid cron entry format"); if (parts.length == 5) { //cron entry format StringBuilder cronStringBuilder = new StringBuilder("0 " + cronEntry); if (cronStringBuilder.charAt(cronStringBuilder.length() - 1) == '*') { cronStringBuilder.setCharAt(cronStringBuilder.length() - 1, '?'); } return cronStringBuilder.toString(); } else { //Use the given cronExpression return cronEntry; } } private JobFactory createJobFactory(final Store store) { return new JobFactory() { @Override public Job newJob(TriggerFiredBundle bundle, org.quartz.Scheduler scheduler) throws org.quartz.SchedulerException { Class<? extends Job> jobClass = bundle.getJobDetail().getJobClass(); if (DefaultSchedulerService.ScheduledJob.class.isAssignableFrom(jobClass)) { return new DefaultSchedulerService.ScheduledJob(store, lifecycleService, propertiesResolver, taskExecutorService); } else { try { return jobClass.newInstance(); } catch (Exception e) { throw new org.quartz.SchedulerException("Failed to create instance of " + jobClass, e); } } } }; } /** * @return Trigger key created from program, programType and scheduleName and TimeScheuler#PAUSED_NEW_TRIGGERS_GROUP * if it exists in this group else returns the {@link TriggerKey} prepared with null which gets it with * {@link Key#DEFAULT_GROUP} * @throws org.quartz.SchedulerException */ private synchronized TriggerKey getGroupedTriggerKey(Id.Program program, SchedulableProgramType programType, String scheduleName) throws org.quartz.SchedulerException { TriggerKey neverResumedTriggerKey = new TriggerKey(AbstractSchedulerService.scheduleIdFor(program, programType, scheduleName), PAUSED_NEW_TRIGGERS_GROUP); if (scheduler.checkExists(neverResumedTriggerKey)) { return neverResumedTriggerKey; } return new TriggerKey(AbstractSchedulerService.scheduleIdFor(program, programType, scheduleName)); } /** * Gets a {@link Trigger} associated with this program name, type and schedule name */ private synchronized Trigger getTrigger(Id.Program program, SchedulableProgramType programType, String scheduleName) throws org.quartz.SchedulerException, ScheduleNotFoundException { Trigger trigger = scheduler.getTrigger(getGroupedTriggerKey(program, programType, scheduleName)); if (trigger == null) { throw new ScheduleNotFoundException(Id.Schedule.from(program.getApplication(), scheduleName)); } return trigger; } /** * Adds properties to a {@link TriggerBuilder} to used by the {@link Trigger} */ private void addProperties(TriggerBuilder trigger, Map<String, String> properties) { if (properties != null) { for (Map.Entry<String, String> entry : properties.entrySet()) { trigger.usingJobData(entry.getKey(), entry.getValue()); } } } /** * An empty {@link Job} to create a group in the scheduler */ private final class EmptyJob implements Job { public void execute(JobExecutionContext context) throws JobExecutionException { // no-op } } }