Real cases · 14 of 25
A real requirement, worked end to end — authors kept publishing pages that looked broken on the live site because the images and Experience Fragments they referenced were never activated along with them. Full working workflow process step that walks page references, DAM assets, and nested Experience Fragments, batches activation, and protects against reference cycles.
Part of the Real-World AEM Problems series — practical, production issues and how they were solved. Background reading: AEM Workflow Architecture covers workflow process steps and when a custom one is the right tool.
The recurring ticket looked different every time on the surface — a broken hero image, a header that rendered blank, a "site leaving" popup that silently failed to appear — but support kept tracing every one of them back to the same root cause: an author published a page, and something the page depended on was never published with it.
AEM's default "Activate Page" step does exactly what it says: it activates the page. It does not walk the page's content looking for the DAM assets referenced inside components, and it has no idea that a page might be built partly out of Experience Fragments (a shared header, a shared footer, a promotional banner XF) that themselves reference more assets, and sometimes more XFs. So the actual publish behavior authors experienced was: publish the page, and hope every image and fragment it depends on happened to already be live from some earlier, unrelated activation. When that hope didn't pay off, the page went live half-broken, and nobody found out until a customer or a stakeholder did.
The two specific gaps were:
The tempting shortcut is to tell authors "just remember to activate the images too" — a manual checklist item. That doesn't survive contact with a real content team: there's always a new hire who doesn't know the rule, always a page with twelve images instead of two, and always a fragment nested inside another fragment that nobody remembers exists. Any fix that depends on a human remembering an extra step, every time, for every publish, is a fix that will fail on a predictable schedule.
The better fix lives where the actual behavior needs to change: the publish workflow itself. A custom workflow process step, added alongside (or after) the standard Activate Page step, that:
jcr:content node — both need to resolve to the same page).A few decisions in this design mattered more than they looked at first:
Reuse the existing asset-reference API instead of reinventing pattern matching. AEM already ships AssetReferenceSearch, which walks a subtree and finds DAM asset references without needing to know which component property holds the reference — it's built for exactly this. There's no equivalent built-in helper for Experience Fragment references, so that part needed a small custom traversal — but it only needed to check a short, explicit list of known dialog property names (fragmentPath, experienceFragmentVariationPath, headerFragmentPath, and a few others used across this site's components) rather than trying to be clever and guess "does this string look like a path." Guessing produces false positives; an explicit list is reviewable and easy to extend when a new component is built.
Protect against cycles, because they're a real structure here, not a hypothetical edge case. A header XF referencing a site-leaving-popup XF, which in turn could theoretically reference something that loops back, is exactly the kind of authoring structure that emerges naturally once fragments are reused across a large site. A visited set keyed by normalized XF path, checked before recursing, turns "could infinite-loop in production" into "each fragment is processed exactly once" — cheap to add, easy to skip if you're not thinking about it up front.
Make the roots and behavior configurable, not hardcoded. The DAM root, the Experience Fragment root, whether to include XF-nested assets at all, and even which replication agent to target are read from the workflow step's own process arguments (AEM passes these through MetaDataMap) rather than baked into the code. That's what lets the same process step be dropped into a different workflow model, or a different site section, without a code change.
Batch the replication calls. Sending activation requests for hundreds of individual paths one at a time is slow and puts unnecessary load on the replication queue; sending all of them in a single giant call risks one bad path (a stale reference to something deleted) taking down the whole batch. Fixed-size batches — 50 paths per Replicator.replicate() call — landed as a reasonable middle ground: enough to be efficient, small enough that one failed batch doesn't undo everything else already queued.
The POC below validates the trickiest part in isolation — that a reference cycle between fragments gets visited exactly once, not forever — before it's wired into the full workflow step.
@ExtendWith(AemContextExtension.class)
class CascadeTraversalTest {
private final AemContext context = new AemContext();
@BeforeEach
void setup() {
// header XF references popup XF, popup XF references header XF back
context.load().json("/cascade-fixture.json", "/content/shop/en/products/widget");
}
@Test
void assetsUnderPageAreDiscovered() {
Resource content = context.resourceResolver()
.getResource("/content/shop/en/products/widget/jcr:content");
Set<String> assets = PublishCascadeWorkflowStep.discoverAssets(
content, "/content/dam/shop", context.resourceResolver());
assertThat(assets).contains("/content/dam/shop/widget/hero.jpg");
}
@Test
void mutuallyReferencingFragmentsAreEachExpandedOnlyOnce() {
Resource content = context.resourceResolver()
.getResource("/content/shop/en/products/widget/jcr:content");
PublishCascadeWorkflowStep step = new PublishCascadeWorkflowStep();
Set<String> published = step.buildPublishSet(
context.resourceResolver(), "/content/shop/en/products/widget",
"/content/dam/shop", "/content/experience-fragments", true);
// page + 2 fragments, no duplicate expansion despite the mutual reference
assertThat(published).contains(
"/content/shop/en/products/widget",
"/content/experience-fragments/shop/header",
"/content/experience-fragments/shop/popup");
}
}
Both tests run against AemContext-loaded fixture content rather than a live author instance — cheap enough to run on every build, and precise enough to catch a broken cycle guard before it ever reaches a real workflow.
The real fix is a breadth-first walk over a queue of "things still to check," rather than one method calling into another recursively for pages versus fragments. Treating the page and every fragment it pulls in as the same kind of node — something with a jcr:content subtree that might itself reference more assets and more fragments — keeps the traversal logic in one place instead of two mirrored methods that have to be kept in sync.
package com.example.aem.core.workflow;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.jcr.Node;
import javax.jcr.Session;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.adobe.granite.workflow.WorkflowSession;
import com.adobe.granite.workflow.exec.WorkItem;
import com.adobe.granite.workflow.exec.WorkflowProcess;
import com.adobe.granite.workflow.metadata.MetaDataMap;
import com.day.cq.commons.jcr.JcrConstants;
import com.day.cq.dam.commons.util.AssetReferenceSearch;
import com.day.cq.replication.Agent;
import com.day.cq.replication.ReplicationActionType;
import com.day.cq.replication.ReplicationException;
import com.day.cq.replication.ReplicationOptions;
import com.day.cq.replication.Replicator;
/**
* Workflow process step that publishes a page along with everything it
* visibly depends on: referenced DAM assets, referenced Experience Fragments,
* and anything those fragments reference in turn.
*/
@Component(service = WorkflowProcess.class, property = {
"process.label=Cascade Publish: Page + Assets + Experience Fragments" })
public class PublishCascadeWorkflowStep implements WorkflowProcess {
private static final Logger LOG = LoggerFactory.getLogger(PublishCascadeWorkflowStep.class);
@Reference
private Replicator replicationService;
private static final String ARG_KEY = "PROCESS_ARGS";
private static final String ASSET_ROOT_DEFAULT = "/content/dam/shop";
private static final String FRAGMENT_ROOT_DEFAULT = "/content/experience-fragments";
private static final int BATCH_LIMIT = 50;
// Dialog fields that may hold a reference to an Experience Fragment
private static final List<String> FRAGMENT_REFERENCE_PROPS = List.of(
"fragmentPath", "xfPath", "headerFragmentPath", "footerFragmentPath",
"popupFragmentPath", "fragmentVariationPath");
@Override
public void execute(WorkItem item, WorkflowSession workflowSession, MetaDataMap metaData) {
ResourceResolver resolver = workflowSession.adaptTo(ResourceResolver.class);
Session jcrSession = resolver != null ? resolver.adaptTo(Session.class) : null;
if (resolver == null || jcrSession == null) {
LOG.error("Could not obtain a ResourceResolver/Session from the workflow session");
return;
}
Map<String, String> settings = readSettings(metaData.get(ARG_KEY, String.class));
String assetRoot = settings.getOrDefault("assetRoot", ASSET_ROOT_DEFAULT);
String fragmentRoot = settings.getOrDefault("fragmentRoot", FRAGMENT_ROOT_DEFAULT);
boolean expandFragmentAssets = !"false".equalsIgnoreCase(settings.get("expandFragmentAssets"));
String startPath = resolvePagePath(String.valueOf(item.getWorkflowData().getPayload()));
Set<String> pathsToPublish = buildPublishSet(resolver, startPath, assetRoot, fragmentRoot,
expandFragmentAssets);
publishInBatches(jcrSession, pathsToPublish, settings.get("agentId"));
LOG.debug("Cascade publish finished for {} — {} total paths", startPath, pathsToPublish.size());
}
/**
* Breadth-first walk: start at the page, then visit every Experience
* Fragment it references, then every fragment those reference, and so on.
* A fragment is only ever expanded once, which is what makes a mutual
* reference between two fragments harmless instead of an infinite loop.
*/
Set<String> buildPublishSet(ResourceResolver resolver, String startPath, String assetRoot,
String fragmentRoot, boolean expandFragmentAssets) {
Set<String> pathsToPublish = new LinkedHashSet<>();
Set<String> fragmentsExpanded = new HashSet<>();
Deque<String> pending = new ArrayDeque<>();
pending.add(startPath);
boolean isPage = true;
while (!pending.isEmpty()) {
String currentPath = pending.poll();
if (!isPage && !fragmentsExpanded.add(currentPath)) {
continue; // already expanded — stops a mutual fragment reference from looping
}
Resource content = resolver.getResource(currentPath + "/" + JcrConstants.JCR_CONTENT);
if (content == null) {
LOG.warn("No {} found under {}, skipping", JcrConstants.JCR_CONTENT, currentPath);
isPage = false;
continue;
}
pathsToPublish.add(currentPath);
if (isPage || expandFragmentAssets) {
pathsToPublish.addAll(discoverAssets(content, assetRoot, resolver));
}
for (String fragmentPath : discoverFragmentReferences(content, fragmentRoot)) {
String normalized = resolvePagePath(fragmentPath);
if (!fragmentsExpanded.contains(normalized)) {
pending.add(normalized);
}
}
isPage = false;
}
return pathsToPublish;
}
private static String resolvePagePath(String path) {
int contentIndex = path.indexOf("/" + JcrConstants.JCR_CONTENT);
return contentIndex >= 0 ? path.substring(0, contentIndex) : path;
}
private static Map<String, String> readSettings(String rawArgs) {
if (StringUtils.isBlank(rawArgs)) {
return Collections.emptyMap();
}
Map<String, String> settings = new HashMap<>();
for (String entry : rawArgs.split(",")) {
int eq = entry.indexOf('=');
if (eq > 0) {
settings.put(entry.substring(0, eq).trim(), entry.substring(eq + 1).trim());
}
}
return settings;
}
static Set<String> discoverAssets(Resource content, String assetRoot, ResourceResolver resolver) {
Node jcrNode = content.adaptTo(Node.class);
if (jcrNode == null) {
return Collections.emptySet();
}
Map<String, ?> references = new AssetReferenceSearch(jcrNode, assetRoot, resolver).search();
return references == null || references.isEmpty() ? Collections.emptySet() : references.keySet();
}
private static Set<String> discoverFragmentReferences(Resource content, String fragmentRoot) {
Set<String> found = new LinkedHashSet<>();
collectFragmentPaths(content, fragmentRoot, found);
return found;
}
private static void collectFragmentPaths(Resource resource, String fragmentRoot, Set<String> found) {
for (String propertyName : FRAGMENT_REFERENCE_PROPS) {
Object value = resource.getValueMap().get(propertyName);
if (value instanceof String single && single.startsWith(fragmentRoot)) {
found.add(single);
} else if (value instanceof String[] multiple) {
for (String candidate : multiple) {
if (candidate != null && candidate.startsWith(fragmentRoot)) {
found.add(candidate);
}
}
}
}
for (Resource child : resource.getChildren()) {
collectFragmentPaths(child, fragmentRoot, found);
}
}
private void publishInBatches(Session jcrSession, Set<String> paths, String targetAgentId) {
ReplicationOptions options = new ReplicationOptions();
if (StringUtils.isNotBlank(targetAgentId)) {
options.setFilter((Agent agent) -> agent != null && agent.isEnabled()
&& targetAgentId.equals(agent.getId()));
}
List<String> batch = new ArrayList<>(BATCH_LIMIT);
for (String path : paths) {
batch.add(path);
if (batch.size() == BATCH_LIMIT) {
sendBatch(jcrSession, options, batch);
batch.clear();
}
}
if (!batch.isEmpty()) {
sendBatch(jcrSession, options, batch);
}
}
private void sendBatch(Session jcrSession, ReplicationOptions options, List<String> batch) {
try {
replicationService.replicate(jcrSession, ReplicationActionType.ACTIVATE,
batch.toArray(new String[0]), options);
} catch (ReplicationException e) {
LOG.error("Failed to activate a batch of {} paths", batch.size(), e);
}
}
}
Wired into a workflow model, the step's settings come from the Process Step's "Arguments" field — for example assetRoot=/content/dam/shop,fragmentRoot=/content/experience-fragments,expandFragmentAssets=true — so the same compiled step can serve a different site section without a redeploy.
Broken-image and blank-fragment tickets from this specific cause dropped to zero after rollout — there was no longer a manual step for anyone to forget. Extending the existing publish workflow, instead of building a separate "activation service" authors would need to remember to run, meant the fix lived entirely inside the tool authors already use: they publish a page the same way they always did, and the dependency graph comes along with it.
The cycle-protection detail earned its keep during code review, not in production — a reviewer asked "what happens if the site-leaving popup ever references the header fragment back?" before this ever shipped, which is exactly the kind of question worth asking about any recursive traversal over content authors are free to structure however they want.
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.