/**
* 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 org.apache.aurora.scheduler.cron.quartz;
import java.util.Map;
import java.util.TimeZone;
import javax.inject.Inject;
import com.google.common.base.Optional;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableMap;
import org.apache.aurora.gen.CronCollisionPolicy;
import org.apache.aurora.scheduler.base.JobKeys;
import org.apache.aurora.scheduler.cron.CronException;
import org.apache.aurora.scheduler.cron.CronJobManager;
import org.apache.aurora.scheduler.cron.CrontabEntry;
import org.apache.aurora.scheduler.cron.SanitizedCronJob;
import org.apache.aurora.scheduler.storage.CronJobStore;
import org.apache.aurora.scheduler.storage.Storage;
import org.apache.aurora.scheduler.storage.Storage.MutateWork.NoResult;
import org.apache.aurora.scheduler.storage.entities.IJobConfiguration;
import org.apache.aurora.scheduler.storage.entities.IJobKey;
import org.quartz.CronTrigger;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.impl.matchers.GroupMatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static java.util.Objects.requireNonNull;
/**
* NOTE: The source of truth for whether a cron job exists or not is always the JobStore. If state
* somehow becomes inconsistent (i.e. a job key is scheduled for execution but its underlying
* JobConfiguration does not exist in storage the execution of the job will log a warning and
* exit).
*/
class CronJobManagerImpl implements CronJobManager {
private static final Logger LOG = LoggerFactory.getLogger(CronJobManagerImpl.class);
private final Storage storage;
private final Scheduler scheduler;
private final TimeZone timeZone;
@Inject
CronJobManagerImpl(Storage storage, Scheduler scheduler, TimeZone timeZone) {
this.storage = requireNonNull(storage);
this.scheduler = requireNonNull(scheduler);
this.timeZone = requireNonNull(timeZone);
}
@Override
public void startJobNow(final IJobKey jobKey) throws CronException {
requireNonNull(jobKey);
storage.read(storeProvider -> {
checkCronExists(jobKey, storeProvider.getCronJobStore());
triggerJob(jobKey);
return null;
});
}
private void triggerJob(IJobKey jobKey) throws CronException {
try {
scheduler.triggerJob(Quartz.jobKey(jobKey));
} catch (SchedulerException e) {
throw new CronException(e);
}
LOG.info(formatMessage("Triggered cron job for %s.", jobKey));
}
private static void checkNoRunOverlap(SanitizedCronJob cronJob) throws CronException {
// NOTE: We check at create and update instead of in SanitizedCronJob to allow existing jobs
// but reject new ones.
if (CronCollisionPolicy.RUN_OVERLAP.equals(cronJob.getCronCollisionPolicy())) {
throw new CronException(
"The RUN_OVERLAP collision policy has been removed (AURORA-38).");
}
}
@Override
public void updateJob(final SanitizedCronJob config) throws CronException {
requireNonNull(config);
checkNoRunOverlap(config);
final IJobKey jobKey = config.getSanitizedConfig().getJobConfig().getKey();
storage.write((NoResult<CronException>) storeProvider -> {
checkCronExists(jobKey, storeProvider.getCronJobStore());
removeJob(jobKey, storeProvider.getCronJobStore());
descheduleJob(jobKey);
saveJob(config, storeProvider.getCronJobStore());
scheduleJob(config.getCrontabEntry(), jobKey);
});
}
@Override
public void createJob(final SanitizedCronJob cronJob) throws CronException {
requireNonNull(cronJob);
checkNoRunOverlap(cronJob);
final IJobKey jobKey = cronJob.getSanitizedConfig().getJobConfig().getKey();
storage.write((NoResult<CronException>) storeProvider -> {
checkNotExists(jobKey, storeProvider.getCronJobStore());
saveJob(cronJob, storeProvider.getCronJobStore());
scheduleJob(cronJob.getCrontabEntry(), jobKey);
});
}
private void checkNotExists(IJobKey jobKey, CronJobStore cronJobStore) throws CronException {
if (cronJobStore.fetchJob(jobKey).isPresent()) {
throw new CronException(formatMessage("Job already exists for %s.", jobKey));
}
}
private void checkCronExists(IJobKey jobKey, CronJobStore cronJobStore) throws CronException {
if (!cronJobStore.fetchJob(jobKey).isPresent()) {
throw new CronException(formatMessage("No cron job found for %s.", jobKey));
}
}
private void removeJob(IJobKey jobKey, CronJobStore.Mutable jobStore) {
jobStore.removeJob(jobKey);
LOG.info(formatMessage("Deleted cron job %s from storage.", jobKey));
}
private void saveJob(SanitizedCronJob cronJob, CronJobStore.Mutable jobStore) {
IJobConfiguration config = cronJob.getSanitizedConfig().getJobConfig();
jobStore.saveAcceptedJob(config);
LOG.info(formatMessage("Saved new cron job %s to storage.", config.getKey()));
}
// TODO(ksweeney): Consider exposing this in the interface and making caller responsible.
void scheduleJob(CrontabEntry crontabEntry, IJobKey jobKey) throws CronException {
try {
scheduler.scheduleJob(
Quartz.jobDetail(jobKey, AuroraCronJob.class),
Quartz.cronTrigger(crontabEntry, timeZone));
} catch (SchedulerException e) {
throw new CronException(e);
}
LOG.info(formatMessage("Scheduled job %s with schedule %s.", jobKey, crontabEntry));
}
@Override
public boolean deleteJob(final IJobKey jobKey) {
requireNonNull(jobKey);
return storage.write(storeProvider -> {
if (!storeProvider.getCronJobStore().fetchJob(jobKey).isPresent()) {
return false;
}
removeJob(jobKey, storeProvider.getCronJobStore());
descheduleJob(jobKey);
return true;
});
}
private void descheduleJob(IJobKey jobKey) {
try {
// TODO(ksweeney): Consider interrupting the running job here.
// There's a race here where an old running job could fail to find the old config. That's
// fine given that the behavior of AuroraCronJob is to log an error and exit if it's unable
// to find a job for its key.
scheduler.deleteJob(Quartz.jobKey(jobKey));
LOG.info(formatMessage("Successfully descheduled %s.", jobKey));
} catch (SchedulerException e) {
LOG.warn(formatMessage("Error descheduling %s: %s", jobKey, e), e);
}
}
@Override
public Map<IJobKey, CrontabEntry> getScheduledJobs() {
// NOTE: no synchronization is needed here since this is just a dump of internal quartz state
// for debugging.
ImmutableMap.Builder<IJobKey, CrontabEntry> scheduledJobs = ImmutableMap.builder();
try {
for (JobKey jobKey : scheduler.getJobKeys(GroupMatcher.anyGroup())) {
// The quartz API allows jobs to have multiple triggers. We don't use that feature but
// we're defensive here since this function is used for debugging.
Optional<CronTrigger> trigger = FluentIterable.from(scheduler.getTriggersOfJob(jobKey))
.filter(CronTrigger.class)
.first();
if (trigger.isPresent()) {
scheduledJobs.put(
Quartz.auroraJobKey(jobKey),
Quartz.crontabEntry(trigger.get()));
}
}
} catch (SchedulerException e) {
throw new RuntimeException(e);
}
return scheduledJobs.build();
}
private static String formatMessage(String format, IJobKey jobKey, Object... args) {
return String.format(format, JobKeys.canonicalString(jobKey), args);
}
}