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:
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:
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:
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:
Consuming it:
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:
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:
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.
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:
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:
Useful topics to know:
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:
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:
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.runOnset toSINGLEorLEADER. 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
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
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
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
8.4 Reacting to replication specifically
When you care about a page being published, not merely saved:
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
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
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:
- Is it in
/system/console/scheduler? If not, it never registered. - Is the component Active in
/system/console/components? - Is the cron expression what you think? Check the next fire time in the console rather than reading the expression.
- Is
enabledset false in the config? - On a cluster — is
runOnset such that this node isn't the one running it? (In which case it is working.)
When jobs pile up:
- Is a consumer registered for that topic?
/system/console/slingeventshows topics with no consumer. - Is the consumer component Active, or unsatisfied?
- Is it returning
FAILEDrepeatedly? The queue statistics show retry counts. - 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.
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.runOn
JobResult
Event mechanisms
Resource change vs replication event
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.