Scheduler — AEM as a Cloud Service
Overview
Schedules in AEM CS must be declared as OSGi component properties — Scheduler.schedule() is not persisted across restarts. There is no @SlingScheduled annotation in the CS SDK.
Three properties control every scheduler:
Classification — choose before making any changes
Path A — Runnable + OSGi component properties when ALL are true:
- Cron is a hardcoded constant or single
@AttributeDefinition-backed value - Only one schedule per class
- Class
implements Runnable - No
ScheduleOptions.config(), no per-execution job payload
→ Read path-a.md [blocked] and follow its steps.
Path B — Sling Jobs via JobManager when ANY is true in the legacy source:
- Cron comes from runtime config (
config.cronExpression()) - Multiple cron expressions per class
- Legacy code needs per-execution job data, config-driven scheduling, or a Scheduler + JobConsumer split
- Business logic needs job context or properties at execution time
@Modifiedre-registers schedules with new config values
→ Read path-b.md [blocked] and follow its steps.
One pattern per session. If the codebase has both kinds, fix one class at a time.
Discovery
Detection is performed by the analyzer (../scripts/analyze.sh), run by the runbook:
Match criteria (what the detector flags):
- A class that
implements org.apache.sling.commons.scheduler.Job(import-aware). - The OSGi-property scheduler shape — a class that
implements Runnableand carries an OSGi@Componentdeclaring ascheduler.expression/scheduler.name/scheduler.periodproperty. - A file that imports
org.apache.sling.commons.scheduler.Scheduler(programmatic use via an injectedScheduler) but has no class-level match — one finding at the file's primary type.
Emitted at the class declaration, with the class header as the snippet. Parse-level only — direct implements clause and same-file @Component; reached-via-base-class and constant-valued properties are not resolved.
Resolution contract
guided — apply (guided). The analyzer locates and reports each scheduler class; remediation is judgment-based and routed by the Classification above to Path A (path-a.md [blocked], Runnable + OSGi properties) or Path B (path-b.md [blocked], Sling Jobs via JobManager). Open the chosen path and apply its steps in an apply session.
Review Checklist
Use the path-specific checklists in path-a.md [blocked] and path-b.md [blocked] for scheduler mechanics.
Cross-cutting checks:
- The chosen path matches the source shape: Path A for a single hardcoded cron +
Runnable; Path B for config-driven or multi-schedule legacy code - No
@SlingScheduledannotation - No
scheduler.schedule(),scheduler.unschedule(), orscheduler.EXPR()calls in the migrated code -
getServiceResourceResolverused — notgetAdministrativeResourceResolver - ResourceResolver in try-with-resources — not stored as a field
- OSGi DS R6 annotations (
org.osgi.service.component.annotations) — no Felix SCR - Service user subservice name matches a
ServiceUserMapperImpl.amendedconfig
Path A checks:
-
scheduler.expressionis a valid Quartz cron string (6 or 7 fields) -
scheduler.concurrent:Boolean=falsepresent with the:Booleantype hint -
scheduler.runOn=SINGLEorLEADERset when the job writes to repo or calls external systems
Path B checks:
- The job topic constant is shared between the Scheduler and JobConsumer classes
- Job properties are read with
job.getProperty("key", Type.class) -
JobResult.OK,FAILED, orCANCELis returned from the consumer
Troubleshooting
Thread pool starvation root cause chain:
- A scheduler makes a blocking call (HTTP/LDAP/DB) without a read timeout
scheduler.concurrentmissing/true→ new thread per trigger- All
ApacheSlingdefaultthreads stuck block policy=ABORTsilently rejects new submissions — no log, no execution
Diagnose: Developer Console → Thread Pool → ApacheSlingdefault → check active count vs max pool size and block policy.
Fix: add HTTP read timeout to the blocking scheduler; set scheduler.concurrent:Boolean=false; change block policy to RUN via OSGi config in Git.
Common Pitfalls
@SlingScheduled used — the annotation does not exist in the CS SDK; class compiles but schedule is never registered (silent failure).
scheduler.schedule() in @Activate — dynamic registration is not persisted across restarts; schedule disappears on next deployment.
Missing scheduler.runOn on a write-side scheduler — default ALL fires on every publish pod; 3 pods = 3× the JCR writes / external calls per trigger.
Static ResourceResolver field — closed by the repo after idle timeout; subsequent run() invocations throw ClosedResourceResolverException.
scheduler.concurrent=false without :Boolean type hint — OSGi treats it as String, concurrent execution is not suppressed.


