Real cases · 15 of 25
A real requirement, worked end to end — legal/compliance approvals were quietly sitting in reviewers' inboxes for weeks because nobody checked AEM's Inbox unless something else reminded them. Full working scheduled job that finds stalled workflow approvals, groups them by request reference, and sends one batched reminder email instead of one per page — plus the dual-trigger design that lets the same job run on a cron schedule or on demand with different parameters.
Part of the Real-World AEM Problems series — practical, production issues and how they were solved. Background reading: Sling Jobs vs Schedulers in AEM — Choosing the Right Background Processing Model covers the Scheduler vs. Jobs API decision behind the approach used here.
Content that needed legal or compliance sign-off before publishing went through an approval step in a workflow — a standard AEM Inbox item sitting in a reviewer's queue, waiting for someone to open it, read the page, and approve or reject it. In theory this worked. In practice, pages routinely sat in that step for two, three, sometimes four weeks before anyone acted on them, and the first anyone in marketing heard about it was a stakeholder asking "why isn't this live yet?"
The root problem wasn't the workflow model — it correctly routed content to the right approvers and correctly blocked publish until sign-off. The problem was that AEM's Inbox is a pull system: it shows you what's waiting, but it doesn't tell you anything is waiting unless you go look. Reviewers with a busy week, or reviewers who simply didn't have "check AEM Inbox" as a standing habit, let items sit indefinitely. There was no push notification, no escalation, nothing that surfaced a stalled approval to the person who could act on it.
The fix needed to answer a specific, narrow question on a schedule — "which approvals have been sitting for longer than N days, and who needs an email about them?" — without becoming a second workflow model bolted awkwardly onto the first, and without spamming a reviewer with one email per page when they might have a dozen stalled items from the same content request.
The instinct to solve "people aren't checking their queue" is often to add a wait-and-escalate step directly into the workflow model — a timeout branch that fires a reminder if the participant step hasn't completed after N days. That works, but it couples the reminder logic to the workflow engine's own timing and makes the model itself more complex to read and maintain, and it doesn't easily support "let ops manually re-run this reminder right now with a 1-day threshold to sanity-check it" — you'd be re-triggering real workflow timers, not just querying.
The better fit here is a scheduled Sling Job, decoupled entirely from the workflow model:
Two design decisions mattered more than they looked at first:
Query the repository directly instead of asking the Workflow API to enumerate instances. AEM's WorkflowSession can list active instances, but doing this reliably at scale — "find every instance for this specific model, currently sitting in this specific step, older than a cutoff date" — is a straightforward, indexable JCR-SQL2 query and awkward as a Java-side filter over an API not built for bulk queries. Querying directly keeps this fast even as the number of in-flight workflow instances grows.
One job class, two ways to trigger it. The same job body needs to run unattended on a daily cron schedule and needs to be re-runnable on demand — ops wants to test a change to the email template, or wants to temporarily lower the threshold from 7 days to 1 day to confirm the query logic is right, without waiting for tomorrow's scheduled run. Rather than writing a second near-duplicate class for the "manual run" case, the job reads its configuration two ways depending on how it was triggered: default values when it's the real schedule firing, or an explicit JSON payload of overrides when someone queues it manually with different parameters. One code path, two ways in.
The POC below proves the query-and-group logic works in isolation before it's wired into the actual job consumer.
@ExtendWith(AemContextExtension.class)
class StalledApprovalQueryTest {
private final AemContext context = new AemContext();
@BeforeEach
void setup() {
context.load().json("/stalled-approvals-fixture.json", "/var/workflow/instances");
}
@Test
void onlyInstancesOlderThanThresholdAreReturned() {
List<StalledApproval> results = StalledApprovalFinder.find(
context.resourceResolver(), "/var/workflow/models/publish-approval",
"Legal & Compliance Review", 7);
assertThat(results).extracting(StalledApproval::path)
.containsExactly("/content/mysite/en/promo/spring-sale");
// a second fixture item started 2 days ago and is correctly excluded
}
@Test
void resultsGroupByRequestReferenceForBatchedEmails() {
List<StalledApproval> stalled = List.of(
new StalledApproval("/content/mysite/en/a", "REQ-101", 9),
new StalledApproval("/content/mysite/en/b", "REQ-101", 11),
new StalledApproval("/content/mysite/en/c", "REQ-202", 8));
Map<String, List<StalledApproval>> grouped = StalledApprovalFinder.groupByRequestReference(stalled);
assertThat(grouped).hasSize(2);
assertThat(grouped.get("REQ-101")).hasSize(2);
}
}
Both run against fixture content and a list of plain objects — no scheduler, no live email sending, no workflow engine involved. That's deliberate: the part worth testing here is "does the query and grouping logic produce the right buckets," not "does Sling's scheduler fire jobs on time."
package com.example.aem.core.jobs;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.jcr.Node;
import javax.jcr.NodeIterator;
import javax.jcr.Session;
import javax.jcr.query.Query;
import javax.jcr.query.QueryManager;
import javax.jcr.query.QueryResult;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
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 com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Daily job: find workflow approvals stuck in the review step longer than
* a configured threshold, and send one batched reminder email per content
* request rather than one email per stalled page.
*/
@Component(service = JobConsumer.class, property = {
JobConsumer.PROPERTY_TOPICS + "=" + "example/jobs/stalled-approval-reminder" }, immediate = true)
public class StalledApprovalReminderJob implements JobConsumer {
private static final Logger LOG = LoggerFactory.getLogger(StalledApprovalReminderJob.class);
private static final String WORKFLOW_MODEL_PATH = "/var/workflow/models/publish-approval";
private static final String REVIEW_STEP_TITLE = "Legal & Compliance Review";
private static final String APPROVERS_GROUP_LIST = "/etc/acs-commons/lists/content-approvers";
private static final int DEFAULT_THRESHOLD_DAYS = 7;
private static final String PROP_TRIGGER_SOURCE = "triggerSource";
private static final String PROP_OVERRIDES_JSON = "overrides";
private static final String TRIGGER_SCHEDULE = "schedule";
private static final ObjectMapper JSON = new ObjectMapper();
@Reference
private ResourceResolverFactory resolverFactory;
@Reference
private ApprovalReminderMailer mailer;
@Override
public JobResult process(Job job) {
JobSettings settings = resolveSettings(job);
if (settings == null) {
LOG.warn("Job triggered without a recognizable source; failing");
return JobResult.FAILED;
}
try (ResourceResolver resolver = resolverFactory.getServiceResourceResolver(serviceUserParams())) {
List<StalledApproval> stalled = StalledApprovalFinder.find(
resolver, WORKFLOW_MODEL_PATH, REVIEW_STEP_TITLE, settings.thresholdDays());
if (stalled.isEmpty()) {
LOG.debug("No stalled approvals older than {} days", settings.thresholdDays());
return JobResult.OK;
}
Map<String, List<StalledApproval>> byRequest = StalledApprovalFinder.groupByRequestReference(stalled);
String recipients = mailer.resolveGroupEmailList(resolver, APPROVERS_GROUP_LIST);
for (Map.Entry<String, List<StalledApproval>> group : byRequest.entrySet()) {
mailer.sendReminder(recipients, group.getKey(), group.getValue(), settings.thresholdDays());
}
LOG.debug("Sent {} reminder email(s) covering {} stalled approvals",
byRequest.size(), stalled.size());
return JobResult.OK;
} catch (Exception e) {
LOG.error("Stalled approval reminder job failed", e);
return JobResult.FAILED;
}
}
/**
* The same job runs two ways: a real cron trigger (use configured
* defaults) or an on-demand run carrying a JSON payload of overrides
* (so ops can re-test with a different threshold without waiting for
* the next scheduled run).
*/
private JobSettings resolveSettings(Job job) {
String source = job.getProperty(PROP_TRIGGER_SOURCE, String.class);
if (TRIGGER_SCHEDULE.equals(source)) {
return new JobSettings(DEFAULT_THRESHOLD_DAYS);
}
String overridesJson = job.getProperty(PROP_OVERRIDES_JSON, String.class);
if (StringUtils.isBlank(overridesJson)) {
return null;
}
try {
JsonNode node = JSON.readTree(overridesJson);
int threshold = node.path("thresholdDays").asInt(DEFAULT_THRESHOLD_DAYS);
return new JobSettings(threshold);
} catch (Exception e) {
LOG.error("Could not parse job overrides payload", e);
return null;
}
}
private Map<String, Object> serviceUserParams() {
return Map.of(ResourceResolverFactory.SUBSERVICE, "approval-reminder-service");
}
private record JobSettings(int thresholdDays) {
}
}
package com.example.aem.core.jobs;
/**
* One page currently sitting in the review step past the configured threshold.
*/
public record StalledApproval(String path, String requestReference, int daysStalled) {
}
package com.example.aem.core.jobs;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.jcr.Node;
import javax.jcr.NodeIterator;
import javax.jcr.Session;
import javax.jcr.query.Query;
import javax.jcr.query.QueryManager;
import javax.jcr.query.QueryResult;
import org.apache.sling.api.resource.ResourceResolver;
/**
* Finds workflow instances stuck in a specific step for longer than a
* threshold, and groups the results by content-request reference.
*/
final class StalledApprovalFinder {
private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ISO_LOCAL_DATE;
private StalledApprovalFinder() {
}
static List<StalledApproval> find(ResourceResolver resolver, String modelPath, String stepTitle,
int thresholdDays) throws Exception {
Session session = resolver.adaptTo(Session.class);
String cutoff = LocalDate.now(ZoneOffset.UTC).minusDays(thresholdDays).format(ISO_DATE);
QueryManager queryManager = session.getWorkspace().getQueryManager();
String sql2 = "SELECT * FROM [nt:base] AS wf "
+ "WHERE ISDESCENDANTNODE(wf, '/var/workflow/instances') "
+ "AND wf.[modelId] = $modelId "
+ "AND wf.[currentStepTitle] = $stepTitle "
+ "AND wf.[startTime] < CAST($cutoff AS DATE)";
Query query = queryManager.createQuery(sql2, Query.JCR_SQL2);
query.bindValue("modelId", session.getValueFactory().createValue(modelPath));
query.bindValue("stepTitle", session.getValueFactory().createValue(stepTitle));
query.bindValue("cutoff", session.getValueFactory().createValue(cutoff));
QueryResult result = query.execute();
NodeIterator nodes = result.getNodes();
List<StalledApproval> stalled = new ArrayList<>();
while (nodes.hasNext()) {
Node instanceNode = nodes.nextNode();
String payloadPath = instanceNode.getProperty("payloadPath").getString();
String requestReference = instanceNode.hasProperty("requestReference")
? instanceNode.getProperty("requestReference").getString()
: "unassigned";
int daysStalled = thresholdDays; // real value derived from startTime in the full implementation
stalled.add(new StalledApproval(payloadPath, requestReference, daysStalled));
}
return stalled;
}
static Map<String, List<StalledApproval>> groupByRequestReference(List<StalledApproval> stalled) {
Map<String, List<StalledApproval>> grouped = new LinkedHashMap<>();
for (StalledApproval approval : stalled) {
grouped.computeIfAbsent(approval.requestReference(), k -> new ArrayList<>()).add(approval);
}
return grouped;
}
}
ApprovalReminderMailer is a small interface wrapping whichever notification mechanism the project already uses (AEM's mail service, or an external transactional-email API) — kept separate so the job's scheduling and query logic doesn't need to know or care how the email actually gets sent, and so the mailer can be swapped or mocked independently in tests.
Average time-in-review for approval-step content dropped noticeably within the first month — not because reviewers suddenly became more diligent, but because a stalled approval no longer depended on someone remembering to check a queue nobody had a habit of checking. The batched-by-request email mattered more than expected: the first design (one email per stalled page) was tested briefly in staging and produced reviewer complaints about inbox noise within a day, which is what pushed the grouping logic in before it ever reached production.
The dual-trigger design — one job class instead of a scheduled version and a separate "run it manually" version — paid for itself the first time ops needed to verify a change to the email template without waiting for the next 24-hour cycle.
This is one entry in an ongoing series of real production AEM problems and how they were actually solved. Check back for the next one.
Get an email when I publish the next chapter. No spam — just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.