> 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.

# 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:

```java
@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:**

| Property               | Purpose                                                |
| ---------------------- | ------------------------------------------------------ |
| `scheduler.expression` | A cron expression                                      |
| `scheduler.period`     | Interval in seconds — an alternative to cron           |
| `scheduler.concurrent` | Allow a run to start while the previous is still going |
| `scheduler.runOn`      | **Cluster 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:

```text
second  minute  hour  day-of-month  month  day-of-week  [year]
```

| Expression           | Meaning                           |
| -------------------- | --------------------------------- |
| `0 0 2 * * ?`        | 2:00 AM every day                 |
| `0 */15 * * * ?`     | Every 15 minutes                  |
| `0 0 */4 * * ?`      | Every 4 hours                     |
| `0 0 3 ? * MON`      | 3:00 AM every Monday              |
| `0 0 0 1 * ?`        | Midnight on the 1st of each month |
| `0 30 6 ? * MON-FRI` | 6: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:**

```java
@Reference
private JobManager jobManager;

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

**Consuming it:**

```java
@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:**

| Result   | Meaning                                                 |
| -------- | ------------------------------------------------------- |
| `OK`     | Done. Remove the job.                                   |
| `FAILED` | Failed — **retry** according to the retry configuration |
| `CANCEL` | Failed permanently — do not retry                       |
| `ASYNC`  | Completing 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:**

|                    | Scheduler          | Sling Job                      |
| ------------------ | ------------------ | ------------------------------ |
| Persisted          | **No**             | **Yes** (`/var/eventing/jobs`) |
| Survives a restart | No                 | **Yes**                        |
| Retries on failure | No                 | **Yes**, configurable          |
| Cluster behaviour  | You must handle it | **Handled** — one node per job |
| Triggered by       | A clock            | Code                           |

**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.

```java
// 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:

```java
@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:

```java
@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:

| Topic                                 | Fires on                                    |
| ------------------------------------- | ------------------------------------------- |
| `ReplicationAction.EVENT_TOPIC`       | A page or asset is activated or deactivated |
| `PageEvent.EVENT_TOPIC`               | Page created, moved, deleted                |
| `DamEvent.EVENT_TOPIC`                | Asset events                                |
| `SlingConstants.TOPIC_RESOURCE_ADDED` | A 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:**

```java
@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`:**

```java
property = {
    "scheduler.expression=0 0 2 * * ?",
    "scheduler.runOn=SINGLE"
}
```

| Value    | Behaviour                                                                                 |
| -------- | ----------------------------------------------------------------------------------------- |
| `SINGLE` | Run on exactly **one** instance in the cluster                                            |
| `LEADER` | Run only on the cluster **leader**                                                        |
| `ALL`    | Run 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

| What               | Where                     | Grows?                                     |
| ------------------ | ------------------------- | ------------------------------------------ |
| Sling Jobs         | `/var/eventing/jobs`      | **Yes** — if consumers fail or are missing |
| Workflow instances | `/var/workflow/instances` | Yes — file 09                              |
| Scheduler state    | Nowhere — **in memory**   | No                                         |

**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

```java
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

```java
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

```java
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:

```java
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

```java
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 mistake                                 | What actually happens                                            | The fix                                     |
| ------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------- |
| Scheduler with no cluster handling          | Runs on **every** node — three emails, or three concurrent syncs | `scheduler.runOn=SINGLE`, or dispatch jobs  |
| Missing `immediate = true` on a scheduler   | May never activate, since nothing consumes it                    | Add it                                      |
| `scheduler.concurrent` left at default      | A slow run overlaps the next; copies compete                     | Set it `false`                              |
| Hardcoded cron expression                   | Can't retune without a deployment                                | `@ObjectClassDefinition` + run-mode config  |
| `*` in both day fields                      | Doesn't do what you expect                                       | One must be `?`                             |
| Forgetting cron starts with **seconds**     | Everything runs at the wrong time                                | Quartz format has 6–7 fields                |
| Real work inside an event handler           | Blocks the event thread; events dropped under bulk operations    | Cheap check, then dispatch a job            |
| Non-idempotent job consumer                 | Duplicate side effects on retry — two emails, double counts      | Design for at-least-once                    |
| `FAILED` for permanent errors               | Burns the retry budget on something that can never succeed       | `CANCEL` for permanent failures             |
| `CANCEL` for transient errors               | Work silently lost on a brief network blip                       | `FAILED` for transient                      |
| No consumer registered for a topic          | Jobs accumulate in `/var/eventing/jobs` **silently**             | Check `/system/console/slingevent`          |
| Unbounded job queue concurrency             | Saturates the instance during a bulk run                         | Cap it in the queue configuration           |
| Scheduler that swallows exceptions          | Silent failure — nothing tells you it stopped working            | Log, and add a health check on last success |
| Using a raw thread instead of a job         | No persistence, no retry, no cluster awareness, possible leak    | Use a job                                   |
| Scheduled work on Cloud Service **publish** | Pods are ephemeral and the count varies                          | Keep scheduled work on author               |
| JCR `EventListener` in new code             | Manual registration, not path-scoped                             | `ResourceChangeListener`                    |
| Listener path scoped too broadly            | Notified about every change in the repository                    | Scope 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.

| Console                       | Answers                                      |
| ----------------------------- | -------------------------------------------- |
| `/system/console/scheduler`   | Is it registered, and when does it next fire |
| `/system/console/slingevent`  | Job queues, topics, failures, statistics     |
| `/system/console/components`  | Is the component Active or Unsatisfied       |
| `/system/console/jmx`         | Event queue backlog, topology                |
| `/var/eventing/jobs` in CRXDE | The real job count                           |
| `error.log`                   | Exceptions 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**

|                   | Scheduler                         | Sling Job             | Event Handler          | Workflow                |
| ----------------- | --------------------------------- | --------------------- | ---------------------- | ----------------------- |
| Trigger           | **Clock**                         | Code                  | **Repository change**  | Event, launcher, manual |
| Persisted         | **No**                            | **Yes**               | N/A                    | Yes                     |
| Survives restart  | No                                | **Yes**               | N/A                    | Yes                     |
| Retries           | No                                | **Yes**               | No                     | Manual                  |
| Cluster behaviour | **Runs on all** unless configured | **One node**          | Node where it happened | Handled                 |
| Runs sync         | N/A                               | No                    | **Yes — blocks**       | No                      |
| Human steps       | No                                | No                    | No                     | **Yes**                 |
| Use for           | Nightly tasks                     | Bulk, guaranteed work | Reacting to changes    | Approvals               |

**`scheduler.runOn`**

| Value    | Behaviour               | Use when                                      |
| -------- | ----------------------- | --------------------------------------------- |
| `SINGLE` | Exactly one instance    | Shared state — the usual choice               |
| `LEADER` | Only the cluster leader | One designated node must act                  |
| `ALL`    | Every instance          | Per-node local state, e.g. an in-memory cache |

**`JobResult`**

| Result   | Retried? | Use for                                       |
| -------- | -------- | --------------------------------------------- |
| `OK`     | —        | Success                                       |
| `FAILED` | **Yes**  | Transient — timeout, temporary unavailability |
| `CANCEL` | **No**   | Permanent — malformed data, missing content   |
| `ASYNC`  | —        | Completing later                              |

**Event mechanisms**

|              | `ResourceChangeListener` | `EventHandler`                | JCR `EventListener` |
| ------------ | ------------------------ | ----------------------------- | ------------------- |
| Level        | Sling resources          | OSGi events                   | JCR                 |
| Path-scoped  | **Yes**                  | No                            | Partially           |
| Registration | Declarative              | Declarative                   | **Manual**          |
| Use for      | Content changes          | Replication, page, DAM events | Avoid in new code   |
| Status       | **Preferred**            | For non-resource events       | Legacy              |

**Resource change vs replication event**

|            | Resource change           | Replication event               |
| ---------- | ------------------------- | ------------------------------- |
| Fires when | Content is **saved**      | Content is **published**        |
| Tier       | Where the change happened | Author (and publish on receipt) |
| Use for    | Reacting to authoring     | **Invalidating 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._
