/* * See the NOTICE file distributed with this work for additional * information regarding copyright ownership. * * This is free software; you can redistribute it and/or modify it * under the terms of the GNU Lesser General Public License as * published by the Free Software Foundation; either version 2.1 of * the License, or (at your option) any later version. * * This software is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this software; if not, write to the Free * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA * 02110-1301 USA, or see the FSF site: http://www.fsf.org. */ package com.xpn.xwiki.plugin.scheduler; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.List; import javax.inject.Provider; import org.apache.commons.lang3.exception.ExceptionUtils; import org.quartz.CronScheduleBuilder; import org.quartz.Job; import org.quartz.JobBuilder; import org.quartz.JobDataMap; import org.quartz.JobKey; import org.quartz.Scheduler; import org.quartz.SchedulerException; import org.quartz.Trigger; import org.quartz.Trigger.TriggerState; import org.quartz.TriggerBuilder; import org.quartz.TriggerKey; import org.quartz.impl.StdSchedulerFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xwiki.bridge.event.DocumentCreatedEvent; import org.xwiki.bridge.event.DocumentDeletedEvent; import org.xwiki.bridge.event.DocumentUpdatedEvent; import org.xwiki.context.concurrent.ExecutionContextRunnable; import org.xwiki.model.reference.DocumentReference; import org.xwiki.model.reference.EntityReference; import org.xwiki.observation.EventListener; import org.xwiki.observation.ObservationManager; import org.xwiki.observation.event.Event; import org.xwiki.script.service.ScriptServiceManager; import com.xpn.xwiki.XWikiContext; import com.xpn.xwiki.XWikiException; import com.xpn.xwiki.api.Api; import com.xpn.xwiki.doc.XWikiDocument; import com.xpn.xwiki.objects.BaseObject; import com.xpn.xwiki.plugin.XWikiDefaultPlugin; import com.xpn.xwiki.plugin.XWikiPluginInterface; import com.xpn.xwiki.plugin.scheduler.internal.SchedulerJobClassDocumentInitializer; import com.xpn.xwiki.plugin.scheduler.internal.StatusListener; import com.xpn.xwiki.web.Utils; import com.xpn.xwiki.web.XWikiResponse; import com.xpn.xwiki.web.XWikiServletRequest; import com.xpn.xwiki.web.XWikiServletRequestStub; import com.xpn.xwiki.web.XWikiServletResponseStub; /** * See {@link com.xpn.xwiki.plugin.scheduler.SchedulerPluginApi} for documentation. * * @version $Id: abd58368fa746670c79d8979f0d782a2a352581e $ */ public class SchedulerPlugin extends XWikiDefaultPlugin implements EventListener { /** * Log object to log messages in this class. */ private static final Logger LOGGER = LoggerFactory.getLogger(SchedulerPlugin.class); /** * Fullname of the XWiki Scheduler Job Class representing a job that can be scheduled by this plugin. * * @deprecated use {@link #XWIKI_JOB_CLASSREFERENCE} instead */ @Deprecated public static final String XWIKI_JOB_CLASS = "XWiki.SchedulerJobClass"; /** * Local reference of the XWiki Scheduler Job Class representing a job that can be scheduled by this plugin. */ public static final EntityReference XWIKI_JOB_CLASSREFERENCE = SchedulerJobClassDocumentInitializer.XWIKI_JOB_CLASSREFERENCE; private static final List<Event> EVENTS = Arrays.<Event>asList(new DocumentCreatedEvent(), new DocumentDeletedEvent(), new DocumentUpdatedEvent()); /** * Default Quartz scheduler instance. */ private Scheduler scheduler; /** * Default plugin constructor. * * @see XWikiDefaultPlugin#XWikiDefaultPlugin(String,String,com.xpn.xwiki.XWikiContext) */ public SchedulerPlugin(String name, String className, XWikiContext context) { super(name, className, context); } @Override public void init(XWikiContext context) { Thread thread = new Thread(new ExecutionContextRunnable(new Runnable() { @Override public void run() { initAsync(); } }, Utils.getComponentManager())); thread.setName("XWiki Scheduler initialization"); thread.setDaemon(true); thread.start(); // Start listening to documents modifications Utils.getComponent(ObservationManager.class).addListener(this); } private void initAsync() { XWikiContext xcontext = Utils.<Provider<XWikiContext>>getComponent(XWikiContext.TYPE_PROVIDER).get(); try { String initialDb = !xcontext.getWikiId().equals("") ? xcontext.getWikiId() : xcontext.getMainXWiki(); List<String> wikiServers = new ArrayList<String>(); try { wikiServers = xcontext.getWiki().getVirtualWikisDatabaseNames(xcontext); } catch (Exception e) { LOGGER.error("error getting list of wiki servers!", e); } // Before we start the thread ensure that Quartz will create daemon threads so that // the JVM can exit properly. System.setProperty("org.quartz.scheduler.makeSchedulerThreadDaemon", "true"); System.setProperty("org.quartz.threadPool.makeThreadsDaemons", "true"); setScheduler(getDefaultSchedulerInstance()); setStatusListener(); getScheduler().start(); // Restore jobs try { // Iterate on all virtual wikis for (String wikiName : wikiServers) { xcontext.setWikiId(wikiName); restoreExistingJobs(xcontext); } } finally { xcontext.setWikiId(initialDb); } } catch (SchedulerException e) { LOGGER.error("Failed to start the scheduler", e); } catch (SchedulerPluginException e) { LOGGER.error("Failed to initialize the scheduler", e); } } /** * Create and feed a stub context for the job execution thread. Stub context data are retrieved from job object * fields "contextUser", "contextLang", "contextDatabase". If one of this field is empty (this would typically * happen on the first schedule operation), it is instead retrieved from the passed context, and the job object is * updated with this value. This mean that this method may modify the passed object. * * @param job the job for which the context will be prepared * @param context the XWikiContext at preparation time. This is a real context associated with a servlet request * @return the stub context prepared with job data */ private XWikiContext prepareJobStubContext(BaseObject job, XWikiContext context) throws SchedulerPluginException { boolean jobNeedsUpdate = false; String cUser = job.getStringValue("contextUser"); if (cUser.equals("")) { // The context user has not been filled yet. // We can suppose it's the first scheduling. Let's assume it's the context user cUser = context.getUser(); job.setStringValue("contextUser", cUser); jobNeedsUpdate = true; } String cLang = job.getStringValue("contextLang"); if (cLang.equals("")) { cLang = context.getLanguage(); job.setStringValue("contextLang", cLang); jobNeedsUpdate = true; } String iDb = context.getWikiId(); String cDb = job.getStringValue("contextDatabase"); if (cDb.equals("") || !cDb.equals(iDb)) { cDb = context.getWikiId(); job.setStringValue("contextDatabase", cDb); jobNeedsUpdate = true; } if (jobNeedsUpdate) { try { context.setWikiId(cDb); XWikiDocument jobHolder = context.getWiki().getDocument(job.getName(), context); jobHolder.setMinorEdit(true); context.getWiki().saveDocument(jobHolder, context); } catch (XWikiException e) { throw new SchedulerPluginException( SchedulerPluginException.ERROR_SCHEDULERPLUGIN_UNABLE_TO_PREPARE_JOB_CONTEXT, "Failed to prepare context for job with job name " + job.getStringValue("jobName"), e); } finally { context.setWikiId(iDb); } } // lets now build the stub context XWikiContext scontext = context.clone(); scontext.setWiki(context.getWiki()); context.getWiki().getStore().cleanUp(context); // We are sure the context request is a real servlet request // So we force the dummy request with the current host XWikiServletRequestStub dummy = new XWikiServletRequestStub(); dummy.setHost(context.getRequest().getHeader("x-forwarded-host")); dummy.setScheme(context.getRequest().getScheme()); dummy.setContextPath(context.getRequest().getContextPath()); XWikiServletRequest request = new XWikiServletRequest(dummy); scontext.setRequest(request); // Force forged context response to a stub response, since the current context response // will not mean anything anymore when running in the scheduler's thread, and can cause // errors. XWikiResponse stub = new XWikiServletResponseStub(); scontext.setResponse(stub); // feed the dummy context scontext.setUser(cUser); scontext.setLanguage(cLang); scontext.setWikiId(cDb); scontext.setMainXWiki(context.getMainXWiki()); if (scontext.getURL() == null) { try { scontext.setURL(new URL("http://www.mystuburl.com/")); } catch (Exception e) { // the URL is well formed, I promise } } com.xpn.xwiki.web.XWikiURLFactory xurf = context.getURLFactory(); if (xurf == null) { xurf = context.getWiki().getURLFactoryService().createURLFactory(context.getMode(), context); } scontext.setURLFactory(xurf); try { XWikiDocument cDoc = context.getWiki().getDocument(job.getDocumentReference(), context); scontext.setDoc(cDoc); } catch (Exception e) { throw new SchedulerPluginException( SchedulerPluginException.ERROR_SCHEDULERPLUGIN_UNABLE_TO_PREPARE_JOB_CONTEXT, "Failed to prepare context for job with job name " + job.getStringValue("jobName"), e); } return scontext; } /** * Restore the existing job, by looking up for such job in the database and re-scheduling those according to their * stored status. If a Job is stored with the status "Normal", it is just scheduled If a Job is stored with the * status "Paused", then it is both scheduled and paused. Jobs with other status (None, Complete) are not * rescheduled. * * @param context The XWikiContext when initializing the plugin */ private void restoreExistingJobs(XWikiContext context) { String hql = ", BaseObject as obj where obj.name=doc.fullName and obj.className='XWiki.SchedulerJobClass'"; try { List<DocumentReference> jobDocReferences = context.getWiki().getStore().searchDocumentReferences(hql, context); for (DocumentReference docReference : jobDocReferences) { try { XWikiDocument jobDoc = context.getWiki().getDocument(docReference, context); register(jobDoc, context); } catch (Exception e) { LOGGER.error("Failed to restore job with in document [{}] and wiki [{}]", docReference, context.getWikiId(), e); } } } catch (Exception e) { LOGGER.error("Failed to restore existing scheduler jobs in wiki [{}]", context.getWikiId(), e); } } private void register(XWikiDocument jobDoc, XWikiContext context) throws SchedulerPluginException { BaseObject jobObj = jobDoc.getXObject(XWIKI_JOB_CLASSREFERENCE); register(jobObj, context); } private void register(BaseObject jobObj, XWikiContext context) throws SchedulerPluginException { String status = jobObj.getStringValue("status"); if (status.equals(JobState.STATE_NORMAL) || status.equals(JobState.STATE_PAUSED)) { scheduleJob(jobObj, context); } if (status.equals(JobState.STATE_PAUSED)) { pauseJob(jobObj, context); } } private void unregister(BaseObject jobObj, XWikiContext context) throws SchedulerPluginException { String status = jobObj.getStringValue("status"); if (status.equals(JobState.STATE_NORMAL) || status.equals(JobState.STATE_PAUSED)) { scheduleJob(jobObj, context); } if (status.equals(JobState.STATE_PAUSED)) { pauseJob(jobObj, context); } } /** * Retrieve the job's status of a given {@link com.xpn.xwiki.plugin.scheduler.SchedulerPlugin#XWIKI_JOB_CLASS} job * XObject, by asking the actual job status to the quartz scheduler instance. It's the actual status, as the one * stored in the XObject may be changed manually by users. * * @param object the XObject to give the status of * @return the status of the Job inside the quartz scheduler, as {@link com.xpn.xwiki.plugin.scheduler.JobState} * instance */ public JobState getJobStatus(BaseObject object, XWikiContext context) throws SchedulerException { TriggerState state = getScheduler().getTriggerState(new TriggerKey(getObjectUniqueId(object, context))); return new JobState(state); } public boolean scheduleJob(BaseObject object, XWikiContext context) throws SchedulerPluginException { boolean scheduled = true; try { // compute the job unique Id String xjob = getObjectUniqueId(object, context); // Load the job class. // Note: Remember to always use the current thread's class loader and not the container's // (Class.forName(...)) since otherwise we will not be able to load classes installed with EM. ClassLoader currentThreadClassLoader = Thread.currentThread().getContextClassLoader(); String jobClassName = object.getStringValue("jobClass"); Class<Job> jobClass = (Class<Job>) Class.forName(jobClassName, true, currentThreadClassLoader); // Build the new job. JobBuilder jobBuilder = JobBuilder.newJob(jobClass); jobBuilder.withIdentity(xjob); jobBuilder.storeDurably(); JobDataMap data = new JobDataMap(); // Let's prepare an execution context... XWikiContext stubContext = prepareJobStubContext(object, context); data.put("context", stubContext); data.put("xcontext", stubContext); data.put("xwiki", new com.xpn.xwiki.api.XWiki(context.getWiki(), stubContext)); data.put("xjob", object); data.put("services", Utils.getComponent(ScriptServiceManager.class)); jobBuilder.setJobData(data); getScheduler().addJob(jobBuilder.build(), true); TriggerBuilder<Trigger> triggerBuilder = TriggerBuilder.newTrigger(); triggerBuilder.withIdentity(xjob); triggerBuilder.forJob(xjob); triggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(object.getStringValue("cron"))); Trigger trigger = triggerBuilder.build(); JobState status = getJobStatus(object, context); switch (status.getQuartzState()) { case PAUSED: // a paused job must be resumed, not scheduled break; case NORMAL: if (getTrigger(object, context).compareTo(trigger) != 0) { LOGGER.debug("Reschedule Job: [{}]", object.getStringValue("jobName")); } getScheduler().rescheduleJob(trigger.getKey(), trigger); break; case NONE: LOGGER.debug("Schedule Job: [{}]", object.getStringValue("jobName")); getScheduler().scheduleJob(trigger); LOGGER.info("XWiki Job Status: [{}]", object.getStringValue("status")); if (object.getStringValue("status").equals("Paused")) { getScheduler().pauseJob(new JobKey(xjob)); saveStatus("Paused", object, context); } else { saveStatus("Normal", object, context); } break; default: LOGGER.debug("Schedule Job: [{}]", object.getStringValue("jobName")); getScheduler().scheduleJob(trigger); saveStatus("Normal", object, context); break; } } catch (SchedulerException e) { throw new SchedulerPluginException(SchedulerPluginException.ERROR_SCHEDULERPLUGIN_SCHEDULE_JOB, "Error while scheduling job " + object.getStringValue("jobName"), e); } catch (ClassNotFoundException e) { throw new SchedulerPluginException(SchedulerPluginException.ERROR_SCHEDULERPLUGIN_JOB_XCLASS_NOT_FOUND, "Error while loading job class for job : " + object.getStringValue("jobName"), e); } catch (XWikiException e) { throw new SchedulerPluginException(SchedulerPluginException.ERROR_SCHEDULERPLUGIN_JOB_XCLASS_NOT_FOUND, "Error while saving job status for job : " + object.getStringValue("jobName"), e); } return scheduled; } /** * Pause the job with the given name by pausing all of its current triggers. * * @param object the non-wrapped XObject Job to be paused */ public void pauseJob(BaseObject object, XWikiContext context) throws SchedulerPluginException { String job = getObjectUniqueId(object, context); try { getScheduler().pauseJob(new JobKey(job)); saveStatus("Paused", object, context); } catch (SchedulerException e) { throw new SchedulerPluginException(SchedulerPluginException.ERROR_SCHEDULERPLUGIN_PAUSE_JOB, "Error occured while trying to pause job " + object.getStringValue("jobName"), e); } catch (XWikiException e) { throw new SchedulerPluginException(SchedulerPluginException.ERROR_SCHEDULERPLUGIN_PAUSE_JOB, "Error occured while trying to save status of job " + object.getStringValue("jobName"), e); } } /** * Resume the job with the given name (un-pause) * * @param object the non-wrapped XObject Job to be resumed */ public void resumeJob(BaseObject object, XWikiContext context) throws SchedulerPluginException { String job = getObjectUniqueId(object, context); try { getScheduler().resumeJob(new JobKey(job)); saveStatus("Normal", object, context); } catch (SchedulerException e) { throw new SchedulerPluginException(SchedulerPluginException.ERROR_SCHEDULERPLUGIN_RESUME_JOB, "Error occured while trying to resume job " + object.getStringValue("jobName"), e); } catch (XWikiException e) { throw new SchedulerPluginException(SchedulerPluginException.ERROR_SCHEDULERPLUGIN_RESUME_JOB, "Error occured while trying to save status of job " + object.getStringValue("jobName"), e); } } /** * Trigger a job (execute it now) * * @param object the non-wrapped XObject Job to be triggered * @param context the XWiki context */ public void triggerJob(BaseObject object, XWikiContext context) throws SchedulerPluginException { String job = getObjectUniqueId(object, context); try { getScheduler().triggerJob(new JobKey(job)); } catch (SchedulerException e) { throw new SchedulerPluginException(SchedulerPluginException.ERROR_SCHEDULERPLUGIN_TRIGGER_JOB, "Error occured while trying to trigger job " + object.getStringValue("jobName"), e); } } /** * Unschedule the given job * * @param object the unwrapped XObject job to be unscheduled */ public void unscheduleJob(BaseObject object, XWikiContext context) throws SchedulerPluginException { String job = getObjectUniqueId(object, context); try { getScheduler().deleteJob(new JobKey(job)); saveStatus("None", object, context); } catch (SchedulerException e) { throw new SchedulerPluginException(SchedulerPluginException.ERROR_SCHEDULERPLUGIN_JOB_XCLASS_NOT_FOUND, "Error while unscheduling job " + object.getStringValue("jobName"), e); } catch (XWikiException e) { throw new SchedulerPluginException(SchedulerPluginException.ERROR_SCHEDULERPLUGIN_JOB_XCLASS_NOT_FOUND, "Error while saving status of job " + object.getStringValue("jobName"), e); } } /** * Get Trigger object of the given job * * @param object the unwrapped XObject to be retrieve the trigger for * @param context the XWiki context * @return the trigger object of the given job */ private Trigger getTrigger(BaseObject object, XWikiContext context) throws SchedulerPluginException { String job = getObjectUniqueId(object, context); Trigger trigger; try { trigger = getScheduler().getTrigger(new TriggerKey(job)); } catch (SchedulerException e) { throw new SchedulerPluginException(SchedulerPluginException.ERROR_SCHEDULERPLUGIN_JOB_XCLASS_NOT_FOUND, "Error while getting trigger for job " + job, e); } if (trigger == null) { throw new SchedulerPluginException(SchedulerPluginException.ERROR_SCHEDULERPLUGIN_JOB_DOES_NOT_EXITS, "Job does not exists"); } return trigger; } /** * Give, for a BaseObject job in a {@link JobState#STATE_NORMAL} state, the previous date at which the job has been * executed. Note that this method does not compute a date from the CRON expression, it only returns a date value * which is set each time the job is executed. If the job has never been fired this method will return null. * * @param object unwrapped XObject job for which the next fire time will be given * @param context the XWiki context * @return the next Date the job will be fired at, null if the job has never been fired */ public Date getPreviousFireTime(BaseObject object, XWikiContext context) throws SchedulerPluginException { return getTrigger(object, context).getPreviousFireTime(); } /** * Get the next fire time for the given job name SchedulerJob * * @param object unwrapped XObject job for which the next fire time will be given * @return the next Date the job will be fired at */ public Date getNextFireTime(BaseObject object, XWikiContext context) throws SchedulerPluginException { return getTrigger(object, context).getNextFireTime(); } @Override public Api getPluginApi(XWikiPluginInterface plugin, XWikiContext context) { return new SchedulerPluginApi((SchedulerPlugin) plugin, context); } @Override public String getName() { return "scheduler"; } /** * @param scheduler the scheduler to use */ public void setScheduler(Scheduler scheduler) { this.scheduler = scheduler; } /** * @return the scheduler in use */ public Scheduler getScheduler() { return this.scheduler; } /** * @return the default Scheduler instance * @throws SchedulerPluginException if the default Scheduler instance failed to be retrieved for any reason. Note * that on the first call the default scheduler is also initialized. */ private synchronized Scheduler getDefaultSchedulerInstance() throws SchedulerPluginException { Scheduler scheduler; try { scheduler = StdSchedulerFactory.getDefaultScheduler(); } catch (SchedulerException e) { throw new SchedulerPluginException(SchedulerPluginException.ERROR_SCHEDULERPLUGIN_GET_SCHEDULER, "Error getting default Scheduler instance", e); } return scheduler; } /** * Associates the scheduler with a StatusListener * * @throws SchedulerPluginException if the status listener failed to be set properly */ private void setStatusListener() throws SchedulerPluginException { StatusListener listener = new StatusListener(); try { getScheduler().getListenerManager().addSchedulerListener(listener); getScheduler().getListenerManager().addJobListener(listener); } catch (SchedulerException e) { throw new SchedulerPluginException( SchedulerPluginException.ERROR_SCHEDULERPLUGIN_INITIALIZE_STATUS_LISTENER, "Error while initializing the status listener", e); } } private void saveStatus(String status, BaseObject object, XWikiContext context) throws XWikiException { XWikiDocument jobHolder = context.getWiki().getDocument(object.getDocumentReference(), context); // We need to retrieve the object BaseObject the document again. Otherwise, modifications made to the // BaseObject passed as argument will not be saved (XWikiDocument#getObject clones the document // and returns the BaseObject from the clone) // TODO refactor the plugin in order to stop passing BaseObject around, passing document references instead. BaseObject job = jobHolder.getXObject(XWIKI_JOB_CLASSREFERENCE); job.setStringValue("status", status); jobHolder.setMinorEdit(true); context.getWiki().saveDocument(jobHolder, context); } /** * Compute a cross-document unique {@link com.xpn.xwiki.objects.BaseObject} id, by concatenating its name (it's * document holder full name, such as "SomeSpace.SomeDoc") and it's instance number inside this document. * <p> * The scheduler uses this unique object id to assure the unicity of jobs * * @return a unique String that can identify the object */ private String getObjectUniqueId(BaseObject object, XWikiContext context) { return context.getWikiId() + ":" + object.getName() + "_" + object.getNumber(); } @Override public List<Event> getEvents() { return EVENTS; } @Override public void onEvent(Event event, Object source, Object data) { XWikiContext xcontext = (XWikiContext) data; XWikiDocument document = (XWikiDocument) source; XWikiDocument originalDocument = document.getOriginalDocument(); BaseObject jobObj = document.getXObject(XWIKI_JOB_CLASSREFERENCE); BaseObject originalJobObj = originalDocument.getXObject(XWIKI_JOB_CLASSREFERENCE); if (jobObj == null) { if (originalJobObj != null) { // Job deleted try { unregister(originalJobObj, xcontext); } catch (SchedulerPluginException e) { LOGGER.warn("Failed to register job in document [{}]: {}", document.getDocumentReference(), ExceptionUtils.getRootCauseMessage(e)); } } } else { if (originalJobObj == null) { // New job try { register(jobObj, xcontext); } catch (SchedulerPluginException e) { LOGGER.warn("Failed to register job in document [{}]: {}", document.getDocumentReference(), ExceptionUtils.getRootCauseMessage(e)); } } } } }