All AEM as a Cloud Service skills: component development, Workflow model design, development, triggering, launchers, debugging, and triaging, Dispatcher config authoring, advisory, incident response, performance tuning, and security hardening, Rapid Development Environment (RDE) deployment, log inspection, snapshots, and troubleshooting via `aio aem rde`, Jackrabbit Oak query/index tuning (property, Lucene index definitions), and extracting/inventorying JCR queries (XPath, SQL2, QueryBuilder) across a codebase.
85%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
For schedulers with a hardcoded cron, a single schedule, and implements Runnable.
Before starting: Read ../references/aem-cloud-service-pattern-prerequisites.md
and apply SCR→DS and ResourceResolver fixes if present in the same changeset.
@Component to declare the schedule via properties// BEFORE (legacy)
@Component(immediate = true)
// OR: @Component(service = Job.class, immediate = true)
// AFTER — Cloud Service compatible
@Component(
service = Runnable.class,
immediate = true,
property = {
"scheduler.expression=0 0 2 * * ?", // keep your existing cron value
"scheduler.concurrent:Boolean=false", // :Boolean type hint is required
"scheduler.runOn=SINGLE" // one JVM only; use LEADER if needed
}
)
public class MyScheduler implements Runnable { /* ... */ }scheduler.concurrent:Boolean=false requires the :Boolean type hint — without it OSGi treats
the value as a String and concurrent execution is not suppressed.
Full property reference:
| Property | Type | Purpose | Typical value |
|---|---|---|---|
scheduler.expression | String | Quartz cron expression | Your existing cron |
scheduler.concurrent | Boolean | Allow overlapping runs | false (set via :Boolean=false) |
scheduler.runOn | String | Which topology members execute it | SINGLE for writes/external calls; omit only for per-pod tasks |
scheduler.period | Long | Fixed-interval scheduling in seconds | Only when migrating a periodic (non-cron) scheduler — prefer scheduler.expression |
scheduler.name | String | Human-readable name / registry key | Optional; useful for identifying the scheduler in Sling Scheduler status (Developer Console on AEMaaCS, Felix Web Console on local SDK) |
Scheduler @Reference field// REMOVE entirely — the framework calls run() directly via scheduler.expression
import org.apache.sling.commons.scheduler.Scheduler;
@Reference
private Scheduler scheduler;schedule() / unschedule() / EXPR() callRemove scheduler.schedule(...), scheduler.unschedule(...), scheduler.EXPR(...) calls and
helper methods that exist only for scheduling (addScheduler(), removeScheduler()).
// BEFORE
@Activate
protected void activate() {
scheduler.schedule(this, scheduler.NOW(-1, 30), "0 0 2 * * ?");
}
// AFTER
@Activate
protected void activate() {
LOG.info("Scheduler activated");
}@Modified if it only re-registers schedulesSling re-reads scheduler.expression automatically on config change.
// REMOVE if it only calls removeScheduler() + addScheduler()
@Modified
protected void modified(Config config) {
removeScheduler();
addScheduler(config);
}Keep @Modified only if it updates other cached config fields; remove the scheduling calls from it.
@Designate-backed Path A schedulers: If the class uses @Designate and @Modified purely to update cached instance fields (not to re-register the schedule), keep @Modified — the Sling framework handles the schedule update from the new config automatically, but your code may still need to react to the new config values:
@Modified
protected void modified(Config config) {
this.myParameter = config.myParameter(); // keep — updates cached field
// do NOT call removeScheduler() / addScheduler() — Sling handles reschedule
}@Component| Found in legacy code | Put in @Component.property |
|---|---|
scheduler.schedule(this, ..., "0 0 2 * * ?") | "scheduler.expression=0 0 2 * * ?" |
@Property(name="scheduler.expression", value="*/30 * * * * ?") | "scheduler.expression=*/30 * * * * ?" |
scheduler.EXPR("0 * * * * ?") | "scheduler.expression=0 * * * * ?" |
Do not invent a cron. If not found in the legacy code, ask the user.
For configurable crons, use @Designate with an @ObjectClassDefinition:
@ObjectClassDefinition(name = "My Scheduler")
public @interface Config {
@AttributeDefinition(name = "Cron expression")
String scheduler_expression() default "0 0 2 * * ?";
@AttributeDefinition(name = "Concurrent")
boolean scheduler_concurrent() default false;
@AttributeDefinition(name = "Run on")
String scheduler_runOn() default "SINGLE";
}
@Component(service = Runnable.class, immediate = true)
@Designate(ocd = MyScheduler.Config.class)
public class MyScheduler implements Runnable { /* ... */ }scheduler_expression() maps to property key scheduler.expression — underscores become dots.
If the cron must come from runtime OSGi configuration with arbitrary values (the admin can set any cron at runtime), this is a Path B scheduler — stop here and follow path-b.md instead.
ResourceResolver handling inside run()@Override
public void run() {
try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver(
Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "scheduler-service"))) {
// existing job logic here
LOG.debug("Scheduled job completed");
} catch (LoginException e) {
LOG.error("Could not open service resolver for subservice 'scheduler-service'", e);
}
}getServiceResourceResolver throws LoginException on failure — it does NOT normally return
null. Catch LoginException and log; do not add a resolver == null branch unless a custom
wrapper is in use.
Write-side schedulers: If run() modifies JCR content, call resolver.commit() before the try-with-resources closes the resolver. Changes not committed are silently discarded on resolver close:
@Override
public void run() {
try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver(
Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "scheduler-service"))) {
Resource resource = resolver.getResource("/content/my-page");
if (resource != null) {
ModifiableValueMap props = resource.adaptTo(ModifiableValueMap.class);
props.put("lastRun", System.currentTimeMillis());
resolver.commit(); // required — changes are discarded without this
}
} catch (LoginException e) {
LOG.error("Could not open service resolver for subservice 'scheduler-service'", e);
} catch (PersistenceException e) {
LOG.error("Failed to commit changes", e);
}
}Author-only schedulers: If run() must only execute on author, inject SlingSettingsService and guard at the top of run():
@Reference
private SlingSettingsService slingSettingsService;
@Override
public void run() {
if (!slingSettingsService.getRunModes().contains("author")) {
return;
}
// author-only logic
}Import: import org.apache.sling.settings.SlingSettingsService;
Add the factory field if missing:
@Reference
private ResourceResolverFactory resolverFactory;@Activate// BEFORE
@Activate
protected void activate(final Map<String, Object> config) {
configure(config);
addScheduler(config);
}
// AFTER
@Activate
protected void activate(final Config config) {
this.myParameter = config.myParameter();
LOG.info("Scheduler activated");
}Remove:
import org.apache.sling.commons.scheduler.Scheduler;
import org.apache.sling.commons.scheduler.ScheduleOptions;
import org.apache.sling.commons.scheduler.Job;
import org.apache.sling.commons.scheduler.JobContext;Add if not present:
import org.apache.sling.api.resource.LoginException;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
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.Collections;Do not add org.apache.sling.commons.scheduler.SlingScheduled — it does not exist in the CS SDK.
@Deactivate if missing@Deactivate
protected void deactivate() {
LOG.info("Scheduler deactivated");
}For a stateless Path A scheduler, @Deactivate provides no functional benefit — OSGi unregisters the component and the Sling Scheduler removes the job automatically. The method is conventional and aids observability in logs, but is optional. If the legacy class already has a @Deactivate with meaningful cleanup (closing resources, clearing caches), keep it. If there is none, adding a log-only method is fine but not required.
@Component(
service = Runnable.class,
immediate = true,
property = {
"scheduler.expression=0 0 2 * * ?",
"scheduler.concurrent:Boolean=false",
"scheduler.runOn=SINGLE"
}
)
public class NightlyCleanupScheduler implements Runnable {
private static final Logger LOG = LoggerFactory.getLogger(NightlyCleanupScheduler.class);
@Reference
private ResourceResolverFactory resolverFactory;
@Activate
protected void activate() { LOG.info("NightlyCleanupScheduler activated"); }
@Deactivate
protected void deactivate() { LOG.info("NightlyCleanupScheduler deactivated"); }
@Override
public void run() {
try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver(
Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "scheduler-service"))) {
// business logic
} catch (LoginException e) {
LOG.error("Could not open service resolver for subservice 'scheduler-service'", e);
}
}
}Use @Designate(factory = true) to allow multiple independent instances via OSGi factory configs.
@Component(service = Runnable.class, immediate = true)
@Designate(ocd = MyScheduler.Config.class, factory = true)
public class MyScheduler implements Runnable {
private static final Logger LOG = LoggerFactory.getLogger(MyScheduler.class);
@ObjectClassDefinition(name = "My Scheduler Config")
@interface Config {
@AttributeDefinition(name = "Enabled")
boolean enabled() default true;
@AttributeDefinition(name = "Cron Expression")
String scheduler_expression() default "0 0 2 * * ?";
@AttributeDefinition(name = "Concurrent")
boolean scheduler_concurrent() default false;
@AttributeDefinition(name = "Run On")
String scheduler_runOn() default "SINGLE";
}
private volatile boolean enabled;
@Activate
@Modified
protected void activate(Config config) {
this.enabled = config.enabled();
}
@Reference
private ResourceResolverFactory resolverFactory;
@Override
public void run() {
if (!enabled) {
return;
}
try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver(
Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "my-scheduler-service"))) {
// business logic
} catch (LoginException e) {
LOG.error("Failed to obtain service ResourceResolver", e);
}
}
}SUBSERVICE name must match a ServiceUserMapperImpl.amended-*.cfg.json mapping.
import org.apache.sling.commons.scheduler.Scheduler remains@SlingScheduled annotationscheduler.schedule(, scheduler.unschedule(, scheduler.EXPR( calls remain@Component(service = Runnable.class) with all three scheduler propertiesscheduler.concurrent:Boolean=false uses the :Boolean type hintscheduler.runOn=SINGLE or LEADER when job writes to repo or calls external systemsResourceResolverFactory injected via @Reference if run() uses a resolverrun() — not a field@Deactivate present if there is meaningful cleanup; optional for purely stateless schedulers (see A9)org.apache.felix.scr.annotations.*ui.configmvn clean compile passes.tessl-plugin
skills
aem-rde
aem-workflow
code-assessment
content-distribution
create-component
assets
references
dispatcher
incident-response
references
dispatcher-foundation
performance-tuning
references
dispatcher-foundation
security-hardening
references
dispatcher-foundation
technical-advisory
references
dispatcher-foundation
workflow-orchestrator
references
dispatcher-foundation
ensure-agents-md
references
migration
references
dispatcher
legacy-ui
template-modernization
validation
scripts
dispatcher-tool
validate-migration