/**
* Copyright (c) 2009 - 2012 Red Hat, Inc.
*
* This software is licensed to you under the GNU General Public License,
* version 2 (GPLv2). There is NO WARRANTY for this software, express or
* implied, including the implied warranties of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2
* along with this software; if not, see
* http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt.
*
* Red Hat trademarks are not licensed under GPLv2. No permission is
* granted to use or replicate Red Hat trademarks that are incorporated
* in this software or its documentation.
*/
package org.candlepin.pinsetter.core;
import static org.quartz.CronScheduleBuilder.cronSchedule;
import static org.quartz.JobBuilder.newJob;
import static org.quartz.JobKey.jobKey;
import static org.quartz.TriggerBuilder.newTrigger;
import static org.quartz.TriggerKey.triggerKey;
import static org.quartz.impl.matchers.GroupMatcher.jobGroupEquals;
import org.candlepin.auth.SystemPrincipal;
import org.candlepin.common.config.Configuration;
import org.candlepin.config.ConfigProperties;
import org.candlepin.controller.ModeChangeListener;
import org.candlepin.controller.ModeManager;
import org.candlepin.model.CandlepinModeChange.Mode;
import org.candlepin.model.JobCurator;
import org.candlepin.pinsetter.core.model.JobStatus;
import org.candlepin.pinsetter.tasks.CancelJobJob;
import org.candlepin.util.PropertyUtil;
import org.candlepin.util.Util;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import org.apache.commons.lang.StringUtils;
import org.quartz.CronTrigger;
import org.quartz.JobDataMap;
import org.quartz.JobDetail;
import org.quartz.JobExecutionException;
import org.quartz.JobKey;
import org.quartz.JobListener;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.Trigger;
import org.quartz.TriggerListener;
import org.quartz.impl.JobDetailImpl;
import org.quartz.impl.StdSchedulerFactory;
import org.quartz.impl.matchers.GroupMatcher;
import org.quartz.spi.JobFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Properties;
import java.util.Set;
/**
* Pinsetter Kernel.
* @version $Rev$
*/
@Singleton
public class PinsetterKernel implements ModeChangeListener {
public static final String CRON_GROUP = "cron group";
public static final String SINGLE_JOB_GROUP = "async group";
public static final String[] DELETED_JOBS = new String[] {
"StatisticHistoryTask",
"ExportCleaner"
};
private static Logger log = LoggerFactory.getLogger(PinsetterKernel.class);
private Scheduler scheduler;
private Configuration config;
private JobCurator jobCurator;
private ModeManager modeManager;
/**
* Kernel main driver behind Pinsetter
* @param conf Configuration to use
* @throws InstantiationException thrown if this.scheduler can't be
* initialized.
*/
@Inject
public PinsetterKernel(Configuration conf, JobFactory jobFactory,
JobListener listener, JobCurator jobCurator,
StdSchedulerFactory fact,
TriggerListener triggerListener,
ModeManager modeManager) throws InstantiationException {
this.config = conf;
this.jobCurator = jobCurator;
this.modeManager = modeManager;
/*
* Did your unit test get an NPE here?
* this will help:
* when(config.subset(eq("org.quartz"))).thenReturn(
* new MapConfiguration(ConfigProperties.DEFAULT_PROPERTIES));
*
* TODO: We should probably be clearing up what's happening here. Not a fan of a comment
* explaining what should be handled by something like an illegal arg or illegal state
* exception. -C
*/
Properties props = config.subset("org.quartz").toProperties();
// create a schedulerFactory
try {
fact.initialize(props);
scheduler = fact.getScheduler();
scheduler.setJobFactory(jobFactory);
if (listener != null) {
scheduler.getListenerManager().addJobListener(listener);
}
if (triggerListener != null) {
scheduler.getListenerManager().addTriggerListener(triggerListener);
}
}
catch (SchedulerException e) {
throw new InstantiationException("this.scheduler failed: " + e.getMessage());
}
}
/**
* Starts Pinsetter
* This method does not return until the this.scheduler is shutdown
* @throws PinsetterException error occurred during Quartz or Hibernate
* startup
*/
public void startup() throws PinsetterException {
try {
scheduler.start();
if (modeManager.getLastCandlepinModeChange().getMode() != Mode.NORMAL) {
scheduler.pauseAll();
}
modeManager.registerModeChangeListener(this);
configure();
}
catch (SchedulerException e) {
throw new PinsetterException(e.getMessage(), e);
}
}
private void addToList(Set<String> impls, String confkey) {
List<String> jobs = config.getList(confkey, null);
if (jobs != null && !jobs.isEmpty()) {
for (String job : jobs) {
if (!StringUtils.isEmpty(job)) {
impls.add(job);
}
}
}
}
/**
* Configures the system.
* @param conf Configuration object containing config values.
*/
private void configure() {
if (log.isDebugEnabled()) {
log.debug("Scheduling tasks");
}
List<JobEntry> pendingJobs = new ArrayList<JobEntry>();
// use a set to remove potential duplicate jobs from config
Set<String> jobImpls = new HashSet<String>();
try {
if (config.getBoolean(ConfigProperties.ENABLE_PINSETTER, true)) {
// get the default tasks first
addToList(jobImpls, ConfigProperties.DEFAULT_TASKS);
// get other tasks
addToList(jobImpls, ConfigProperties.TASKS);
}
else if (!isClustered()) {
// Since pinsetter is disabled, we only want to allow
// CancelJob and async jobs on this node.
jobImpls.add(CancelJobJob.class.getName());
}
// Bail if there is nothing to configure
if (jobImpls.size() == 0) {
log.warn("No tasks to schedule");
return;
}
log.debug("Jobs implemented:" + jobImpls);
Set<JobKey> jobKeys = scheduler.getJobKeys(jobGroupEquals(CRON_GROUP));
/*
* purge jobs that have been deleted from this version of Candlepin.
* This is necessary as we might not even have the Class definition
* at classpath, Hence any attempt at fetching the JobDetail by the
* Scheduler or JobStatus by the JobCurator will fail.
*/
for (JobKey jobKey : jobKeys) {
for (String deletedJob : DELETED_JOBS) {
if (jobKey.getName().contains(deletedJob)) {
scheduler.deleteJob(jobKey);
jobCurator.deleteJobNoStatusReturn(jobKey.getName());
break;
}
}
}
for (String jobImpl : jobImpls) {
if (log.isDebugEnabled()) {
log.debug("Scheduling " + jobImpl);
}
// Find all existing cron triggers matching this job impl
List<CronTrigger> existingCronTriggers = new LinkedList<CronTrigger>();
if (jobKeys != null) {
for (JobKey key : jobKeys) {
JobDetail jd = scheduler.getJobDetail(key);
if (jd != null &&
jd.getJobClass().getName().equals(jobImpl)) {
CronTrigger trigger = (CronTrigger) scheduler.getTrigger(
triggerKey(key.getName(), CRON_GROUP));
if (trigger != null) {
existingCronTriggers.add(trigger);
}
else {
log.warn("JobKey " + key + " returned null cron trigger.");
}
}
}
}
// get the default schedule from the job class in case one
// is not found in the configuration.
String defvalue = PropertyUtil.getStaticPropertyAsString(jobImpl,
"DEFAULT_SCHEDULE");
String schedule = this.config.getString("pinsetter." +
jobImpl + ".schedule", defvalue);
if (schedule != null && schedule.length() > 0) {
if (log.isDebugEnabled()) {
log.debug("Scheduler entry for " + jobImpl + ": " +
schedule);
}
addUniqueJob(pendingJobs, jobImpl,
existingCronTriggers, schedule);
}
else {
log.warn("No schedule found for " + jobImpl + ". Skipping...");
}
}
}
catch (SchedulerException e) {
throw new RuntimeException(e.getLocalizedMessage(), e);
}
catch (Exception e) {
throw new RuntimeException(e.getLocalizedMessage(), e);
}
scheduleJobs(pendingJobs);
}
/*
* Adds a unique job, replacing any old ones with different schedules.
*/
private void addUniqueJob(List<JobEntry> pendingJobs,
String jobImpl, List<CronTrigger> existingCronTriggers, String schedule)
throws SchedulerException {
// If trigger already exists with same schedule, nothing to do
if (existingCronTriggers.size() == 1 &&
existingCronTriggers.get(0).getCronExpression().equals(schedule)) {
return;
}
/*
* Otherwise, we know there are existing triggers, delete them all and create
* one with our new schedule. Normally there should only ever be one, but past
* bugs caused duplicates so we handle this situation by default now.
*
* This could be cleaning up some with the same schedule we want, but we can't
* allow there to be multiple with the same schedule so simpler to just make sure
* there's only one.
*/
if (existingCronTriggers.size() > 0) {
log.warn("Cleaning up " + existingCronTriggers.size() + " obsolete triggers.");
}
for (CronTrigger t : existingCronTriggers) {
boolean result = scheduler.deleteJob(t.getJobKey());
log.warn(t.getJobKey() + " deletion success?: " + result);
}
// Create our new job:
pendingJobs.add(new JobEntry(jobImpl, schedule));
}
/**
* Shuts down the application
*
* @throws PinsetterException if there was a scheduling error in shutdown
*/
public void shutdown() throws PinsetterException {
try {
log.info("shutting down pinsetter kernel");
scheduler.standby(); // do not allow any new jobs to be scheduled
deleteAllJobs(); // delete all jobs if we are not clustered
log.info("allowing running jobs to finish..");
scheduler.shutdown(true);
log.info("pinsetter kernel is shut down");
}
catch (SchedulerException e) {
throw new PinsetterException("Error shutting down Pinsetter.", e);
}
}
@SuppressWarnings("checkstyle:indentation")
private void scheduleJobs(List<JobEntry> pendingJobs) {
if (pendingJobs.size() == 0) {
return;
}
try {
for (JobEntry jobentry : pendingJobs) {
Trigger trigger = newTrigger()
.withIdentity(jobentry.getJobName(), CRON_GROUP)
.withSchedule(cronSchedule(jobentry.getSchedule())
.withMisfireHandlingInstructionDoNothing())
.build();
scheduleJob(
this.getClass().getClassLoader().loadClass(
jobentry.getClassName()), jobentry.getJobName(), trigger);
}
}
catch (Throwable t) {
log.error(t.getMessage(), t);
throw new RuntimeException(t.getMessage(), t);
}
}
@SuppressWarnings("checkstyle:indentation")
public void scheduleJob(Class job, String jobName, String crontab)
throws PinsetterException {
try {
Trigger trigger = newTrigger()
.withIdentity(job.getName(), CRON_GROUP)
.withSchedule(cronSchedule(crontab)
.withMisfireHandlingInstructionDoNothing())
.build();
scheduleJob(job, jobName, trigger);
}
catch (Exception pe) {
throw new PinsetterException("problem parsing schedule", pe);
}
}
@SuppressWarnings("unchecked")
public void scheduleJob(Class job, String jobName, Trigger trigger)
throws PinsetterException {
JobDataMap map = new JobDataMap();
map.put(PinsetterJobListener.PRINCIPAL_KEY, new SystemPrincipal());
JobDetail detail = newJob(job)
.withIdentity(jobName, CRON_GROUP)
.usingJobData(map)
.build();
scheduleJob(detail, CRON_GROUP, trigger);
}
private JobStatus scheduleJob(JobDetail detail, String grpName, Trigger trigger)
throws PinsetterException {
JobDetailImpl detailImpl = (JobDetailImpl) detail;
detailImpl.setGroup(grpName);
try {
JobStatus status = (JobStatus) (detail.getJobClass()
.getMethod("scheduleJob", JobCurator.class, Scheduler.class, JobDetail.class, Trigger.class)
.invoke(null, jobCurator, scheduler, detail, trigger));
if (log.isDebugEnabled()) {
log.debug("Scheduled " + detailImpl.getFullName());
}
return status;
}
catch (Exception e) {
log.error("There was a problem scheduling " +
detail.getKey().getName(), e);
throw new PinsetterException("There was a problem scheduling " +
detail.getKey().getName(), e);
}
}
public void cancelJob(Serializable id, String group)
throws PinsetterException {
try {
// this deletes from the scheduler, it's already marked as
// canceled in the JobStatus table
if (scheduler.deleteJob(jobKey((String) id, group))) {
log.info("canceled job " + group + ":" + id + " in scheduler");
}
}
catch (SchedulerException e) {
throw new PinsetterException("problem canceling " + group + ":" + id, e);
}
}
/**
* Schedule a long-running job for a single execution.
*
* @param jobDetail the long-running job to perform - assumed to be
* prepopulated with a valid job task and name
* @return the initial status of the submitted job
* @throws PinsetterException if there is an error scheduling the job
*/
public JobStatus scheduleSingleJob(JobDetail jobDetail) throws PinsetterException {
Trigger trigger = newTrigger()
.withIdentity(jobDetail.getKey().getName() + " trigger", SINGLE_JOB_GROUP)
.build();
return scheduleJob(jobDetail, SINGLE_JOB_GROUP, trigger);
}
public JobStatus scheduleSingleJob(Class job, String jobName) throws PinsetterException {
JobDataMap map = new JobDataMap();
map.put(PinsetterJobListener.PRINCIPAL_KEY, new SystemPrincipal());
JobDetail detail = newJob(job)
.withIdentity(jobName, CRON_GROUP)
.usingJobData(map)
.build();
Trigger trigger = newTrigger()
.withIdentity(detail.getKey().getName() + " trigger", SINGLE_JOB_GROUP)
.build();
return scheduleJob(detail, SINGLE_JOB_GROUP, trigger);
}
public void addTrigger(JobStatus status) throws SchedulerException {
Trigger trigger = newTrigger()
.withIdentity(status.getId() + " trigger", SINGLE_JOB_GROUP)
.forJob(status.getJobKey())
.build();
scheduler.scheduleJob(trigger);
}
public boolean getSchedulerStatus() throws PinsetterException {
try {
// return true when scheduler is running (double negative)
return !scheduler.isInStandbyMode();
}
catch (SchedulerException e) {
throw new PinsetterException("There was a problem gathering" +
"scheduler status ", e);
}
}
public void pauseScheduler() throws PinsetterException {
// go into standby mode
try {
scheduler.standby();
}
catch (SchedulerException e) {
throw new PinsetterException("There was a problem pausing the scheduler", e);
}
}
public void unpauseScheduler() throws PinsetterException {
log.debug("looking for canceled jobs since scheduler was paused");
CancelJobJob cjj = new CancelJobJob(jobCurator, this);
try {
//Not sure why we don't want to use a UnitOfWork here
cjj.toExecute(null);
}
catch (JobExecutionException e1) {
throw new PinsetterException("Could not clear canceled jobs before starting");
}
log.debug("restarting scheduler");
try {
scheduler.start();
}
catch (SchedulerException e) {
throw new PinsetterException("There was a problem unpausing the scheduler", e);
}
}
private void deleteJobs(String groupName) {
try {
Set<JobKey> jobs = this.scheduler.getJobKeys(jobGroupEquals(groupName));
for (JobKey jobKey : jobs) {
this.scheduler.deleteJob(jobKey);
}
}
catch (SchedulerException e) {
// TODO: Something better than nothing
}
}
private void deleteAllJobs() {
if (!isClustered()) {
deleteJobs(CRON_GROUP);
deleteJobs(SINGLE_JOB_GROUP);
}
}
public Set<JobKey> getSingleJobKeys() throws SchedulerException {
return scheduler.getJobKeys(GroupMatcher.jobGroupEquals(SINGLE_JOB_GROUP));
}
private boolean isClustered() {
boolean clustered = false;
if (config.containsKey("org.quartz.jobStore.isClustered")) {
clustered = config.getBoolean("org.quartz.jobStore.isClustered");
}
return clustered;
}
private static class JobEntry {
private String classname;
private String schedule;
private String jobname;
public JobEntry(String cname, String sched) {
classname = cname;
schedule = sched;
jobname = genName(classname);
}
private String genName(String cname) {
return Util.getClassName(cname) + "-" + Util.generateUUID();
}
public String getClassName() {
return classname;
}
public String getSchedule() {
return schedule;
}
public String getJobName() {
return jobname;
}
}
private void pauseAll() {
try {
log.debug("Pinsetter Kernel is being paused");
scheduler.pauseAll();
}
catch (SchedulerException e) {
throw new RuntimeException(e);
}
}
private void resumeAll() {
try {
log.debug("Pinsetter Kernel is being resumed");
scheduler.resumeAll();
}
catch (SchedulerException e) {
throw new RuntimeException(e);
}
}
@Override
public void modeChanged(Mode newMode) {
if (newMode == Mode.SUSPEND) {
pauseAll();
}
else if (newMode == Mode.NORMAL) {
resumeAll();
}
}
}