For AI agents: the complete documentation index is available at https://nhanbe.id.vn/llms.txt, the full documentation bundle is available at https://nhanbe.id.vn/llms-full.txt, and this page is available as Markdown at https://nhanbe.id.vn/aem/advanced/schedulers-jobs-and-events.md.

Scheduler, Sling Jobs and Event Handlers

10 – Schedulers, Sling Jobs and Event Handlers

Target: 3–4 years experienced AEM Developer Syllabus point covered (19, second half): "Scheduler/workflow examples. Say you haven't done it but you know about it." Project domain: a global energy technology company's marketing site.


Before we start — the question underneath all of this

File 09 covered workflows. This file covers the other three ways to run code that isn't tied to a page request: schedulers, Sling Jobs, and event handlers.

And the interview question is almost never "how do you write a scheduler." It is:

"You need to do X in the background. Which mechanism, and why?"

That is a judgment question, and there is a right answer for each shape of problem. Getting the choice right matters far more than remembering cron syntax.

Here is the decision, up front, because everything else in this file supports it:

graph TD A["I need to run something<br/>outside a page request"] --> B{"Does a human<br/>need to act?"} B -->|Yes| C["WORKFLOW<br/>— file 09"] B -->|No| D{"What triggers it?"} D -->|"A clock"| E["SCHEDULER<br/>nightly sync, cache warm-up"] D -->|"Something changed<br/>in the repository"| F["EVENT HANDLER<br/>page published, asset uploaded"] D -->|"Code decides,<br/>and it must not be lost"| G["SLING JOB<br/>bulk processing, retries"]

And there is one trap sitting underneath all three, which is the thing that genuinely separates candidates:

AEM runs on more than one machine. Your background code has to know that.

An author tier can be clustered. A publish tier on Cloud Service auto-scales to several pods. So a scheduler written the obvious way runs once per node — your "nightly report" sends four emails, or your data sync runs four times concurrently and corrupts itself.

That is section 2.6, and it is the highest-value thing in this file.


1. Introduction

1.1 The three mechanisms, in one paragraph each

A Scheduler runs code on a timer. You give it a cron expression and it fires. It is simple, it is not persisted, and it has no memory — if the instance is down at 2am, that run simply doesn't happen.

A Sling Job is a unit of work you hand to AEM saying "run this, and don't lose it." It is persisted in the repository, it retries on failure, and it is designed for clusters — exactly one node processes each job. This is the right answer for bulk processing.

An Event Handler reacts to something happening in the repository — a page published, an asset uploaded, a node changed. It runs synchronously on the event dispatch thread, which is the single most important thing to know about it.

1.2 A real project example to adapt

"We use all three. There's a scheduler that refreshes the product data cache overnight — configured with a cron expression in an OSGi config so it differs between environments, and set to run on a single node so it doesn't fire once per pod. We use Sling Jobs for bulk work, like reprocessing metadata across the asset library, because jobs are persisted and cluster-aware so nothing gets lost or duplicated. And we have a resource change listener that reacts to product pages being published, which does almost nothing itself — it just dispatches a job, because an event handler runs on the event thread and you can't do real work there."

That paragraph covers all three, the cluster problem, and the event-handler discipline — which is most of the interview.


2. Core Concepts

2.1 The Sling Scheduler

The common pattern — usually called the "whiteboard" pattern, because you register a Runnable and the scheduler picks it up:

@Component(service = Runnable.class, immediate = true)
@Designate(ocd = ProductSyncConfig.class)
public class ProductSyncTask implements Runnable {

    @Override
    public void run() {
        // your work
    }
}

Three things about that:

service = Runnable.class — you register under Runnable, and the Sling scheduler watches for those.

immediate = true — from file 06. A component providing a service is delayed by default, meaning nothing creates it until someone consumes it. Nothing consumes a scheduled task, so without immediate = true it may never start.

It implements Runnable — so the work goes in run().

The scheduling properties:

PropertyPurpose
scheduler.expressionA cron expression
scheduler.periodInterval in seconds — an alternative to cron
scheduler.concurrentAllow a run to start while the previous is still going
scheduler.runOnCluster behaviour — section 2.6

scheduler.concurrent deserves attention. It defaults to allowing concurrent runs. So if your task normally takes two minutes but occasionally takes twelve, and it's scheduled every five minutes, you end up with three copies running simultaneously — competing for the same data, possibly corrupting it.

Set scheduler.concurrent = false unless you have a specific reason not to. That is a good detail to raise unprompted.

2.2 Cron expressions

Quartz format, and note it starts with seconds — which trips up people used to Unix cron:

second  minute  hour  day-of-month  month  day-of-week  [year]
ExpressionMeaning
0 0 2 * * ?2:00 AM every day
0 */15 * * * ?Every 15 minutes
0 0 */4 * * ?Every 4 hours
0 0 3 ? * MON3:00 AM every Monday
0 0 0 1 * ?Midnight on the 1st of each month
0 30 6 ? * MON-FRI6:30 AM on weekdays

The ? is the thing people get wrong. You cannot specify both day-of-month and day-of-week meaningfully, so one of them must be ? meaning "no specific value."

  • Scheduling by date → ? in day-of-week: 0 0 0 1 * ?
  • Scheduling by weekday → ? in day-of-month: 0 0 3 ? * MON

Using * in both is a common mistake and produces behaviour you did not intend.

Always make the expression configurable through an OSGi config rather than hardcoding it. Then dev can run every five minutes for testing while production runs nightly — the run-mode config pattern from file 06.

2.3 Sling Jobs

The idea. A job is a unit of work you hand to AEM with a topic and some properties, saying "run this somewhere, and don't lose it."

Two halves. Something creates the job; a consumer registered for that topic processes it.

Creating a job:

@Reference
private JobManager jobManager;

Map<String, Object> properties = new HashMap<>();
properties.put("productId", "TX-4000");
jobManager.addJob("energy/product/import", properties);

Consuming it:

@Component(
        service = JobConsumer.class,
        property = { JobConsumer.PROPERTY_TOPICS + "=energy/product/import" }
)
public class ProductImportJobConsumer implements JobConsumer {

    @Override
    public JobResult process(Job job) {
        String productId = job.getProperty("productId", String.class);
        // ... do the work ...
        return JobResult.OK;
    }
}

The topic is the routing key — it connects the producer to the consumer. Conventionally a slash-separated namespace like energy/product/import.

JobResult tells the framework what happened:

ResultMeaning
OKDone. Remove the job.
FAILEDFailed — retry according to the retry configuration
CANCELFailed permanently — do not retry
ASYNCCompleting asynchronously; signal later

The FAILED versus CANCEL distinction matters. A network timeout is FAILED — retrying may well succeed. A malformed product ID is CANCEL — retrying will fail identically every time and just burns the retry budget.

Why jobs beat a scheduler for real work:

SchedulerSling Job
PersistedNoYes (/var/eventing/jobs)
Survives a restartNoYes
Retries on failureNoYes, configurable
Cluster behaviourYou must handle itHandled — one node per job
Triggered byA clockCode

Jobs are stored in /var/eventing/jobs. Same lesson as workflow instances from file 09: if consumers fail or a topic has no consumer at all, jobs accumulate there.

2.4 At-least-once, and why your consumer must be idempotent

This is the most important thing about Sling Jobs, and it is a genuinely good point to raise unprompted.

Sling Jobs guarantee at-least-once delivery. Not exactly-once. Which means:

Your job consumer may process the same job more than once.

A node can crash after doing the work but before recording completion. On restart, the job is still there and gets processed again.

So the consumer must be idempotent — running it twice must have the same effect as running it once.

// NOT idempotent -- runs twice, the counter is wrong
properties.put("viewCount", currentCount + 1);

// Idempotent -- runs twice, same result
properties.put("importedAt", jobTimestamp);
properties.put("status", "imported");

Where it bites in practice: sending an email, charging something, incrementing a counter, appending to a list. Setting a value or writing a file is naturally idempotent; anything cumulative is not.

The interview answer:

"Sling Jobs are at-least-once, not exactly-once — a node can crash after doing the work but before marking the job complete, and on restart it runs again. So the consumer has to be idempotent. Setting a property or writing a file is naturally safe; anything cumulative like incrementing a counter or sending an email isn't, and needs a guard — usually checking whether the work was already done before doing it."

2.5 Event handlers — reacting to repository changes

Three mechanisms, and the modern one is the answer.

ResourceChangeListener — the current, recommended approach:

@Component(
        service = ResourceChangeListener.class,
        property = {
                ResourceChangeListener.PATHS + "=/content/energy/global/en/products",
                ResourceChangeListener.CHANGES + "=ADDED",
                ResourceChangeListener.CHANGES + "=CHANGED"
        }
)
public class ProductPageChangeListener implements ResourceChangeListener {

    @Override
    public void onChange(List<ResourceChange> changes) {
        for (ResourceChange change : changes) {
            // react
        }
    }
}

Why this one is preferred: it is path-scoped, so AEM only notifies you about the branch you care about rather than every change in the repository. That is a real performance property, not just tidiness.

EventHandler — the OSGi event mechanism, for events that aren't resource changes:

@Component(
        service = EventHandler.class,
        property = { EventConstants.EVENT_TOPIC + "=" + ReplicationAction.EVENT_TOPIC }
)
public class ReplicationEventHandler implements EventHandler {

    @Override
    public void handleEvent(Event event) {
        // react to a page being published
    }
}

Useful topics to know:

TopicFires on
ReplicationAction.EVENT_TOPICA page or asset is activated or deactivated
PageEvent.EVENT_TOPICPage created, moved, deleted
DamEvent.EVENT_TOPICAsset events
SlingConstants.TOPIC_RESOURCE_ADDEDA resource is added

javax.jcr.observation.EventListener — the low-level JCR mechanism. It works, but it requires manual registration and deregistration in @Activate and @Deactivate, and it is not path-scoped in the same convenient way. Avoid it in new code — ResourceChangeListener exists precisely to replace it. Knowing that it exists and that you would not use it is the right answer.

2.6 The rule that governs every event handler

Event handlers run synchronously, on the event dispatch thread.

That has a hard consequence:

Do almost nothing in an event handler. Decide, then dispatch.

If your handler takes two seconds and a bulk activation publishes five hundred pages, you have just blocked the event thread for over sixteen minutes. Other listeners are delayed, and events may be dropped.

The correct pattern:

@Override
public void onChange(List<ResourceChange> changes) {
    for (ResourceChange change : changes) {
        if (!isRelevant(change)) {      // cheap check
            continue;
        }
        // Hand the real work to a JOB and return immediately
        Map<String, Object> props = new HashMap<>();
        props.put("path", change.getPath());
        jobManager.addJob("energy/product/reindex", props);
    }
}

The handler decides. The job does. That single sentence is the answer to a lot of questions in this area, and it is the thing that separates someone who has caused a production incident from someone who hasn't.

2.7 The cluster problem — the highest-value section here

The setup. AEM does not run on one machine:

  • An author tier can be clustered — several instances sharing a MongoDB repository.
  • A publish tier on Cloud Service auto-scales — several pods, and the number changes with traffic.

The problem. A scheduler is just a component. Every instance has the component. So every instance runs it.

graph TD A["scheduler.expression = 0 0 2 * *?<br/>'send the nightly report'"] --> B["Author node 1<br/>sends the report"] A --> C["Author node 2<br/>sends the report"] A --> D["Author node 3<br/>sends the report"] B --> E["Three identical emails<br/>at 2am"] C --> E D --> E

Three emails. Or worse — three concurrent data syncs writing the same nodes.

And on Cloud Service publish it is worse, because the number of pods changes. You cannot even predict how many times it will run, and it will differ between a quiet night and a busy one.

Three solutions, in order of preference:

Solution 1 — scheduler.runOn:

property = {
    "scheduler.expression=0 0 2 * * ?",
    "scheduler.runOn=SINGLE"
}
ValueBehaviour
SINGLERun on exactly one instance in the cluster
LEADERRun only on the cluster leader
ALLRun on every instance — occasionally what you want, e.g. clearing a local in-memory cache

Solution 2 — use a Sling Job instead. Jobs are cluster-aware by design: exactly one node consumes each job. If the work is substantial, this is better than a scheduler anyway, because you also get persistence and retries.

Solution 3 — check leadership yourself, via the Sling topology API. More code, and only worth it when you need finer control than runOn gives.

The Cloud Service caveat worth raising: the safest pattern on Cloud Service is to keep scheduled work on the author tier and avoid schedulers on publish entirely, because publish pods are ephemeral and their count is not under your control.

The interview answer:

"The trap is that a scheduler is just an OSGi component, so every instance in the cluster has it and every instance runs it. On a clustered author that means your nightly report goes out three times; on Cloud Service publish it's worse, because the pod count changes with traffic so you can't even predict how many times.

The direct fix is scheduler.runOn set to SINGLE or LEADER. But if the work is substantial I'd rather dispatch a Sling Job, because jobs are cluster-aware by design — exactly one node consumes each one — and you get persistence and retries as well.

On Cloud Service I'd also keep scheduled work on author rather than publish, since publish pods are ephemeral."


3. Internal Working

3.1 How a Sling Job flows

sequenceDiagram participant P as Producer participant JM as JobManager participant R as /var/eventing/jobs participant Q as Job queue participant C as JobConsumer P->>JM: addJob("energy/product/import", props) JM->>R: PERSIST the job JM->>Q: enqueue by topic Q->>C: find a consumer for this topic C->>C: process(job) alt JobResult.OK C-->>Q: OK Q->>R: remove the job else JobResult.FAILED C-->>Q: FAILED Q->>Q: retry after a delay Note over Q: up to the retry limit,<br/>then it stays failed else JobResult.CANCEL C-->>Q: CANCEL Q->>R: mark cancelled — NO retry end

Three things worth drawing out:

The job is persisted before anything runs. That is what makes it survive a restart — and it is also why jobs accumulate in /var/eventing/jobs when something is wrong.

If no consumer is registered for the topic, the job just sits there. No error, no warning. It waits for a consumer that never arrives. That is a genuinely confusing failure, and the fix is checking /system/console/slingevent.

The retry path is why FAILED and CANCEL differ. FAILED costs a retry; CANCEL doesn't.

3.2 Why event handlers must be fast

graph TD A["Author bulk-activates 500 pages"] --> B["500 replication events"] B --> C{"Your handler:<br/>how long per event?"} C -->|"2 seconds"| D["Event thread blocked<br/>for 16+ minutes"] C -->|"5 milliseconds<br/>+ dispatch a job"| E["Done in seconds.<br/>Jobs process in the background."] D --> F["Other listeners delayed<br/>Events possibly dropped"]

The comparison is the argument. Same work, but where it happens changes everything.

3.3 Where things are stored

WhatWhereGrows?
Sling Jobs/var/eventing/jobsYes — if consumers fail or are missing
Workflow instances/var/workflow/instancesYes — file 09
Scheduler stateNowhere — in memoryNo

That "nowhere" is the point about schedulers. No persistence means no recovery. If the instance is down at 2am, that run simply did not happen and nothing will notice.


8. Coding Examples

8.1 A cluster-safe scheduled task

package com.energy.core.schedulers;

import com.energy.core.services.ProductDataService;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Modified;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.event.jobs.JobManager;
import org.osgi.service.metatype.annotations.AttributeDefinition;
import org.osgi.service.metatype.annotations.Designate;
import org.osgi.service.metatype.annotations.ObjectClassDefinition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@ObjectClassDefinition(name = "Energy — Nightly Product Sync")
@interface ProductSyncConfig {

    // CONFIGURABLE, not hardcoded: dev runs it every 5 minutes
    // for testing, production runs it at 2am. Run-mode configs
    // supply the difference -- file 06.
    @AttributeDefinition(name = "Cron expression",
            description = "Quartz format: sec min hour day-of-month month day-of-week")
    String scheduler_expression() default "0 0 2 * * ?";

    // FALSE: if a run overruns, do NOT start another on top of it.
    // The default allows concurrency, which is almost never what you want.
    @AttributeDefinition(name = "Allow concurrent runs")
    boolean scheduler_concurrent() default false;

    // THE CLUSTER FIX.
    // A scheduler is just an OSGi component, so EVERY node has it and
    // EVERY node runs it. SINGLE means exactly one node does.
    // On Cloud Service publish this matters even more, because the pod
    // count changes with traffic -- you can't even predict the multiple.
    @AttributeDefinition(name = "Run on",
            options = {
                    @org.osgi.service.metatype.annotations.Option(label = "Single instance", value = "SINGLE"),
                    @org.osgi.service.metatype.annotations.Option(label = "Cluster leader", value = "LEADER"),
                    @org.osgi.service.metatype.annotations.Option(label = "All instances", value = "ALL")
            })
    String scheduler_runOn() default "SINGLE";

    @AttributeDefinition(name = "Batch size")
    int batchSize() default 100;

    @AttributeDefinition(name = "Enabled")
    boolean enabled() default true;
}

/**
 * Registered as a Runnable -- the "whiteboard" pattern. The Sling
 * scheduler watches for Runnables carrying scheduler.* properties.
 *
 * immediate = true because NOTHING CONSUMES this service. A
 * service-providing component is delayed by default (file 06), so
 * without this it might never activate at all.
 */
@Component(service = Runnable.class, immediate = true)
@Designate(ocd = ProductSyncConfig.class)
public class ProductSyncScheduler implements Runnable {

    private static final Logger LOG = LoggerFactory.getLogger(ProductSyncScheduler.class);
    private static final String JOB_TOPIC = "energy/product/sync";

    @Reference
    private JobManager jobManager;

    @Reference
    private ProductDataService productDataService;

    private boolean enabled;
    private int batchSize;

    @Activate
    @Modified
    protected void activate(ProductSyncConfig config) {
        this.enabled   = config.enabled();
        this.batchSize = config.batchSize();
    }

    @Override
    public void run() {
        if (!enabled) {
            return;
        }

        try {
            // THE SCHEDULER DOES ALMOST NOTHING ITSELF.
            //
            // It works out what needs doing and dispatches JOBS. That gives
            // us persistence (a restart mid-run loses nothing), retries on
            // transient failure, and proper distribution across the cluster
            // instead of one node doing all the work.
            List<String> productIds = productDataService.getProductIdsToSync();

            LOG.info("Product sync: dispatching {} products in batches of {}",
                    productIds.size(), batchSize);

            for (int i = 0; i < productIds.size(); i += batchSize) {
                List<String> batch = productIds.subList(
                        i, Math.min(i + batchSize, productIds.size()));

                Map<String, Object> props = new HashMap<>();
                props.put("productIds", batch.toArray(new String[0]));
                props.put("batchNumber", (i / batchSize) + 1);

                jobManager.addJob(JOB_TOPIC, props);
            }

        } catch (Exception e) {
            // A scheduler that throws is SILENT -- nothing surfaces it.
            // Log it, and ideally back it with a health check reporting
            // the last successful run.
            LOG.error("Product sync dispatch failed", e);
        }
    }
}

The five decisions to be able to defend:

The cron expression is configurable, not hardcoded, so it differs per environment.

scheduler.concurrent = false, so a slow run never overlaps the next.

scheduler.runOn = SINGLE — the cluster fix, with the reason in the comment.

immediate = true, because nothing consumes this service.

The scheduler dispatches jobs rather than doing the work — persistence, retries and cluster distribution in one decision.

8.2 An idempotent job consumer

package com.energy.core.jobs;

import com.energy.core.services.ProductDataService;
import org.apache.sling.event.jobs.Job;
import org.apache.sling.event.jobs.consumer.JobConsumer;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.SocketTimeoutException;

@Component(
        service = JobConsumer.class,
        property = {
                // The TOPIC is the routing key connecting producer to consumer.
                // If NO consumer is registered for a topic, jobs just sit in
                // /var/eventing/jobs forever -- silently. No error at all.
                JobConsumer.PROPERTY_TOPICS + "=energy/product/sync"
        }
)
public class ProductSyncJobConsumer implements JobConsumer {

    private static final Logger LOG = LoggerFactory.getLogger(ProductSyncJobConsumer.class);

    @Reference
    private ProductDataService productDataService;

    @Override
    public JobResult process(Job job) {

        String[] productIds = job.getProperty("productIds", String[].class);
        Integer batchNumber = job.getProperty("batchNumber", Integer.class);

        if (productIds == null || productIds.length == 0) {
            // Nothing to do, and retrying won't change that.
            return JobResult.CANCEL;
        }

        try {
            for (String productId : productIds) {

                // IDEMPOTENCY.
                //
                // Sling Jobs are AT-LEAST-ONCE, not exactly-once. A node can
                // do the work and crash before recording completion, and on
                // restart this job runs AGAIN.
                //
                // This operation is naturally idempotent -- it sets values to
                // a derived result, so running twice produces the same state.
                // Anything CUMULATIVE (a counter, an email, an append) would
                // need an explicit guard.
                productDataService.syncProduct(productId);
            }

            LOG.info("Product sync batch {} completed ({} products)",
                    batchNumber, productIds.length);
            return JobResult.OK;

        } catch (SocketTimeoutException e) {
            // TRANSIENT: the API was slow or briefly unavailable.
            // FAILED means retry -- which may well succeed.
            LOG.warn("Product sync batch {} timed out, will retry", batchNumber);
            return JobResult.FAILED;

        } catch (IllegalArgumentException e) {
            // PERMANENT: malformed data. Retrying will fail identically
            // every time and just burns the retry budget.
            // CANCEL means don't retry.
            LOG.error("Product sync batch {} has invalid data, cancelling",
                    batchNumber, e);
            return JobResult.CANCEL;

        } catch (Exception e) {
            LOG.error("Product sync batch {} failed", batchNumber, e);
            return JobResult.FAILED;
        }
    }
}

The distinction to point at: FAILED versus CANCEL, chosen deliberately per exception type. Returning FAILED for everything means permanent failures consume the retry budget pointlessly; returning CANCEL for everything means a transient network blip loses the work.

8.3 An event handler that stays fast

package com.energy.core.listeners;

import org.apache.sling.api.resource.observation.ResourceChange;
import org.apache.sling.api.resource.observation.ResourceChangeListener;
import org.apache.sling.event.jobs.JobManager;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Reacts to product page changes.
 *
 * ResourceChangeListener is the MODERN approach, preferred over the JCR
 * EventListener because it is PATH-SCOPED -- AEM only notifies us about
 * the branch we registered for, rather than every change in the repository.
 * That is a real performance property, not just tidiness.
 */
@Component(
        service = ResourceChangeListener.class,
        property = {
                // Scope as NARROWLY as possible
                ResourceChangeListener.PATHS + "=/content/energy/global/en/products",
                ResourceChangeListener.CHANGES + "=ADDED",
                ResourceChangeListener.CHANGES + "=CHANGED",
                ResourceChangeListener.CHANGES + "=REMOVED"
        }
)
public class ProductPageChangeListener implements ResourceChangeListener {

    private static final String JOB_TOPIC = "energy/listing/invalidate";

    @Reference
    private JobManager jobManager;

    /**
     * THIS RUNS SYNCHRONOUSLY ON THE EVENT DISPATCH THREAD.
     *
     * Whatever happens here, the event system waits for. If an author
     * bulk-activates 500 pages and this took 2 seconds each, the event
     * thread would be blocked for over 16 minutes -- other listeners
     * starve behind it and events can be DROPPED.
     *
     * So: DECIDE here, DO elsewhere.
     */
    @Override
    public void onChange(List<ResourceChange> changes) {

        for (ResourceChange change : changes) {

            // A cheap check only -- string comparison, no repository
            // traversal, no adaptTo, no external calls.
            if (!isProductPageContent(change.getPath())) {
                continue;
            }

            // Hand the real work to a JOB and return immediately.
            //
            // A job rather than a raw thread, because jobs give us
            // persistence (an invalidation isn't lost on restart),
            // retries (a temporary dispatcher failure doesn't silently
            // skip a flush), and a capped queue so 500 invalidations
            // don't saturate the instance.
            Map<String, Object> props = new HashMap<>();
            props.put("path", change.getPath());
            props.put("changeType", change.getType().name());

            jobManager.addJob(JOB_TOPIC, props);
        }
    }

    private boolean isProductPageContent(String path) {
        return path != null && path.endsWith("/jcr:content");
    }
}

8.4 Reacting to replication specifically

When you care about a page being published, not merely saved:

package com.energy.core.listeners;

import com.day.cq.replication.ReplicationAction;
import com.day.cq.replication.ReplicationActionType;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventConstants;
import org.osgi.service.event.EventHandler;
import org.apache.sling.event.jobs.JobManager;

import java.util.HashMap;
import java.util.Map;

@Component(
        service = EventHandler.class,
        property = {
                EventConstants.EVENT_TOPIC + "=" + ReplicationAction.EVENT_TOPIC
        }
)
public class ProductPublishEventHandler implements EventHandler {

    @Reference
    private JobManager jobManager;

    @Override
    public void handleEvent(Event event) {

        ReplicationAction action = ReplicationAction.fromEvent(event);
        if (action == null) {
            return;
        }

        // Only care about activation, not deactivation or delete
        if (action.getType() != ReplicationActionType.ACTIVATE) {
            return;
        }

        String path = action.getPath();
        if (path == null || !path.startsWith("/content/energy/global/en/products")) {
            return;
        }

        // Again: DECIDE here, DO in the job.
        Map<String, Object> props = new HashMap<>();
        props.put("path", path);
        props.put("user", action.getUserId());

        jobManager.addJob("energy/listing/invalidate", props);
    }
}

ResourceChangeListener versus this is worth being able to distinguish: a resource change fires when content is saved; a replication event fires when it is published. For invalidating a public cache you want the second one, because saving on author changes nothing the public can see.

8.5 Testing a job consumer

package com.energy.core.jobs;

import com.energy.core.services.ProductDataService;
import io.wcm.testing.mock.aem.junit5.AemContext;
import io.wcm.testing.mock.aem.junit5.AemContextExtension;
import org.apache.sling.event.jobs.Job;
import org.apache.sling.event.jobs.consumer.JobConsumer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;

import java.net.SocketTimeoutException;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;

@ExtendWith(AemContextExtension.class)
class ProductSyncJobConsumerTest {

    private final AemContext context = new AemContext();
    private ProductDataService productDataService;
    private ProductSyncJobConsumer consumer;

    @BeforeEach
    void setUp() {
        productDataService = Mockito.mock(ProductDataService.class);
        context.registerService(ProductDataService.class, productDataService);
        consumer = context.registerInjectActivateService(new ProductSyncJobConsumer());
    }

    @Test
    void cancelsWhenThereIsNothingToDo() {
        // No retry -- an empty batch will still be empty next time.
        assertEquals(JobConsumer.JobResult.CANCEL, consumer.process(jobWith(null)));
    }

    @Test
    void retriesOnATransientTimeout() throws Exception {
        doThrow(new SocketTimeoutException()).when(productDataService).syncProduct(anyString());

        assertEquals(JobConsumer.JobResult.FAILED,
                consumer.process(jobWith(new String[]{"TX-4000"})));
    }

    @Test
    void doesNotRetryOnPermanentlyInvalidData() throws Exception {
        doThrow(new IllegalArgumentException("bad id")).when(productDataService).syncProduct(anyString());

        assertEquals(JobConsumer.JobResult.CANCEL,
                consumer.process(jobWith(new String[]{"???"})));
    }

    @Test
    void isIdempotent() throws Exception {
        // At-least-once delivery means this WILL happen. Running the same
        // job twice must be safe.
        Job job = jobWith(new String[]{"TX-4000"});

        assertEquals(JobConsumer.JobResult.OK, consumer.process(job));
        assertEquals(JobConsumer.JobResult.OK, consumer.process(job));

        verify(productDataService, times(2)).syncProduct("TX-4000");
        // The service call is itself idempotent -- that is the contract
        // this test is documenting.
    }

    private Job jobWith(String[] productIds) {
        Job job = Mockito.mock(Job.class);
        when(job.getProperty("productIds", String[].class)).thenReturn(productIds);
        when(job.getProperty("batchNumber", Integer.class)).thenReturn(1);
        return job;
    }
}

The valuable tests are the result-code decisions and the idempotency contract — not the happy path. Those are what break in production.


9. Common Mistakes

The mistakeWhat actually happensThe fix
Scheduler with no cluster handlingRuns on every node — three emails, or three concurrent syncsscheduler.runOn=SINGLE, or dispatch jobs
Missing immediate = true on a schedulerMay never activate, since nothing consumes itAdd it
scheduler.concurrent left at defaultA slow run overlaps the next; copies competeSet it false
Hardcoded cron expressionCan't retune without a deployment@ObjectClassDefinition + run-mode config
* in both day fieldsDoesn't do what you expectOne must be ?
Forgetting cron starts with secondsEverything runs at the wrong timeQuartz format has 6–7 fields
Real work inside an event handlerBlocks the event thread; events dropped under bulk operationsCheap check, then dispatch a job
Non-idempotent job consumerDuplicate side effects on retry — two emails, double countsDesign for at-least-once
FAILED for permanent errorsBurns the retry budget on something that can never succeedCANCEL for permanent failures
CANCEL for transient errorsWork silently lost on a brief network blipFAILED for transient
No consumer registered for a topicJobs accumulate in /var/eventing/jobs silentlyCheck /system/console/slingevent
Unbounded job queue concurrencySaturates the instance during a bulk runCap it in the queue configuration
Scheduler that swallows exceptionsSilent failure — nothing tells you it stopped workingLog, and add a health check on last success
Using a raw thread instead of a jobNo persistence, no retry, no cluster awareness, possible leakUse a job
Scheduled work on Cloud Service publishPods are ephemeral and the count variesKeep scheduled work on author
JCR EventListener in new codeManual registration, not path-scopedResourceChangeListener
Listener path scoped too broadlyNotified about every change in the repositoryScope to the narrowest branch

10. Best Practices

On schedulers. Make the cron configurable. Set scheduler.concurrent = false. Always decide the cluster behaviour explicitly rather than accepting the default. Log failures and back the task with a health check — a silent scheduler that stopped running is the worst failure mode, because nothing surfaces it.

On jobs. Design consumers to be idempotent, and treat that as a requirement rather than a nicety. Choose FAILED and CANCEL deliberately per failure type. Cap queue concurrency so a bulk run doesn't starve the site. Batch where the per-item overhead would dominate.

On event handlers. Do the cheapest possible check, then dispatch a job. Scope paths as narrowly as the requirement allows. Prefer ResourceChangeListener over the JCR EventListener. Ask "what happens when five hundred of these arrive at once" before shipping.

On the choice. Scheduler for a clock, event handler for a change, job for guaranteed work, workflow for people. And prefer jobs for anything substantial, because you get persistence, retries and cluster-safety in one decision.

On external calls. Timeouts always, wherever the call happens — same rule as file 06.


11. Debugging Tips

The two consoles that answer most of it:

/system/console/scheduler lists every scheduled job with its expression and next fire time. If your task isn't there, it never registered — check /system/console/components for an unsatisfied component, which is the file 06 problem showing up again.

/system/console/slingevent shows job queues, topics, and statistics — queued, processed, failed, cancelled. This is where you find out that jobs are accumulating for a topic nobody consumes.

When a scheduled task doesn't run:

  1. Is it in /system/console/scheduler? If not, it never registered.
  2. Is the component Active in /system/console/components?
  3. Is the cron expression what you think? Check the next fire time in the console rather than reading the expression.
  4. Is enabled set false in the config?
  5. On a cluster — is runOn set such that this node isn't the one running it? (In which case it is working.)

When jobs pile up:

  1. Is a consumer registered for that topic? /system/console/slingevent shows topics with no consumer.
  2. Is the consumer component Active, or unsatisfied?
  3. Is it returning FAILED repeatedly? The queue statistics show retry counts.
  4. Is the queue concurrency so low that it can't keep up with the arrival rate?

When something runs more times than expected: count your nodes. This is almost always the cluster problem, and the giveaway is that the multiple matches the instance count.

When events seem to be missed: check whether a handler is slow. A thread dump during a bulk operation shows threads sitting in your handler, and the event queue backlog is visible via JMX.

ConsoleAnswers
/system/console/schedulerIs it registered, and when does it next fire
/system/console/slingeventJob queues, topics, failures, statistics
/system/console/componentsIs the component Active or Unsatisfied
/system/console/jmxEvent queue backlog, topology
/var/eventing/jobs in CRXDEThe real job count
error.logExceptions from run() and process()

12. Performance Optimization

Cap job queue concurrency. An unbounded parallel queue will process as many jobs at once as it can and saturate the instance. Capping it means the batch takes longer and nobody notices it running — which is almost always the better trade.

Batch where per-item overhead dominates. Eighty thousand jobs each doing one property write spends more on job machinery than on work. But note the trade-off: a failed batch retries the whole batch, so moderate batch sizes are usually right.

Keep event handlers to microseconds. A cheap string check, then dispatch. Anything else on the event thread multiplies under bulk operations.

Scope listener paths narrowly. A listener registered on /content is woken for every change in the entire repository, and yours is not the only listener paying that cost.

Never block on an external call without a timeout, whether in a scheduler, a job or a handler.

Schedule heavy work for quiet periods, and prefer the author tier — publish should be serving pages.

Watch queue depth as a leading indicator. Like workflow instance counts in file 09, it rises long before anything visibly breaks.


15. Comparison Tables

The four mechanisms

SchedulerSling JobEvent HandlerWorkflow
TriggerClockCodeRepository changeEvent, launcher, manual
PersistedNoYesN/AYes
Survives restartNoYesN/AYes
RetriesNoYesNoManual
Cluster behaviourRuns on all unless configuredOne nodeNode where it happenedHandled
Runs syncN/ANoYes — blocksNo
Human stepsNoNoNoYes
Use forNightly tasksBulk, guaranteed workReacting to changesApprovals

scheduler.runOn

ValueBehaviourUse when
SINGLEExactly one instanceShared state — the usual choice
LEADEROnly the cluster leaderOne designated node must act
ALLEvery instancePer-node local state, e.g. an in-memory cache

JobResult

ResultRetried?Use for
OK—Success
FAILEDYesTransient — timeout, temporary unavailability
CANCELNoPermanent — malformed data, missing content
ASYNC—Completing later

Event mechanisms

ResourceChangeListenerEventHandlerJCR EventListener
LevelSling resourcesOSGi eventsJCR
Path-scopedYesNoPartially
RegistrationDeclarativeDeclarativeManual
Use forContent changesReplication, page, DAM eventsAvoid in new code
StatusPreferredFor non-resource eventsLegacy

Resource change vs replication event

Resource changeReplication event
Fires whenContent is savedContent is published
TierWhere the change happenedAuthor (and publish on receipt)
Use forReacting to authoringInvalidating public caches

Next topic

11-Dialogs-and-Validation — your syllabus point 20: how you write validation JavaScript for a component dialog, the Granite UI validation registry, where the clientlib goes and which category it needs, and the built-in validation you get before writing any code.


Topic 10 of the AEM Interview Preparation repository. Teaching style, energy-sector project domain.