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:58 UTC

[tapestry-5] branch better-page-invalidation created (now 8f9b33380)

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

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


      at 8f9b33380 TAP5-2742: Initial work on smarter page cache invalidation

This branch includes the following new commits:

     new 8f9b33380 TAP5-2742: Initial work on smarter page cache invalidation

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



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

Posted by th...@apache.org.
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);
+    }
+    
+}