Blogs
How to create scheduled tasks for Liferay CE or DXP from 7.0 - 7.3.
In Liferay 6.x, scheduled tasks were kind of easy to implement.
I mean, you'd implement a class that implements the Liferay Message Bus's MessageListener interface and then add the details in the <scheduler-entry /> sections in your liferay-portlet.xml file and you'd be off to the races.
Well, things are not so simple with Liferay 7 CE / Liferay DXP. In fact, I couldn't find a reference anywhere on dev.liferay.com, so I thought I'd whip up a quick blog on them.
Of course I'm going to pursue this as an OSGi-only solution.
StorageType Information
The first thing we need to know before we schedule a job, we should first discuss the supported StorageTypes. Liferay has three supported StorageTypes:
- StorageType.MEMORY_CLUSTERED - This is the default storage type, one that you'll typically want to shoot for. This storage type combines two aspects, MEMORY and CLUSTERED. For MEMORY, that means the job information (next run, etc.) are only held in memory and are not persisted anywhere. For CLUSTERED, that means the job is cluster-aware and will only run on one node in the cluster.
- StorageType.MEMORY - For this storage type, no job information is persisted. The important part here is that you may miss some job runs in cases of outages. For example, if you have a job to run on the 1st of every month but you have a big outage and the server/cluster is down on the 1st, the job will not run. And unlike in PERSISTED, when the server comes up the job will not run even though it was missed. Note that this storage type is not cluster-aware, so your job will run on every node in the cluster which could cause duplicate runs.
- StorageType.PERSISTED - This is the opposite of MEMORY as job details will be persisted in the database. For the missed job above, when the server comes up on the 2nd it will realize the job was missed and will immediately process the job. Note that this storage type relies on cluster-support facilities in the storage engine (Quartz's implementation discussed here: http://www.quartz-scheduler.org/documentation/quartz-2.x/configuration/ConfigJDBCJobStoreClustering.html).
So if you're in a cluster, you'll want to stick with either MEMORY_CLUSTERED or PERSISTED to ensure your job doesn't run on every node (i.e. you're running a report to generate a PDF and email, you wouldn't want your 4 node cluster doing the report 4 times and emailing 4 copies). You may want to stick with the MEMORY type when you have, say, an administrative task that needs to run regularly on all nodes in your cluster.
Choosing between MEMORY[_CLUSTERED] and PERSISTED is how resiliant you need to be in the case of missed job fire times. For example, if that monthly report is mission critical, you might want to elect for PERSISTED to ensure the report goes out as soon as the cluster is back up and ready to pick up the missed job. However, if they are not mission critical it is easier to stick with one of the MEMORY options.
Finally, even if you're not currently in a cluster, I would encourage you to make choices as if you were running in a cluster right from the beginning. The last thing you want to have to do when you start scaling up your environment is trying to figure out why some previous regular tasks are not running as they used to when you had a single server.
Adding StorageType To SchedulerEntry
We'll be handling our scheduling shortly, but for now we'll worry about the SchedulerEntry. The SchedulerEntry object contains most of the details about the scheduled task to be defined, but it does not have details about the StorageType. Remember that MEMORY_CLUSTERED is the default, so if you're going to be using that type, you can skip this section. But to be consistent, you can still apply the changes in this section even for the MEMORY_CLUSTERED type.
To add StorageType details to our SchedulerEntry, we need to make our SchedulerEntry implementation class implement the com.liferay.portal.kernel.scheduler.ScheduleTypeAware interface. When Liferay's scheduler implementation classes are identifying the StorageType to use, it starts with MEMORY_CLUSTERED and will only use another StorageType if the SchedulerEntry implements this interface.
So let's start by defining a SchedulerEntry wrapper class that implements the SchedulerEntry interface as well as the StorageTypeAware interface:
public class StorageTypeAwareSchedulerEntryImpl extends SchedulerEntryImpl implements SchedulerEntry, StorageTypeAware {
/**
* StorageTypeAwareSchedulerEntryImpl: Constructor for the class.
* @param schedulerEntry
*/
public StorageTypeAwareSchedulerEntryImpl(final SchedulerEntryImpl schedulerEntry) {
super();
_schedulerEntry = schedulerEntry;
// use the same default that Liferay uses.
_storageType = StorageType.MEMORY_CLUSTERED;
}
/**
* StorageTypeAwareSchedulerEntryImpl: Constructor for the class.
* @param schedulerEntry
* @param storageType
*/
public StorageTypeAwareSchedulerEntryImpl(final SchedulerEntryImpl schedulerEntry, final StorageType storageType) {
super();
_schedulerEntry = schedulerEntry;
_storageType = storageType;
}
@Override
public String getDescription() {
return _schedulerEntry.getDescription();
}
@Override
public String getEventListenerClass() {
return _schedulerEntry.getEventListenerClass();
}
@Override
public StorageType getStorageType() {
return _storageType;
}
@Override
public Trigger getTrigger() {
return _schedulerEntry.getTrigger();
}
public void setDescription(final String description) {
_schedulerEntry.setDescription(description);
}
public void setTrigger(final Trigger trigger) {
_schedulerEntry.setTrigger(trigger);
}
public void setEventListenerClass(final String eventListenerClass) {
_schedulerEntry.setEventListenerClass(eventListenerClass);
}
private SchedulerEntryImpl _schedulerEntry;
private StorageType _storageType;
}
Now you can use this class to wrap a current SchedulerEntryImpl yet include the StorageTypeAware implementation.
Defining The Scheduled Task
We have all of the pieces now to build out the code for a scheduled task in Liferay 7 CE / Liferay DXP:
@Component(
immediate = true, property = {"cron.expression=0 0 0 * * ?"},
service = MyTaskMessageListener.class
)
public class MyTaskMessageListener extends BaseSchedulerEntryMessageListener {
/**
* doReceive: This is where the magic happens, this is where you want to do the work for
* the scheduled job.
* @param message This is the message object tied to the job. If you stored data with the
* job, the message will contain that data.
* @throws Exception In case there is some sort of error processing the task.
*/
@Override
protected void doReceive(Message message) throws Exception {
_log.info("Scheduled task executed...");
}
/**
* activate: Called whenever the properties for the component change (ala Config Admin)
* or OSGi is activating the component.
* @param properties The properties map from Config Admin.
* @throws SchedulerException in case of error.
*/
@Activate
@Modified
protected void activate(Map<String,Object> properties) throws SchedulerException {
// extract the cron expression from the properties
String cronExpression = GetterUtil.getString(properties.get("cron.expression"), _DEFAULT_CRON_EXPRESSION);
// create a new trigger definition for the job.
String listenerClass = getEventListenerClass();
Trigger jobTrigger = _triggerFactory.createTrigger(listenerClass, listenerClass, new Date(), null, cronExpression);
// wrap the current scheduler entry in our new wrapper.
// use the persisted storaget type and set the wrapper back to the class field.
schedulerEntryImpl = new StorageTypeAwareSchedulerEntryImpl(schedulerEntryImpl, StorageType.PERSISTED);
// update the trigger for the scheduled job.
schedulerEntryImpl.setTrigger(jobTrigger);
// if we were initialized (i.e. if this is called due to CA modification)
if (_initialized) {
// first deactivate the current job before we schedule.
deactivate();
}
// register the scheduled task
_schedulerEngineHelper.register(this, schedulerEntryImpl, DestinationNames.SCHEDULER_DISPATCH);
// set the initialized flag.
_initialized = true;
}
/**
* deactivate: Called when OSGi is deactivating the component.
*/
@Deactivate
protected void deactivate() {
// if we previously were initialized
if (_initialized) {
// unschedule the job so it is cleaned up
try {
_schedulerEngineHelper.unschedule(schedulerEntryImpl, getStorageType());
} catch (SchedulerException se) {
if (_log.isWarnEnabled()) {
_log.warn("Unable to unschedule trigger", se);
}
}
// unregister this listener
_schedulerEngineHelper.unregister(this);
}
// clear the initialized flag
_initialized = false;
}
/**
* getStorageType: Utility method to get the storage type from the scheduler entry wrapper.
* @return StorageType The storage type to use.
*/
protected StorageType getStorageType() {
if (schedulerEntryImpl instanceof StorageTypeAware) {
return ((StorageTypeAware) schedulerEntryImpl).getStorageType();
}
return StorageType.MEMORY_CLUSTERED;
}
/**
* setModuleServiceLifecycle: So this requires some explanation...
*
* OSGi will start a component once all of it's dependencies are satisfied. However, there
* are times where you want to hold off until the portal is completely ready to go.
*
* This reference declaration is waiting for the ModuleServiceLifecycle's PORTAL_INITIALIZED
* component which will not be available until, surprise surprise, the portal has finished
* initializing.
*
* With this reference, this component activation waits until portal initialization has completed.
* @param moduleServiceLifecycle
*/
@Reference(target = ModuleServiceLifecycle.PORTAL_INITIALIZED, unbind = "-")
protected void setModuleServiceLifecycle(ModuleServiceLifecycle moduleServiceLifecycle) {
}
@Reference(unbind = "-")
protected void setTriggerFactory(TriggerFactory triggerFactory) {
_triggerFactory = triggerFactory;
}
@Reference(unbind = "-")
protected void setSchedulerEngineHelper(SchedulerEngineHelper schedulerEngineHelper) {
_schedulerEngineHelper = schedulerEngineHelper;
}
// the default cron expression is to run daily at midnight
private static final String _DEFAULT_CRON_EXPRESSION = "0 0 0 * * ?";
private static final Log _log = LogFactoryUtil.getLog(MyTaskMessageListener.class);
private volatile boolean _initialized;
private TriggerFactory _triggerFactory;
private SchedulerEngineHelper _schedulerEngineHelper;
}
So the code here is kinda thick, but I've documented it as fully as I can.
The base class, BaseSchedulerEntryMessageListener, is a common base class for all schedule-based message listeners. It is pretty short, so you are encouraged to open it up in the source and peruse it to see what few services it provides.
The bulk of the code you can use as-is. You'll probably want to come up with your own default cron expression constant and property so you're not running at midnight (and that's midnight GMT, cron expressions are always based on the timezone your app server is configured to run on).
And you'll certainly want to fill out the doReceive() method to actually build your scheduled task logic.
One More Thing...
One thing to keep in mind, especially with the MEMORY and MEMORY_CLUSTERED storage types: Liferay does not do anything to prevent running the same jobs multiple times.
For example, say you have a job that takes 10 minutes to run, but you schedule it to run every 5 minutes. There's no way the job can complete in 5 minutes, so multiple jobs start piling up. Sure there's a pool backing the implementation to ensure the system doesn't run away and die on you, but even that might lead to disasterous results.
So take care in your scheduling. Know what the worst case scenario is for timing your jobs and use that information to define a schedule that will work even in this situation.
You may even want to consider some sort of locking or semaphore mechanism to prevent the same job running in parallel at all.
Just something to keep in mind...
Conclusion
So this is how all of those scheduled tasks from liferay-portlet.xml get migrated into the OSGi environment. Using this technique, you now have a migration path for this aspect of your legacy portlet code.
Update 05/18/2017
So I was contacted today about the use of the BaseSchedulerEntryMessageListener class as the base class for the message listener. Apparently this class has become deprecated as of DXP FP 13 as well as the upcoming GA4 release.
The only guidance I was given for updating the code happens to be the same guidance that I give to most folks wanting to know how to do something in Liferay - find an example in the Liferay source.
After reviewing various Liferay examples, we will need to change the parent class for our implementation and modify the activation code.
So now our message listener class is:
@Component(
immediate = true, property = {"cron.expression=0 0 0 * * ?"},
service = MyTaskMessageListener.class
)
public class MyTaskMessageListener extends BaseMessageListener {
/**
* doReceive: This is where the magic happens, this is where you want to do the work for
* the scheduled job.
* @param message This is the message object tied to the job. If you stored data with the
* job, the message will contain that data.
* @throws Exception In case there is some sort of error processing the task.
*/
@Override
protected void doReceive(Message message) throws Exception {
_log.info("Scheduled task executed...");
}
/**
* activate: Called whenever the properties for the component change (ala Config Admin)
* or OSGi is activating the component.
* @param properties The properties map from Config Admin.
* @throws SchedulerException in case of error.
*/
@Activate
@Modified
protected void activate(Map<String,Object> properties) throws SchedulerException {
// extract the cron expression from the properties
String cronExpression = GetterUtil.getString(properties.get("cron.expression"), _DEFAULT_CRON_EXPRESSION);
// create a new trigger definition for the job.
String listenerClass = getClass().getName();
Trigger jobTrigger = _triggerFactory.createTrigger(listenerClass, listenerClass, new Date(), null, cronExpression);
// wrap the current scheduler entry in our new wrapper.
// use the persisted storaget type and set the wrapper back to the class field.
_schedulerEntryImpl = new SchedulerEntryImpl(getClass().getName(), jobTrigger);
_schedulerEntryImpl = new StorageTypeAwareSchedulerEntryImpl(_schedulerEntryImpl, StorageType.PERSISTED);
// update the trigger for the scheduled job.
_schedulerEntryImpl.setTrigger(jobTrigger);
// if we were initialized (i.e. if this is called due to CA modification)
if (_initialized) {
// first deactivate the current job before we schedule.
deactivate();
}
// register the scheduled task
_schedulerEngineHelper.register(this, _schedulerEntryImpl, DestinationNames.SCHEDULER_DISPATCH);
// set the initialized flag.
_initialized = true;
}
/**
* deactivate: Called when OSGi is deactivating the component.
*/
@Deactivate
protected void deactivate() {
// if we previously were initialized
if (_initialized) {
// unschedule the job so it is cleaned up
try {
_schedulerEngineHelper.unschedule(_schedulerEntryImpl, getStorageType());
} catch (SchedulerException se) {
if (_log.isWarnEnabled()) {
_log.warn("Unable to unschedule trigger", se);
}
}
// unregister this listener
_schedulerEngineHelper.unregister(this);
}
// clear the initialized flag
_initialized = false;
}
/**
* getStorageType: Utility method to get the storage type from the scheduler entry wrapper.
* @return StorageType The storage type to use.
*/
protected StorageType getStorageType() {
if (_schedulerEntryImpl instanceof StorageTypeAware) {
return ((StorageTypeAware) _schedulerEntryImpl).getStorageType();
}
return StorageType.MEMORY_CLUSTERED;
}
/**
* setModuleServiceLifecycle: So this requires some explanation...
*
* OSGi will start a component once all of it's dependencies are satisfied. However, there
* are times where you want to hold off until the portal is completely ready to go.
*
* This reference declaration is waiting for the ModuleServiceLifecycle's PORTAL_INITIALIZED
* component which will not be available until, surprise surprise, the portal has finished
* initializing.
*
* With this reference, this component activation waits until portal initialization has completed.
* @param moduleServiceLifecycle
*/
@Reference(target = ModuleServiceLifecycle.PORTAL_INITIALIZED, unbind = "-")
protected void setModuleServiceLifecycle(ModuleServiceLifecycle moduleServiceLifecycle) {
}
@Reference(unbind = "-")
protected void setTriggerFactory(TriggerFactory triggerFactory) {
_triggerFactory = triggerFactory;
}
@Reference(unbind = "-")
protected void setSchedulerEngineHelper(SchedulerEngineHelper schedulerEngineHelper) {
_schedulerEngineHelper = schedulerEngineHelper;
}
// the default cron expression is to run daily at midnight
private static final String _DEFAULT_CRON_EXPRESSION = "0 0 0 * * ?";
private static final Log _log = LogFactoryUtil.getLog(MyTaskMessageListener.class);
private volatile boolean _initialized;
private TriggerFactory _triggerFactory;
private SchedulerEngineHelper _schedulerEngineHelper;
private SchedulerEntryImpl _schedulerEntryImpl = null;
}
That's all there is to it, but it's best to avoid the deprecated class since you never know when deprecated will become disappeared...

