You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tapestry.apache.org by th...@apache.org on 2022/11/17 22:59:59 UTC

[tapestry-5] 01/01: TAP5-2742: Initial work on smarter page cache invalidation

This is an automated email from the ASF dual-hosted git repository.

thiagohp pushed a commit to branch better-page-invalidation
in repository https://gitbox.apache.org/repos/asf/tapestry-5.git

commit 8f9b33380dd95bef2df6bb45174043d30e3acdfa
Author: Thiago H. de Paula Figueiredo <th...@arsmachina.com.br>
AuthorDate: Thu Nov 17 19:58:17 2022 -0300

    TAP5-2742: Initial work on smarter page cache invalidation
---
 583_RELEASE_NOTES.md                               |   7 +
 .../commons/services/InvalidationEventHub.java     |  16 ++
 .../tapestry5/corelib/pages/PageCatalog.java       | 139 ++++++++++++++-
 .../internal/event/InvalidationEventHubImpl.java   |  46 ++++-
 .../services/ComponentClassResolverImpl.java       |  35 +++-
 .../services/ComponentDependencyRegistry.java      |  62 +++++++
 .../services/ComponentDependencyRegistryImpl.java  | 191 +++++++++++++++++++++
 .../internal/services/PageSourceImpl.java          |   8 +-
 .../services/ResourceDigestManagerImpl.java        |   7 +
 .../internal/structure/ComponentPageElement.java   |  10 ++
 .../structure/ComponentPageElementImpl.java        |  24 ++-
 .../apache/tapestry5/modules/TapestryModule.java   |  13 ++
 .../tapestry5/services/ComponentClassResolver.java |   6 +-
 .../apache/tapestry5/corelib/pages/PageCatalog.tml |  28 ++-
 .../event/InvalidationEventHubImplTest.java        |  71 ++++++++
 .../ComponentDependencyRegistryImplTest.java       |  41 +++++
 16 files changed, 675 insertions(+), 29 deletions(-)

diff --git a/583_RELEASE_NOTES.md b/583_RELEASE_NOTES.md
new file mode 100644
index 000000000..2e6bea9dd
--- /dev/null
+++ b/583_RELEASE_NOTES.md
@@ -0,0 +1,7 @@
+Scratch pad for changes destined for the 5.8.3 release notes page.
+
+# Non-backward-compatible changes
+
+* New addInvalidationCallback(Function<List<String>, List<String>> callback) method in InvalidationEventHub
+* New getEmbeddedElementIds() method in ComponentPageElement (internal service)
+* New getLogicalName() method in ComponentClassResolver.
\ No newline at end of file
diff --git a/commons/src/main/java/org/apache/tapestry5/commons/services/InvalidationEventHub.java b/commons/src/main/java/org/apache/tapestry5/commons/services/InvalidationEventHub.java
index d5831721e..85e3fbb69 100644
--- a/commons/src/main/java/org/apache/tapestry5/commons/services/InvalidationEventHub.java
+++ b/commons/src/main/java/org/apache/tapestry5/commons/services/InvalidationEventHub.java
@@ -12,7 +12,11 @@
 
 package org.apache.tapestry5.commons.services;
 
+import java.util.List;
 import java.util.Map;
+import java.util.function.Function;
+
+import org.apache.tapestry5.ioc.annotations.IncompatibleChange;
 
 /**
  * An object which manages a list of {@link org.apache.tapestry5.commons.services.InvalidationListener}s. There are multiple
@@ -55,4 +59,16 @@ public interface InvalidationEventHub
      * @since 5.4
      */
     void clearOnInvalidation(Map<?,?> map);
+
+    /**
+     * Adds a callback, as a function that receives a list of strings and also returns a list of strings,
+     * that is invoked when one or more listed underlying tracked resource have changed. 
+     * An empty list should be considered as all resources being changed and any caches needing to be cleared.
+     * The return value of the function should be a non-null, but possibly empty, list of other resources that also
+     * need to be invalidated in a recursive fashion.
+     * This method does nothing in production mode.
+     * @since 5.8.3
+     */
+    @IncompatibleChange(release = "5.8.3", details = "Added method")
+    void addInvalidationCallback(Function<List<String>, List<String>> function);
 }
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/corelib/pages/PageCatalog.java b/tapestry-core/src/main/java/org/apache/tapestry5/corelib/pages/PageCatalog.java
index abaedb62d..72b79b5cc 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/corelib/pages/PageCatalog.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/corelib/pages/PageCatalog.java
@@ -14,31 +14,47 @@
 
 package org.apache.tapestry5.corelib.pages;
 
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.tapestry5.MarkupWriter;
 import org.apache.tapestry5.alerts.AlertManager;
-import org.apache.tapestry5.annotations.*;
+import org.apache.tapestry5.annotations.InjectComponent;
+import org.apache.tapestry5.annotations.Persist;
+import org.apache.tapestry5.annotations.Property;
+import org.apache.tapestry5.annotations.UnknownActivationContextCheck;
+import org.apache.tapestry5.annotations.WhitelistAccessOnly;
 import org.apache.tapestry5.beaneditor.Validate;
 import org.apache.tapestry5.beanmodel.BeanModel;
 import org.apache.tapestry5.beanmodel.services.BeanModelSource;
 import org.apache.tapestry5.commons.Messages;
 import org.apache.tapestry5.commons.util.CollectionFactory;
 import org.apache.tapestry5.corelib.components.Zone;
-import org.apache.tapestry5.func.*;
+import org.apache.tapestry5.dom.Element;
+import org.apache.tapestry5.func.F;
+import org.apache.tapestry5.func.Flow;
+import org.apache.tapestry5.func.Mapper;
+import org.apache.tapestry5.func.Predicate;
+import org.apache.tapestry5.func.Reducer;
 import org.apache.tapestry5.http.TapestryHttpSymbolConstants;
+import org.apache.tapestry5.http.services.Request;
 import org.apache.tapestry5.internal.PageCatalogTotals;
+import org.apache.tapestry5.internal.services.ComponentDependencyRegistry;
 import org.apache.tapestry5.internal.services.PageSource;
 import org.apache.tapestry5.internal.services.ReloadHelper;
+import org.apache.tapestry5.internal.structure.ComponentPageElement;
 import org.apache.tapestry5.internal.structure.Page;
 import org.apache.tapestry5.ioc.OperationTracker;
 import org.apache.tapestry5.ioc.annotations.Inject;
 import org.apache.tapestry5.ioc.annotations.Symbol;
 import org.apache.tapestry5.ioc.internal.util.InternalUtils;
+import org.apache.tapestry5.runtime.Component;
 import org.apache.tapestry5.services.ComponentClassResolver;
 import org.apache.tapestry5.services.pageload.ComponentResourceSelector;
 
-import java.util.Collection;
-import java.util.List;
-import java.util.Set;
-
 /**
  * Lists out the currently loaded pages, using a {@link org.apache.tapestry5.corelib.components.Grid}.
  * Provides an option to force all pages to be loaded. In development mode, includes an option to clear the page cache.
@@ -64,6 +80,9 @@ public class PageCatalog
 
     @Inject
     private ComponentClassResolver resolver;
+    
+    @Inject
+    private ComponentDependencyRegistry componentDependencyRegistry;
 
     @Inject
     private AlertManager alertManager;
@@ -71,8 +90,17 @@ public class PageCatalog
     @Property
     private Page page;
 
+    @Property
+    private Page selectedPage;
+
+    @Property
+    private String dependency;
+
     @InjectComponent
     private Zone pagesZone;
+    
+    @InjectComponent
+    private Zone pageStructureZone;
 
     @Persist
     private Set<String> failures;
@@ -90,13 +118,16 @@ public class PageCatalog
 
     @Inject
     private BeanModelSource beanModelSource;
-
+    
     @Inject
     private Messages messages;
 
     @Property
     public static BeanModel<Page> model;
 
+    @Inject 
+    private Request request;
+
     void pageLoaded()
     {
         model = beanModelSource.createDisplayModel(Page.class, messages);
@@ -291,4 +322,98 @@ public class PageCatalog
     {
         return String.format("%,.3f ms", millis);
     }
+    
+    public List<String> getDependencies() 
+    {
+        List<String> dependencies = new ArrayList<>(componentDependencyRegistry.getDependencies(getSelectedPageClassName()));
+        Collections.sort(dependencies);
+        return dependencies;
+    }
+    
+    public Object onPageStructure(String name)
+    {
+        selectedPage = pageSource.getPage(name);
+        return request.isXHR() ? pageStructureZone.getBody() : null;
+    }
+    
+    public String getDisplayLogicalName() 
+    {
+        return getDisplayLogicalName(dependency);
+    }
+
+    public String getPageClassName() 
+    {
+        
+        return getClassName(page);
+    }
+
+    public String getSelectedPageClassName() 
+    {
+        return getClassName(selectedPage);
+    }
+    
+    private String getClassName(Page page) 
+    {
+        return page.getRootComponent().getComponentResources().getComponentModel().getComponentClassName();
+    }
+
+    private String getClassName(Component component) 
+    {
+        return component.getComponentResources().getComponentModel().getComponentClassName();
+    }
+
+    public void onComponentTree(MarkupWriter writer) 
+    {
+        render(selectedPage.getRootElement(), writer);
+    }
+    
+    private void render(ComponentPageElement componentPageElement, MarkupWriter writer) 
+    {
+        final Element li = writer.element("li");
+        final String className = getClassName(componentPageElement.getComponent());
+        final Set<String> embeddedElementIds = componentPageElement.getEmbeddedElementIds();
+        
+        if (componentPageElement.getComponent().getComponentResources().getComponentModel().isPage()) 
+        {
+            li.text(componentPageElement.getPageName());
+        }
+        else {
+            li.text(String.format("%s (%s)", getDisplayLogicalName(className), componentPageElement.getId()));
+        }
+        
+        if (!embeddedElementIds.isEmpty())
+        {
+            writer.element("ul");
+            for (String id : embeddedElementIds)
+            {
+                render(componentPageElement.getEmbeddedElement(id), writer);
+            }
+            writer.end();
+        }
+        
+        writer.end();
+    }
+
+    private String getDisplayLogicalName(final String className) 
+    {
+        final String logicalName = resolver.getLogicalName(className);
+        String displayName = logicalName;
+        if (logicalName == null || logicalName.trim().length() == 0)
+        {
+            if (className.contains(".base."))
+            {
+                displayName = "(base class)";
+            }
+            if (className.contains(".mixins."))
+            {
+                displayName = "(mixin)";
+            }
+        }
+        return displayName;
+    }
+
+    public String getLogicalName(String className) 
+    {
+        return resolver.getLogicalName(className);
+    }
 }
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/event/InvalidationEventHubImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/event/InvalidationEventHubImpl.java
index b8e458616..b635730b3 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/event/InvalidationEventHubImpl.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/event/InvalidationEventHubImpl.java
@@ -14,12 +14,18 @@
 
 package org.apache.tapestry5.internal.event;
 
+import org.apache.tapestry5.commons.internal.util.TapestryException;
 import org.apache.tapestry5.commons.services.InvalidationEventHub;
 import org.apache.tapestry5.commons.services.InvalidationListener;
 import org.apache.tapestry5.commons.util.CollectionFactory;
 
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
 
 /**
  * Base implementation class for classes (especially services) that need to manage a list of
@@ -27,8 +33,8 @@ import java.util.Map;
  */
 public class InvalidationEventHubImpl implements InvalidationEventHub
 {
-    private final List<Runnable> callbacks;
-
+    private final List<Function<List<String>, List<String>>> callbacks;
+    
     protected InvalidationEventHubImpl(boolean productionMode)
     {
         if (productionMode)
@@ -44,19 +50,37 @@ public class InvalidationEventHubImpl implements InvalidationEventHub
      * Notifies all listeners/callbacks.
      */
     protected final void fireInvalidationEvent()
+    {
+        fireInvalidationEvent(Collections.emptyList());
+    }
+    
+    /**
+     * Notifies all listeners/callbacks.
+     */
+    protected final void fireInvalidationEvent(List<String> resources)
     {
         if (callbacks == null)
         {
             return;
         }
-
-        for (Runnable callback : callbacks)
+        
+        do 
         {
-            callback.run();
+            Set<String> extraResources = new HashSet<>();
+            for (Function<List<String>, List<String>> callback : callbacks)
+            {
+                final List<String> newResources = callback.apply(resources);
+                if (newResources == null) {
+                    throw new TapestryException("InvalidationEventHub callback functions cannot return null", null);
+                }
+                extraResources.addAll(newResources);
+            }
+            resources = new ArrayList<>(extraResources);
         }
+        while (!resources.isEmpty());
     }
 
-    public final void addInvalidationCallback(Runnable callback)
+    public final void addInvalidationCallback(final Runnable callback)
     {
         assert callback != null;
 
@@ -64,7 +88,10 @@ public class InvalidationEventHubImpl implements InvalidationEventHub
         // ignore the callback.
         if (callbacks != null)
         {
-            callbacks.add(callback);
+            callbacks.add((r) -> {
+                callback.run();
+                return Collections.emptyList();
+            });
         }
     }
 
@@ -94,4 +121,9 @@ public class InvalidationEventHubImpl implements InvalidationEventHub
         });
     }
 
+    @Override
+    public void addInvalidationCallback(Function<List<String>, List<String>> callback) {
+        callbacks.add(callback);
+    }
+
 }
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ComponentClassResolverImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ComponentClassResolverImpl.java
index 8ba9c0a3d..f8c6d6df1 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ComponentClassResolverImpl.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ComponentClassResolverImpl.java
@@ -12,6 +12,14 @@
 
 package org.apache.tapestry5.internal.services;
 
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Formatter;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
 import org.apache.tapestry5.SymbolConstants;
 import org.apache.tapestry5.commons.services.InvalidationListener;
 import org.apache.tapestry5.commons.util.AvailableValues;
@@ -27,9 +35,6 @@ import org.apache.tapestry5.services.LibraryMapping;
 import org.apache.tapestry5.services.transform.ControlledPackageType;
 import org.slf4j.Logger;
 
-import java.util.*;
-import java.util.regex.Pattern;
-
 public class ComponentClassResolverImpl implements ComponentClassResolver, InvalidationListener
 {
     private static final String CORE_LIBRARY_PREFIX = "core/";
@@ -812,4 +817,28 @@ public class ComponentClassResolverImpl implements ComponentClassResolver, Inval
         return libraryMappings;
     }
 
+    @Override
+    public String getLogicalName(String className) 
+    {
+        String result = getData().pageClassNameToLogicalName.get(className);
+        if (result == null)
+        {
+            result = getKeyByValue(getData().componentToClassName, className);
+        }
+        else {
+            result = getKeyByValue(getData().mixinToClassName, className);
+        }
+
+        return result;
+    }
+    
+    private String getKeyByValue(Map<String, String> map, String value)
+    {
+        return map.entrySet().stream()
+                .filter(e -> e.getValue().equals(value))
+                .map(e -> e.getKey())
+                .findAny()
+                .orElse(null);
+    }
+
 }
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ComponentDependencyRegistry.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ComponentDependencyRegistry.java
new file mode 100644
index 000000000..d8e827c8a
--- /dev/null
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ComponentDependencyRegistry.java
@@ -0,0 +1,62 @@
+// Copyright 2022 The Apache Software Foundation
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.internal.services;
+
+import java.util.Set;
+
+import org.apache.tapestry5.commons.services.InvalidationEventHub;
+import org.apache.tapestry5.internal.structure.ComponentPageElement;
+
+
+/**
+ * Internal service that registers direct dependencies between components (including components, pages and
+ * base classes). Even though methods receive {@link ComponentPageElement} parameters, dependencies
+ * are tracked using their fully qualified classs names.
+ *
+ * @since 5.8.3
+ */
+public interface ComponentDependencyRegistry {
+    
+    /**
+     * Register all the dependencies of a given component.
+     */
+    void register(ComponentPageElement resources);
+    
+    /**
+     * Clears all dependency information for a given component.
+     */
+    void clear(String className);
+    
+    /**
+     * Clears all dependency information.
+     */
+    void clear();
+
+    /**
+     * Returns the fully qualified names of the direct dependencies of a given component.
+     */
+    Set<String> getDependents(String className);
+    
+    /**
+     * Returns the fully qualified names of the direct dependencies of a given component.
+     */
+    Set<String> getDependencies(String className);
+    
+    /**
+     * Signs up this registry to invalidation events from a given hub.
+     */
+    void listen(InvalidationEventHub invalidationEventHub);
+    
+}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ComponentDependencyRegistryImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ComponentDependencyRegistryImpl.java
new file mode 100644
index 000000000..8c4c7d0da
--- /dev/null
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ComponentDependencyRegistryImpl.java
@@ -0,0 +1,191 @@
+// Copyright 2022 The Apache Software Foundation
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package org.apache.tapestry5.internal.services;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.tapestry5.ComponentResources;
+import org.apache.tapestry5.commons.services.InvalidationEventHub;
+import org.apache.tapestry5.internal.structure.ComponentPageElement;
+import org.apache.tapestry5.model.ComponentModel;
+import org.apache.tapestry5.model.EmbeddedComponentModel;
+import org.apache.tapestry5.runtime.Component;
+
+
+public class ComponentDependencyRegistryImpl implements ComponentDependencyRegistry 
+{
+    
+    // Key is a component, values are the components that depend on it.
+    final private Map<String, Set<String>> map;
+    
+    // Cache to check which classes were already processed or not.
+    final private Set<String> alreadyProcessed;
+
+    public ComponentDependencyRegistryImpl()
+    {
+        map = new HashMap<>();
+        alreadyProcessed = new HashSet<>();
+    }
+
+    @Override
+    public void register(ComponentPageElement componentPageElement) 
+    {
+        final String componentClassName = getClassName(componentPageElement);
+
+        if (!alreadyProcessed.contains(componentClassName)) 
+        {
+            synchronized (map) 
+            {
+                
+                // Components in the tree (i.e. declared in the template
+                for (String id : componentPageElement.getEmbeddedElementIds()) 
+                {
+                    final ComponentPageElement child = componentPageElement.getEmbeddedElement(id);
+                    add(componentPageElement, child);
+                    register(child);
+                }
+                
+                // Mixins, class level
+                final ComponentResources componentResources = componentPageElement.getComponentResources();
+                final ComponentModel componentModel = componentResources.getComponentModel();
+                for (String mixinClassName : componentModel.getMixinClassNames()) 
+                {
+                    add(componentClassName, mixinClassName);
+                }
+                
+                // Mixins applied to embedded component instances
+                final List<String> embeddedComponentIds = componentModel.getEmbeddedComponentIds();
+                for (String id : embeddedComponentIds)
+                {
+                    final EmbeddedComponentModel embeddedComponentModel = componentResources
+                            .getComponentModel()
+                            .getEmbeddedComponentModel(id);
+                    final List<String> mixinClassNames = embeddedComponentModel
+                            .getMixinClassNames();
+                    for (String mixinClassName : mixinClassNames) {
+                        add(componentClassName, mixinClassName);
+                    }
+                }
+                
+                // Superclass
+                final Component component = componentPageElement.getComponent();
+                Class<?> parent = component.getClass().getSuperclass();
+                if (parent != null && !Object.class.equals(parent))
+                {
+                    add(componentClassName, parent.getName());
+                }
+                
+                alreadyProcessed.add(componentClassName);
+                
+            }            
+            
+        }
+        
+    }
+    
+    private String getClassName(ComponentPageElement component) 
+    {
+        return component.getComponentResources().getComponentModel().getComponentClassName();
+    }
+
+    @Override
+    public void clear(String className) 
+    {
+        synchronized (map) 
+        {
+            alreadyProcessed.remove(className);
+            map.put(className, null);
+            final Collection<Set<String>> allDependentSets = map.values();
+            for (Set<String> dependents : allDependentSets) 
+            {
+                if (dependents != null) 
+                {
+                    dependents.remove(className);
+                }
+            }
+        }
+    }
+
+    @Override
+    public void clear() {
+        map.clear();
+        alreadyProcessed.clear();
+    }
+
+    @Override
+    public Set<String> getDependents(String className) 
+    {
+        return map.get(className);
+    }
+
+    @Override
+    public Set<String> getDependencies(String className) 
+    {
+        return map.entrySet().stream()
+                .filter(e -> e.getValue().contains(className))
+                .map(e -> e.getKey())
+                .collect(Collectors.toSet());
+    }
+
+    private void add(ComponentPageElement component, ComponentPageElement dependency) 
+    {
+        add(getClassName(component), getClassName(dependency));
+    }
+    
+    private void add(String component, String dependency) 
+    {
+        synchronized (map) 
+        {
+            Set<String> dependents = map.get(dependency);
+            if (dependents == null) 
+            {
+                dependents = new HashSet<>();
+                map.put(dependency, dependents);
+            }
+            dependents.add(component);
+        }
+    }
+
+    @Override
+    public void listen(InvalidationEventHub invalidationEventHub) 
+    {
+        invalidationEventHub.addInvalidationCallback(this::listen);
+    }
+    
+    private List<String> listen(List<String> resources)
+    {
+        List<String> furtherDependents = new ArrayList<>();
+        for (String resource : resources) 
+        {
+            final Set<String> dependents = map.get(resource);
+            for (String furtherDependent : dependents) 
+            {
+                if (!resources.contains(furtherDependent) && !furtherDependents.contains(furtherDependent))
+                {
+                    furtherDependents.add(furtherDependent);
+                }
+            }
+            clear(resource);
+        }
+        return furtherDependents;
+    }
+    
+}
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/PageSourceImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/PageSourceImpl.java
index cc15c1658..e9db70ad9 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/PageSourceImpl.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/PageSourceImpl.java
@@ -37,6 +37,8 @@ public class PageSourceImpl implements PageSource
 
     private final PageLoader pageLoader;
 
+    private final ComponentDependencyRegistry componentDependencyRegistry;
+
     private static final class CachedPageKey
     {
         final String pageName;
@@ -70,10 +72,12 @@ public class PageSourceImpl implements PageSource
 
     private final Map<CachedPageKey, SoftReference<Page>> pageCache = CollectionFactory.newConcurrentMap();
 
-    public PageSourceImpl(PageLoader pageLoader, ComponentRequestSelectorAnalyzer selectorAnalyzer)
+    public PageSourceImpl(PageLoader pageLoader, ComponentRequestSelectorAnalyzer selectorAnalyzer,
+            ComponentDependencyRegistry componentDependencyRegistry)
     {
         this.pageLoader = pageLoader;
         this.selectorAnalyzer = selectorAnalyzer;
+        this.componentDependencyRegistry = componentDependencyRegistry;
     }
 
     public Page getPage(String canonicalPageName)
@@ -106,6 +110,8 @@ public class PageSourceImpl implements PageSource
             ref = new SoftReference<Page>(page);
 
             pageCache.put(key, ref);
+            
+            componentDependencyRegistry.register(page.getRootElement());
         }
     }
 
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ResourceDigestManagerImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ResourceDigestManagerImpl.java
index 11aa54c3d..08ca9c066 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ResourceDigestManagerImpl.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ResourceDigestManagerImpl.java
@@ -17,7 +17,9 @@ package org.apache.tapestry5.internal.services;
 import org.apache.tapestry5.commons.Resource;
 import org.apache.tapestry5.commons.services.InvalidationListener;
 
+import java.util.List;
 import java.util.Map;
+import java.util.function.Function;
 
 public class ResourceDigestManagerImpl implements ResourceDigestManager
 {
@@ -42,4 +44,9 @@ public class ResourceDigestManagerImpl implements ResourceDigestManager
     public void clearOnInvalidation(Map<?, ?> map)
     {
     }
+
+    @Override
+    public void addInvalidationCallback(Function<List<String>, List<String>> function) 
+    {
+    }
 }
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/structure/ComponentPageElement.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/structure/ComponentPageElement.java
index 0152e401e..1e17fe90b 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/structure/ComponentPageElement.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/structure/ComponentPageElement.java
@@ -12,6 +12,8 @@
 
 package org.apache.tapestry5.internal.structure;
 
+import java.util.Set;
+
 import org.apache.tapestry5.Binding;
 import org.apache.tapestry5.Block;
 import org.apache.tapestry5.ComponentResources;
@@ -20,6 +22,7 @@ import org.apache.tapestry5.commons.Location;
 import org.apache.tapestry5.internal.InternalComponentResources;
 import org.apache.tapestry5.internal.InternalComponentResourcesCommon;
 import org.apache.tapestry5.internal.services.Instantiator;
+import org.apache.tapestry5.ioc.annotations.IncompatibleChange;
 import org.apache.tapestry5.runtime.Component;
 import org.apache.tapestry5.runtime.ComponentEvent;
 import org.apache.tapestry5.runtime.RenderCommand;
@@ -102,6 +105,13 @@ public interface ComponentPageElement extends ComponentResourcesCommon, Internal
      *         if no component exists with the given id
      */
     ComponentPageElement getEmbeddedElement(String id);
+    
+    /**
+     * Returns the ids of all embedded elements defined within the component.
+     * @since 5.8.3
+     */
+    @IncompatibleChange(release = "5.8.3", details = "Added method")
+    Set<String> getEmbeddedElementIds();
 
     /**
      * Returns the {@link org.apache.tapestry5.ComponentResources} for a mixin attached to this component element. Mixin
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/structure/ComponentPageElementImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/structure/ComponentPageElementImpl.java
index 151b5c8af..36fd6bc6e 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/structure/ComponentPageElementImpl.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/structure/ComponentPageElementImpl.java
@@ -842,15 +842,7 @@ public class ComponentPageElementImpl extends BaseLocatable implements Component
 
         if (embeddedElement == null)
         {
-            Set<String> ids = CollectionFactory.newSet();
-
-            if (children != null)
-            {
-                for (ComponentPageElement child : children)
-                {
-                    ids.add(child.getId());
-                }
-            }
+            Set<String> ids = getEmbeddedElementIds();
 
             throw new UnknownValueException(String.format("Component %s does not contain embedded component '%s'.",
                     getCompleteId(), embeddedId), new AvailableValues("Embedded components", ids));
@@ -859,6 +851,20 @@ public class ComponentPageElementImpl extends BaseLocatable implements Component
         return embeddedElement;
     }
 
+    @Override
+    public Set<String> getEmbeddedElementIds() {
+        Set<String> ids = CollectionFactory.newSet();
+
+        if (children != null)
+        {
+            for (ComponentPageElement child : children)
+            {
+                ids.add(child.getId());
+            }
+        }
+        return ids;
+    }
+
     public String getId()
     {
         return id;
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/modules/TapestryModule.java b/tapestry-core/src/main/java/org/apache/tapestry5/modules/TapestryModule.java
index 34b9bec4c..f47bc52b6 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/modules/TapestryModule.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/modules/TapestryModule.java
@@ -163,6 +163,7 @@ import org.apache.tapestry5.internal.services.*;
 import org.apache.tapestry5.internal.services.ajax.AjaxFormUpdateFilter;
 import org.apache.tapestry5.internal.services.ajax.AjaxResponseRendererImpl;
 import org.apache.tapestry5.internal.services.ajax.MultiZoneUpdateEventResultProcessor;
+import org.apache.tapestry5.internal.services.assets.ResourceChangeTracker;
 import org.apache.tapestry5.internal.services.exceptions.ExceptionReportWriterImpl;
 import org.apache.tapestry5.internal.services.exceptions.ExceptionReporterImpl;
 import org.apache.tapestry5.internal.services.linktransform.LinkTransformerImpl;
@@ -2766,6 +2767,18 @@ public final class TapestryModule
     {
         configuration.add(appRootPackage + ".rest.entities");
     }
+    
+    public static ComponentDependencyRegistry buildComponentDependencyRegistry(
+            InternalComponentInvalidationEventHub internalComponentInvalidationEventHub,
+            ResourceChangeTracker resourceChangeTracker,
+            ComponentTemplateSource componentTemplateSource)
+    {
+        ComponentDependencyRegistry componentDependencyRegistry = new ComponentDependencyRegistryImpl();
+        componentDependencyRegistry.listen(internalComponentInvalidationEventHub);
+        componentDependencyRegistry.listen(resourceChangeTracker);
+        componentDependencyRegistry.listen(componentTemplateSource.getInvalidationEventHub());
+        return componentDependencyRegistry;
+    }
 
     private static final class TapestryCoreComponentLibraryInfoSource implements
             ComponentLibraryInfoSource
diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/services/ComponentClassResolver.java b/tapestry-core/src/main/java/org/apache/tapestry5/services/ComponentClassResolver.java
index e84d175e9..e94489a5e 100644
--- a/tapestry-core/src/main/java/org/apache/tapestry5/services/ComponentClassResolver.java
+++ b/tapestry-core/src/main/java/org/apache/tapestry5/services/ComponentClassResolver.java
@@ -179,9 +179,13 @@ public interface ComponentClassResolver
     
     /**
      * Returns the library mappings.
-     * @return
      */
     @IncompatibleChange(release = "5.4", details = "Added method")
     Collection<LibraryMapping> getLibraryMappings();
     
+    /**
+     * Returns the logical name for a page, component or mixin fully classified class name.
+     */
+    @IncompatibleChange(release = "5.8.3", details = "Added method")
+    public String getLogicalName(String className);
 }
diff --git a/tapestry-core/src/main/resources/org/apache/tapestry5/corelib/pages/PageCatalog.tml b/tapestry-core/src/main/resources/org/apache/tapestry5/corelib/pages/PageCatalog.tml
index 5e7c7a2f3..0424beab1 100644
--- a/tapestry-core/src/main/resources/org/apache/tapestry5/corelib/pages/PageCatalog.tml
+++ b/tapestry-core/src/main/resources/org/apache/tapestry5/corelib/pages/PageCatalog.tml
@@ -12,6 +12,11 @@
         <t:beandisplay t:id="totals"/>
 
         <t:grid source="pages" row="page" model="model">
+            <p:componentCountCell>
+                ${page.stats.componentCount} 
+                <a href="#" t:type="EventLink" t:event="pageStructure" t:zone="pageStructureZone"
+                	t:context="page.name">Structure info</a>
+            </p:componentCountCell>
             <p:assemblyTimeCell>
                 ${formatElapsed(page.stats.assemblyTime)}
             </p:assemblyTimeCell>
@@ -37,7 +42,28 @@
         </t:if>
         <t:actionlink t:id="runGC" zone="pages" class="btn btn-default">Run the GC</t:actionlink>
     </div>
-
+    
+    
+    <t:zone t:id="pageStructureZone">
+	    <div class="panel panel-default vert-offset" t:type="If" t:test="selectedPage">
+	        <div class="panel-heading">Component dependency information for ${selectedPage.name} (just direct dependencies)</div>
+	        <div class="panel-body">
+	        	<ul>
+	        		<li t:type="Loop" t:value="dependency" t:source="dependencies">
+	        			${displayLogicalName} (${dependency})
+	        		</li>
+	        	</ul>
+	        </div>
+	    </div>
+	    <div class="panel panel-default vert-offset" t:type="If" t:test="selectedPage">
+	        <div class="panel-heading">${selectedPage.name}'s component tree</div>
+	        <div class="panel-body">
+	        	<ul>
+	        		<t:trigger t:event="componentTree"/>
+	        	</ul>
+	        </div>
+	    </div>
+	</t:zone>	   
 
     <div class="panel panel-default vert-offset">
         <div class="panel-heading">Load single page</div>
diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/internal/event/InvalidationEventHubImplTest.java b/tapestry-core/src/test/java/org/apache/tapestry5/internal/event/InvalidationEventHubImplTest.java
new file mode 100644
index 000000000..6cf69d58a
--- /dev/null
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/internal/event/InvalidationEventHubImplTest.java
@@ -0,0 +1,71 @@
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package org.apache.tapestry5.internal.event;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+
+import org.apache.tapestry5.commons.internal.util.TapestryException;
+import org.apache.tapestry5.internal.services.ComponentTemplateSourceImplTest;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+/**
+ * Tests the parts of {@link InvalidationEventHubImpl} that {@link ComponentTemplateSourceImplTest}
+ * doesn't. This is mostly for the resource-specific invalidations in
+ * {@link InvalidationEventHubImpl#addInvalidationCallback(java.util.function.Function)}
+ */
+public class InvalidationEventHubImplTest 
+{
+
+    /**
+     * Tests {@link InvalidationEventHubImpl#addInvalidationCallback(java.util.function.Function)}.
+     */
+    @Test
+    public void add_invalidation_callback_with_parameter() 
+    {
+        InvalidationEventHubImpl invalidationEventHub = new InvalidationEventHubImpl(false);
+        final String firstInitialElement = "a";
+        final String secondInitialElement = "b";
+        final List<String> initialResources = Arrays.asList(firstInitialElement, secondInitialElement);
+        final AtomicInteger callCount = new AtomicInteger(0);
+        Function<List<String>, List<String>> callback = (r) -> {
+            callCount.incrementAndGet();
+            if (r.size() == 2 && r.get(0).equals(firstInitialElement) && r.get(1).equals(secondInitialElement)) {
+                return Arrays.asList(firstInitialElement.toUpperCase(), secondInitialElement.toUpperCase());
+            }
+            else if (r.size() == 2 && r.get(0).equals(firstInitialElement.toUpperCase()) && r.get(1).equals(secondInitialElement.toUpperCase())) {
+                return Arrays.asList("something", "else");
+            }
+            else {
+                return Collections.emptyList();
+            }
+        };
+        
+        invalidationEventHub.addInvalidationCallback(callback);
+        invalidationEventHub.fireInvalidationEvent(initialResources);
+        Assert.assertEquals(callCount.get(), 3, "Wrong call count");
+        
+    }
+    
+    @Test(expectedExceptions = TapestryException.class)
+    public void null_check_for_callback_method() 
+    {
+        InvalidationEventHubImpl invalidationEventHub = new InvalidationEventHubImpl(false);
+        invalidationEventHub.addInvalidationCallback((s) -> null);
+        invalidationEventHub.fireInvalidationEvent();
+    }
+    
+}
diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/ComponentDependencyRegistryImplTest.java b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/ComponentDependencyRegistryImplTest.java
new file mode 100644
index 000000000..794ffd7dc
--- /dev/null
+++ b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/ComponentDependencyRegistryImplTest.java
@@ -0,0 +1,41 @@
+// Copyright 2022 The Apache Software Foundation
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package org.apache.tapestry5.internal.services;
+
+import org.apache.tapestry5.internal.test.InternalBaseTestCase;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * Tests for the bean editor model source itself, as well as the model classes.
+ */
+public abstract class ComponentDependencyRegistryImplTest extends InternalBaseTestCase
+{
+    
+    private ComponentDependencyRegistry componentDependencyRegistry;
+    
+    @BeforeClass
+    public void setup()
+    {
+        componentDependencyRegistry = new ComponentDependencyRegistryImpl();
+    }
+    
+    @Test
+    public void register()
+    {
+        componentDependencyRegistry.register(null);
+    }
+    
+}