CronJobs Done Right: Authoring, Scheduling, and Surviving the Cloud
The full lifecycle of background work in SAP Commerce: JobPerformable anatomy, triggers and cron expressions, node groups and CCv2 aspect placement, abortable and resumable jobs, composite and distributed patterns, log retention, and the monitoring that catches silent failures.
Every SAP Commerce estate runs on background work: syncs, imports, feeds, cleanups, recalculations. The cronjob framework that carries all of it is old, reliable, and full of defaults that were tuned for a different decade, so real projects meet the same failures on schedule: jobs that die with the node and never resume, jobs piling onto the Backoffice node because someone clicked "run", CronJobHistory eating the database, and the silent job that has failed nightly for a month because nobody alerted on result state. This guide is the authoring and operating manual that prevents the reruns.
Anatomy: Job, CronJob, Trigger#
Three items cooperate. The ServicelayerJob points at your Spring bean (the performable). The CronJob item is one configured execution definition: parameters (via CronJob subtypes with your attributes), node group, log settings, state. The Trigger schedules it with a cron expression. Authoring means one bean plus items.xml plus ImpEx:
public class StaleCartCleanupJob extends AbstractJobPerformable<StaleCartCleanupCronJobModel> {
@Override
public PerformResult perform(final StaleCartCleanupCronJobModel cronJob) {
int removed = 0;
FlexibleSearchQuery fsq = new FlexibleSearchQuery(
"SELECT {pk} FROM {Cart} WHERE {modifiedtime} < ?cutoff AND {user} = ?anon AND {pk} > ?last ORDER BY {pk}");
// keyset batches: restart-safe, flat memory (see the FlexibleSearch guide)
long lastPk = cronJob.getLastProcessedPk() == null ? 0 : cronJob.getLastProcessedPk();
while (true) {
if (clearAbortRequestedIfNeeded(cronJob)) {
return new PerformResult(CronJobResult.UNKNOWN, CronJobStatus.ABORTED);
}
fsq.addQueryParameter("cutoff", Date.from(Instant.now().minus(Duration.ofDays(30))));
fsq.addQueryParameter("anon", userService.getAnonymousUser());
fsq.addQueryParameter("last", lastPk);
fsq.setCount(500);
SearchResult<CartModel> batch = flexibleSearchService.search(fsq);
if (batch.getResult().isEmpty()) break;
modelService.removeAll(batch.getResult());
lastPk = batch.getResult().get(batch.getResult().size() - 1).getPk().getLong();
cronJob.setLastProcessedPk(lastPk);
modelService.save(cronJob); // progress survives a pod recycle
modelService.detachAll(); // flat session cache across the run
removed += batch.getResult().size();
}
return new PerformResult(CronJobResult.SUCCESS, CronJobStatus.FINISHED);
}
@Override
public boolean isAbortable() { return true; } // say it AND honor it above
}
<itemtype code="StaleCartCleanupCronJob" extends="CronJob"
jaloclass="de.hybris.platform.cronjob.jalo.CronJob" generate="true" autocreate="true">
<attributes>
<attribute qualifier="lastProcessedPk" type="java.lang.Long">
<persistence type="property"/>
</attribute>
</attributes>
</itemtype>
INSERT_UPDATE ServicelayerJob; code[unique=true] ; springId
; staleCartCleanupJob ; staleCartCleanupJob
INSERT_UPDATE StaleCartCleanupCronJob; code[unique=true] ; job(code) ; nodeGroup ; sessionLanguage(isocode)
; staleCartCleanupCJ ; staleCartCleanupJob; batch ; en
INSERT_UPDATE Trigger; cronJob(code)[unique=true]; cronExpression
; staleCartCleanupCJ ; 0 30 3 * * ?
The example encodes the four non-negotiables at once: keyset batching (memory stays flat, per the clustering guide's fixed-footprint rule), persisted progress (a recycled node resumes instead of restarting), honest abortability (isAbortable() true and the abort check in the loop; the checkbox without the check is a lie the ops team discovers mid-incident), and detachAll on cadence (session cache growth is the classic long-job killer).
Placement: Node Groups and Aspects#
The scheduling facts that decide where work runs, consolidated from the clustering guide because they cause most cronjob incidents:
- Trigger-fired jobs run on the
backgroundProcessingaspect (CCv2) or wherever the task engine picks unless constrained. Constrain them:nodeGroupon the CronJob, matchingcluster.node.groupsin the aspect or node configuration, gives deterministic placement (batch,integration,syncgroups are a sane taxonomy). - Manually triggered jobs run on the node you clicked in. Backoffice "run now" on a heavy job means running it on the Backoffice node against live editors. Policy plus training, because no property fixes ergonomics.
- Sequential dependencies are real dependencies: a feed export that must follow catalog sync should be a CompositeCronJob or triggered by the predecessor's completion (a
AfterCronJobFinishedEventlistener), not two cron expressions thirty minutes apart hoping the first finishes. Timetable coupling is the flakiest integration pattern in the platform. - Genuinely large single tasks distribute: distributed ImpEx for imports, ServiceLayer batch splitting for computation. One node group of N nodes running one giant serial job is N-1 nodes watching.
Scheduling Hygiene#
Cron expressions are Quartz-style with seconds (0 30 3 * * ?). The estate-level habits that matter more than any one expression:
- An inventory. One exported ImpEx (or a wiki table generated from
SELECT {code},{cronExpression}on Trigger) listing every scheduled job, owner, node group, and expected duration. Every estate that lacks this has a 02:00 pileup nobody designed. - Stagger against the platform's own rhythm: catalog syncs, backups, and your imports contend for the same database; the data maintenance guide's retention jobs belong in the quiet window after business batches, not alongside.
- Time zones: triggers fire in server time; write the inventory in UTC and convert once, or daylight saving will donate you a yearly incident.
- Concurrent execution: a trigger firing while the previous run still executes does not start a second instance (the CronJob is RUNNING), but a pattern of overruns means your schedule is fiction; alert on duration, not just failure.
Logging and the JobLog Problem#
CronJob logging is configurable per job (logToDatabase, logToFile, log level). Database logging (JobLog items) is the convenient default and the classic table-bloat source: verbose jobs at scale write millions of rows that no retention policy covers by default. The rules: INFO to database only for jobs where operators read logs in Backoffice; DEBUG belongs in files; and retention for JobLog plus finished CronJobHistory is mandatory (the platform ships maintenance jobs and FlexibleSearchRetentionRule machinery for exactly this; the data maintenance guide has the recipes and the sanecleanup shout-out). An estate that has never cleaned JobLog is an estate with a slow Backoffice cronjob screen and a fat backup, at minimum.
Monitoring: Silent Failure Is the Default#
A cronjob that throws returns a FAILURE result and then does absolutely nothing else unless you wired something. The minimum adult setup:
- Result-state alerting: a watchdog (Dynatrace metric off the database, or a tiny meta-cronjob) that alerts on any production CronJob whose last result is not SUCCESS, and on any job whose last successful run is older than its cadence. The second clause catches the disabled-trigger and never-scheduled classes that pure failure alerting misses.
- Duration trend: last-N durations per job code on a dashboard; a nightly job that grew from 20 to 200 minutes over a quarter is a data-growth signal (and a lock-contention risk) you want before it collides with the morning peak.
- Business-outcome checks for business-critical jobs: the stock feed job "succeeding" while importing zero rows is a SUCCESS only a result-state monitor would believe. Critical feeds deserve a row-count sanity assertion inside the job (fail loudly on implausible input) and, ideally, a downstream freshness check (the integration monitoring guide's territory).
Patterns Worth Stealing#
- Parameter objects over property soup: job configuration as attributes on the CronJob subtype (typed, per-instance, visible in Backoffice) rather than global properties read inside the performable. Two instances of the same job with different scopes then coexist cleanly.
- Dry-run flags on destructive jobs (
simulateboolean attribute): the cleanup that prints what it would delete earns trust in staging and gets flipped in production change-managed. Pairs with the hAC scripting console's probe-then-commit ethic. - Idempotency as a design test: could this job run twice by accident and leave correct state? Imports keyed on external IDs, cleanups keyed on cutoffs, and feeds writing to staged targets all pass; anything appending blindly fails and needs a guard.
- One job, one concern: the 900-line performable doing import plus recalculation plus export is three jobs with a dependency chain wearing a trench coat, and it fails as one unit with one log. Composite jobs exist so decomposition costs nothing operationally.
Background work is where a commerce platform quietly earns or loses its reliability reputation: customers never see the cronjob screen, but they see yesterday's prices when the sync died silently. Author for the cloud's assumptions (interruption is weather), place work deliberately, retain nothing forever, and alert on staleness rather than hope. The 2 a.m. jobs will still run at 2 a.m.; the difference is whether anyone needs to be awake with them.