Merge branch 'stable-3.3' into stable-3.4

* stable-3.3: (95 commits)
  Make change number a hyperlink
  Add load durations to HitHashMap uses
  Add properties stats with durations to nodes and query stats
  COW List and Map Property expansions
  Use custom Matcher to find and replace properties
  Use expanded task if refreshing is not required
  Split Properties file into properties package
  Cache MatchCache results for all changes
  Cache nameFactory changes in TaskTree
  Cache applicable definitionLists for ChangeNodes
  Add more stats and refactor to handle more
  Use HitHashMap for PreloaderCache
  Use HitHashMap in PredicateCache
  Add TaskTree Caching stats to query output
  Add node level statistics to query outputs
  Add Preloader statistics to query outputs
  Add PredicateCache statistics to query outputs
  Add a --task--include-statistics flag
  zuul: ensure python3-distutils is present
  Use shallow clone while expanding definitions
  ...

Change-Id: Iccddc779e51f57d59dd721c39b776be446521b63
diff --git a/.zuul.yaml b/.zuul.yaml
index 27c8491..52c12ce 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -2,7 +2,8 @@
     name: plugins-task-build
     parent: gerrit-plugin-build
     pre-run:
-        tools/playbooks/install_docker.yaml
+        - tools/playbooks/install_docker.yaml
+        - tools/playbooks/install_python3-distutils.yaml
     vars:
         bazelisk_test_targets: "plugins/task/lint_test plugins/task/..."
 
diff --git a/BUILD b/BUILD
index 0b04753..06297ac 100644
--- a/BUILD
+++ b/BUILD
@@ -1,8 +1,34 @@
-load("//tools/bzl:plugin.bzl", "gerrit_plugin")
+load(
+    "//tools/bzl:plugin.bzl",
+    "PLUGIN_DEPS",
+    "PLUGIN_TEST_DEPS",
+    "gerrit_plugin",
+)
 load("//tools/bzl:js.bzl", "gerrit_js_bundle")
 load("//tools/js:eslint.bzl", "eslint")
+load("//tools/bzl:junit.bzl", "junit_tests")
+load("@rules_java//java:defs.bzl", "java_library", "java_plugin")
+
 plugin_name = "task"
 
+java_plugin(
+    name = "auto-value-plugin",
+    processor_class = "com.google.auto.value.processor.AutoValueProcessor",
+    deps = [
+        "@auto-value-annotations//jar",
+        "@auto-value//jar",
+    ],
+)
+
+java_library(
+    name = "auto-value",
+    exported_plugins = [
+        ":auto-value-plugin",
+    ],
+    visibility = ["//visibility:public"],
+    exports = ["@auto-value//jar"],
+)
+
 gerrit_plugin(
     name = plugin_name,
     srcs = glob(["src/main/java/**/*.java"]),
@@ -14,6 +40,7 @@
     ],
     resource_jars = [":gr-task-plugin"],
     resources = glob(["src/main/resources/**/*"]),
+    deps = [":auto-value"],
     javacopts = [ "-Werror", "-Xlint:all", "-Xlint:-classfile", "-Xlint:-processing"],
 )
 
@@ -23,6 +50,13 @@
     entry_point = "gr-task-plugin/plugin.js",
 )
 
+junit_tests(
+    name = "junit-tests",
+    size = "small",
+    srcs = glob(["src/test/java/**/*Test.java"]),
+    deps = PLUGIN_TEST_DEPS + PLUGIN_DEPS + [plugin_name],
+)
+
 sh_test(
     name = "docker-tests",
     size = "medium",
diff --git a/WORKSPACE b/WORKSPACE
index c217ecf..68aaea5 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -29,10 +29,27 @@
     yarn_lock = "//:yarn.lock",
 )
 
+load("//tools/bzl:maven_jar.bzl", "maven_jar")
+
+AUTO_VALUE_VERSION = "1.7.4"
+
+maven_jar(
+    name = "auto-value",
+    artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
+    sha1 = "6b126cb218af768339e4d6e95a9b0ae41f74e73d",
+)
+
+maven_jar(
+    name = "auto-value-annotations",
+    artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
+    sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
+)
+
 # Load plugin API
 load(
     "@com_googlesource_gerrit_bazlets//:gerrit_api.bzl",
     "gerrit_api",
 )
 
+# Release Plugin API
 gerrit_api()
diff --git a/gr-task-plugin/gr-task-plugin-tasks.js b/gr-task-plugin/gr-task-plugin-tasks.js
index 54585d1..0f89fb6 100644
--- a/gr-task-plugin/gr-task-plugin-tasks.js
+++ b/gr-task-plugin/gr-task-plugin-tasks.js
@@ -44,6 +44,10 @@
   _can_show(show, task) {
     return show === 'true' || task.showOnFilter;
   }
+
+  _getChangeUrl(change) {
+    return Gerrit.url(change.toString());
+  }
 }
 
 customElements.define(GrTaskPluginTasks.is, GrTaskPluginTasks);
diff --git a/gr-task-plugin/gr-task-plugin-tasks_html.js b/gr-task-plugin/gr-task-plugin-tasks_html.js
index df1644f..9122a9d 100644
--- a/gr-task-plugin/gr-task-plugin-tasks_html.js
+++ b/gr-task-plugin/gr-task-plugin-tasks_html.js
@@ -47,7 +47,15 @@
             </iron-icon>
         </gr-tooltip-content>
       </template>
-      [[task.message]]
+      <template is="dom-if" if="[[task.change]]">
+        <a class="links" href$="[[_getChangeUrl(task.change)]]">[[task.change]]</a>
+      </template>
+      <template is="dom-if" if="[[!task.change]]">
+        <template is="dom-if" if="[[!task.hint]]">
+          [[task.name]]
+        </template>
+      </template>
+      <template is="dom-if" if="[[task.hint]]">[[task.hint]]</template>
     </li>
   </template>
   <ul style="list-style-type:none; margin: 0 0 0 0; padding: 0 0 0 2em;">
diff --git a/gr-task-plugin/gr-task-plugin.js b/gr-task-plugin/gr-task-plugin.js
index c665d61..01d22e3 100644
--- a/gr-task-plugin/gr-task-plugin.js
+++ b/gr-task-plugin/gr-task-plugin.js
@@ -22,10 +22,10 @@
 const Defs = {};
 /**
  * @typedef {{
- *  message: string,
  *  sub_tasks: Array<Defs.Task>,
  *  hint: ?string,
  *  name: string,
+ *  change: ?number,
  *  status: string
  * }} Defs.Task
  */
@@ -127,7 +127,7 @@
         icon.tooltip = 'Failed';
         break;
       case 'READY':
-        icon.id = 'gr-icons:rebase';
+        icon.id = 'gr-icons:playArrow';
         icon.color = 'green';
         icon.tooltip = 'Ready';
         break;
@@ -137,13 +137,18 @@
         icon.tooltip = 'Invalid';
         break;
       case 'WAITING':
-        icon.id = 'gr-icons:side-by-side';
+        icon.id = 'gr-icons:pause';
         icon.color = 'red';
         icon.tooltip = 'Waiting';
         break;
-      case 'PASS':
+      case 'DUPLICATE':
         icon.id = 'gr-icons:check';
         icon.color = 'green';
+        icon.tooltip = 'Duplicate';
+        break;
+      case 'PASS':
+        icon.id = 'gr-icons:check-circle';
+        icon.color = 'green';
         icon.tooltip = 'Passed';
         break;
     }
@@ -181,7 +186,6 @@
   _addTasks(tasks) { // rename to process, remove DOM bits
     if (!tasks) return [];
     tasks.forEach(task => {
-      task.message = task.hint || task.name;
       task.icon = this._computeIcon(task);
       task.showOnFilter = this._computeShowOnNeedsAndBlockedFilter(task);
       this._compute_counts(task);
diff --git a/src/main/java/com/google/gerrit/common/BooleanTable.java b/src/main/java/com/google/gerrit/common/BooleanTable.java
new file mode 100644
index 0000000..9e89eb9
--- /dev/null
+++ b/src/main/java/com/google/gerrit/common/BooleanTable.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 com.google.gerrit.common;
+
+import java.util.BitSet;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A space efficient Table for Booleans. This Table takes advantage of the fact that the values
+ * stored in it are all Booleans and uses BitSets to make this very space efficient.
+ */
+public class BooleanTable<R, C> {
+  protected class Row {
+    public final BitSet hasValues = new BitSet();
+    public final BitSet values = new BitSet();
+
+    public void setPosition(int position, Boolean value) {
+      if (value != null) {
+        values.set(position, value);
+      }
+      hasValues.set(position, value != null);
+    }
+
+    public Boolean getPosition(int position) {
+      if (hasValues.get(position)) {
+        return values.get(position);
+      }
+      return null;
+    }
+  }
+
+  protected Map<R, Row> rowByRow = new HashMap<>();
+  protected Map<C, Integer> positionByColumn = new HashMap<>();
+  protected int highestPosition = -1;
+
+  public void put(R r, C c, Boolean v) {
+    Row row = rowByRow.computeIfAbsent(r, k -> new Row());
+    Integer columnPosition = positionByColumn.computeIfAbsent(c, k -> nextPosition());
+    row.setPosition(columnPosition, v);
+  }
+
+  protected int nextPosition() {
+    return ++highestPosition;
+  }
+
+  public Boolean get(R r, C c) {
+    Row row = rowByRow.get(r);
+    if (row != null) {
+      Integer columnPosition = positionByColumn.get(c);
+      if (columnPosition != null) {
+        return row.getPosition(columnPosition);
+      }
+    }
+    return null;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/Copier.java b/src/main/java/com/googlesource/gerrit/plugins/task/Copier.java
new file mode 100644
index 0000000..10f4048
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/Copier.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task;
+
+import java.lang.reflect.Field;
+
+public class Copier {
+  protected static <T> void shallowCopyDeclaredFields(
+      Class<T> cls, T from, T to, boolean includeInaccessible) {
+    for (Field field : cls.getDeclaredFields()) {
+      try {
+        if (includeInaccessible) {
+          field.setAccessible(true);
+        }
+        Object val = field.get(from);
+        if (!field.getName().equals("this$0")) { // Can't copy internal final field
+          field.set(to, val);
+        }
+      } catch (IllegalAccessException e) {
+        if (includeInaccessible) {
+          throw new RuntimeException(
+              "Cannot access field to copy it " + fieldValueToString(field, "unknown"));
+        }
+      }
+    }
+  }
+
+  protected static String fieldValueToString(Field field, Object val) {
+    return "field:" + field.getName() + " value:" + val + " type:" + field.getType();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/FileKey.java b/src/main/java/com/googlesource/gerrit/plugins/task/FileKey.java
new file mode 100644
index 0000000..2d44757
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/FileKey.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.BranchNameKey;
+
+/** An immutable reference to a fully qualified file in gerrit repo. */
+@AutoValue
+public abstract class FileKey {
+  public static FileKey create(BranchNameKey branch, String file) {
+    return new AutoValue_FileKey(branch, file);
+  }
+
+  public abstract BranchNameKey branch();
+
+  public abstract String file();
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/HitBooleanTable.java b/src/main/java/com/googlesource/gerrit/plugins/task/HitBooleanTable.java
new file mode 100644
index 0000000..8271fb9
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/HitBooleanTable.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task;
+
+import com.google.gerrit.common.BooleanTable;
+
+/**
+ * A space efficient Table for Booleans. This Table takes advantage of the fact that the values
+ * stored in it are all Booleans and uses BitSets to make this very space efficient.
+ */
+public class HitBooleanTable<R, C> extends BooleanTable<R, C> implements TracksStatistics {
+  public static class Statistics {
+    public long hits;
+    public long misses;
+    public long size;
+    public int numberOfRows;
+    public int numberOfColumns;
+  }
+
+  protected Statistics statistics;
+
+  @Override
+  public Boolean get(R r, C c) {
+    Boolean value = super.get(r, c);
+    if (statistics != null) {
+      if (value != null) {
+        statistics.hits++;
+      } else {
+        statistics.misses++;
+      }
+    }
+    return value;
+  }
+
+  @Override
+  public void initStatistics() {
+    statistics = new Statistics();
+  }
+
+  @Override
+  public void ensureStatistics() {
+    if (statistics == null) {
+      initStatistics();
+    }
+  }
+
+  @Override
+  public Object getStatistics() {
+    statistics.numberOfRows = rowByRow.size();
+    statistics.numberOfColumns = positionByColumn.size();
+    statistics.size =
+        rowByRow.values().stream()
+            .map(r -> (long) r.hasValues.size() + (long) r.values.size())
+            .mapToLong(Long::longValue)
+            .sum();
+    return statistics;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/HitHashMap.java b/src/main/java/com/googlesource/gerrit/plugins/task/HitHashMap.java
new file mode 100644
index 0000000..453c118
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/HitHashMap.java
@@ -0,0 +1,181 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task;
+
+import static java.util.stream.Collectors.toList;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+public class HitHashMap<K, V> extends HashMap<K, V> implements StatisticsMap<K, V> {
+  public static class Statistics {
+    public long hits;
+    public int size;
+    public long sumNanosecondsLoading;
+    public List<Object> elements;
+
+    protected transient StopWatch loadingStopWatch;
+  }
+
+  public static final long serialVersionUID = 1;
+
+  protected Statistics statistics;
+
+  public HitHashMap() {}
+
+  public HitHashMap(boolean initStatistics) {
+    if (initStatistics) {
+      initStatistics();
+    }
+  }
+
+  @Override
+  public V get(Object key) {
+    V v = super.get(key);
+    if (statistics != null && v != null) {
+      statistics.hits++;
+    }
+    return v;
+  }
+
+  public V getOrStartLoad(K key) {
+    V v = get(key);
+    if (v == null) {
+      startLoad();
+    }
+    return v;
+  }
+
+  @Override
+  public V getOrDefault(Object key, V dv) {
+    V v = get(key);
+    if (v == null) {
+      return dv;
+    }
+    return v;
+  }
+
+  @Override
+  public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) {
+    V v = getOrStartLoad(key);
+    if (v == null) {
+      v = mappingFunction.apply(key);
+      if (v != null) {
+        put(key, v);
+      } else {
+        stopLoad(key);
+      }
+    }
+    return v;
+  }
+
+  @Override
+  public V put(K key, V value) {
+    stopLoad(key);
+    if (statistics != null && value instanceof TracksStatistics) {
+      ((TracksStatistics) value).ensureStatistics();
+    }
+    return super.put(key, value);
+  }
+
+  @Override
+  public void putAll(Map<? extends K, ? extends V> m) {
+    m.entrySet().stream().forEach(e -> put(e.getKey(), e.getValue()));
+  }
+
+  @Override
+  public V putIfAbsent(K key, V value) {
+    if (!containsKey(key)) {
+      put(key, value);
+      return null;
+    }
+    return get(key);
+  }
+
+  @Override
+  public V computeIfPresent(
+      K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction) {
+    throw new UnsupportedOperationException(); // Todo if needed
+  }
+
+  @Override
+  public V compute(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction) {
+    throw new UnsupportedOperationException(); // Todo if needed
+  }
+
+  @Override
+  public V merge(K key, V value, BiFunction<? super V, ? super V, ? extends V> remappingFunction) {
+    throw new UnsupportedOperationException(); // Todo if needed
+  }
+
+  @Override
+  public V replace(K key, V value) {
+    throw new UnsupportedOperationException(); // Todo if needed
+  }
+
+  @Override
+  public boolean replace(K key, V oldValue, V newValue) {
+    throw new UnsupportedOperationException(); // Todo if needed
+  }
+
+  @Override
+  public void replaceAll(BiFunction<? super K, ? super V, ? extends V> function) {
+    throw new UnsupportedOperationException(); // Todo if needed
+  }
+
+  public void startLoad() {
+    if (statistics != null && statistics.loadingStopWatch != null) {
+      statistics.loadingStopWatch.start();
+    }
+  }
+
+  public void stopLoad(K key) {
+    if (statistics != null && statistics.loadingStopWatch != null) {
+      statistics.loadingStopWatch.stop();
+    }
+  }
+
+  @Override
+  public void initStatistics() {
+    statistics = new Statistics();
+    statistics.loadingStopWatch =
+        new StopWatch().enable().setConsumer(ns -> statistics.sumNanosecondsLoading += ns);
+  }
+
+  @Override
+  public void ensureStatistics() {
+    if (statistics == null) {
+      initStatistics();
+    }
+  }
+
+  @Override
+  public Object getStatistics() {
+    statistics.size = size();
+    List<Object> elementStatistics =
+        values().stream()
+            .filter(e -> e instanceof TracksStatistics)
+            .map(e -> ((TracksStatistics) e).getStatistics())
+            .collect(toList());
+    if (!elementStatistics.isEmpty()) {
+      statistics.elements = elementStatistics;
+    }
+    statistics.loadingStopWatch = null;
+    return statistics;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/HitHashMapOfCollection.java b/src/main/java/com/googlesource/gerrit/plugins/task/HitHashMapOfCollection.java
new file mode 100644
index 0000000..422418b
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/HitHashMapOfCollection.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task;
+
+import static java.util.stream.Collectors.toList;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+
+public class HitHashMapOfCollection<K, V extends Collection<?>> extends HitHashMap<K, V> {
+  public static class Statistics extends HitHashMap.Statistics {
+    public List<Integer> top5CollectionSizes;
+    public List<Integer> bottom5CollectionSizes;
+  }
+
+  public static final long serialVersionUID = 1;
+
+  protected Statistics statistics;
+
+  public HitHashMapOfCollection() {}
+
+  public HitHashMapOfCollection(boolean initStatistics) {
+    if (initStatistics) {
+      initStatistics();
+    }
+  }
+
+  @Override
+  public void initStatistics() {
+    super.initStatistics();
+    statistics = new Statistics();
+  }
+
+  @Override
+  public Object getStatistics() {
+    super.getStatistics();
+    statistics.hits = super.statistics.hits;
+    statistics.size = super.statistics.size;
+
+    List<Integer> collectionSizes =
+        values().stream().map(l -> l.size()).sorted(Comparator.reverseOrder()).collect(toList());
+    statistics.top5CollectionSizes = new ArrayList<>(5);
+    statistics.bottom5CollectionSizes = new ArrayList<>(5);
+    for (int i = 0; i < 5 && i < collectionSizes.size(); i++) {
+      statistics.top5CollectionSizes.add(collectionSizes.get(i));
+      int bottom = collectionSizes.size() - 6 + i;
+      if (bottom > 4 && bottom < collectionSizes.size()) {
+        // The > 4 ensures that there are no entries also in the top list
+        statistics.bottom5CollectionSizes.add(collectionSizes.get(bottom));
+      }
+    }
+    if (statistics.bottom5CollectionSizes.isEmpty()) {
+      statistics.bottom5CollectionSizes = null;
+    }
+    return statistics;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/MatchCache.java b/src/main/java/com/googlesource/gerrit/plugins/task/MatchCache.java
index 45fe46d..7bdb95d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/MatchCache.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/MatchCache.java
@@ -14,44 +14,53 @@
 
 package com.googlesource.gerrit.plugins.task;
 
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
-import java.util.HashMap;
-import java.util.Map;
 
 public class MatchCache {
+  protected final HitBooleanTable<String, Change.Id> resultByChangeByQuery =
+      new HitBooleanTable<>();
   protected final PredicateCache predicateCache;
-  protected final ChangeData changeData;
 
-  protected final Map<String, Boolean> matchResultByQuery = new HashMap<>();
-
-  public MatchCache(PredicateCache predicateCache, ChangeData changeData) {
+  public MatchCache(PredicateCache predicateCache) {
     this.predicateCache = predicateCache;
-    this.changeData = changeData;
   }
 
-  protected boolean match(String query) throws StorageException, QueryParseException {
+  public boolean match(ChangeData changeData, String query)
+      throws StorageException, QueryParseException {
     if (query == null) {
       return true;
     }
-    Boolean isMatched = matchResultByQuery.get(query);
+    Boolean isMatched = resultByChangeByQuery.get(query, changeData.getId());
     if (isMatched == null) {
-      isMatched = predicateCache.match(changeData, query);
-      matchResultByQuery.put(query, isMatched);
+      isMatched = predicateCache.matchWithExceptions(changeData, query);
+      resultByChangeByQuery.put(query, changeData.getId(), isMatched);
     }
     return isMatched;
   }
 
-  protected Boolean matchOrNull(String query) {
+  public Boolean matchOrNull(ChangeData changeData, String query) {
     if (query == null) {
       return null;
     }
-    Boolean isMatched = matchResultByQuery.get(query);
+    Boolean isMatched = resultByChangeByQuery.get(query, changeData.getId());
     if (isMatched == null) {
-      isMatched = predicateCache.matchOrNull(changeData, query);
-      matchResultByQuery.put(query, isMatched);
+      try {
+        isMatched = predicateCache.matchWithExceptions(changeData, query);
+      } catch (QueryParseException | RuntimeException e) {
+      }
+      resultByChangeByQuery.put(query, changeData.getId(), isMatched);
     }
     return isMatched;
   }
+
+  public void initStatistics() {
+    resultByChangeByQuery.initStatistics();
+  }
+
+  public Object getStatistics() {
+    return resultByChangeByQuery.getStatistics();
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/Modules.java b/src/main/java/com/googlesource/gerrit/plugins/task/Modules.java
index 3a8d903..6f8d5cd 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/Modules.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/Modules.java
@@ -61,6 +61,9 @@
     @Option(name = "--evaluation-time", usage = "Include elapsed evaluation time on each task")
     public boolean evaluationTime = false;
 
+    @Option(name = "--include-statistics", usage = "Include statistcs about the task evaluations")
+    public boolean includeStatistics = false;
+
     @Option(
         name = "--preview",
         metaVar = "{CHANGE,PATCHSET}",
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/PredicateCache.java b/src/main/java/com/googlesource/gerrit/plugins/task/PredicateCache.java
index 7896417..4c3748d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/PredicateCache.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/PredicateCache.java
@@ -15,56 +15,81 @@
 package com.googlesource.gerrit.plugins.task;
 
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.index.query.AndPredicate;
+import com.google.gerrit.index.query.NotPredicate;
+import com.google.gerrit.index.query.OrPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.DestinationPredicate;
+import com.google.gerrit.server.query.change.ProjectPredicate;
+import com.google.gerrit.server.query.change.RefPredicate;
+import com.google.gerrit.server.query.change.RegexProjectPredicate;
+import com.google.gerrit.server.query.change.RegexRefPredicate;
 import com.google.inject.Inject;
-import java.util.HashMap;
-import java.util.Map;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
 
 public class PredicateCache {
-  protected final ChangeQueryBuilder cqb;
-  protected final CurrentUser user;
+  public static class Statistics {
+    protected Object predicatesByQueryCache;
+    protected long numberOfMatches;
+  }
 
-  protected final Map<String, ThrowingProvider<Predicate<ChangeData>, QueryParseException>>
-      predicatesByQuery = new HashMap<>();
+  protected final ChangeQueryBuilder cqb;
+  protected final Set<String> cacheableByBranchPredicateClassNames;
+  protected final CurrentUser user;
+  protected final HitHashMap<String, ThrowingProvider<Predicate<ChangeData>, QueryParseException>>
+      predicatesByQuery = new HitHashMap<>();
+
+  protected Statistics statistics;
 
   @Inject
-  public PredicateCache(CurrentUser user, ChangeQueryBuilder cqb) {
+  public PredicateCache(
+      @GerritServerConfig Config config,
+      @PluginName String pluginName,
+      CurrentUser user,
+      ChangeQueryBuilder cqb) {
     this.user = user;
     this.cqb = cqb;
+    cacheableByBranchPredicateClassNames =
+        new HashSet<>(
+            Arrays.asList(
+                config.getStringList(pluginName, "cacheable-predicates", "byBranch-className")));
   }
 
-  public boolean match(ChangeData c, String query) throws StorageException, QueryParseException {
-    if (query == null) {
-      return true;
+  public void initStatistics() {
+    statistics = new Statistics();
+    predicatesByQuery.initStatistics();
+  }
+
+  public Object getStatistics() {
+    if (statistics != null) {
+      statistics.predicatesByQueryCache = predicatesByQuery.getStatistics();
     }
-    return matchWithExceptions(c, query);
+    return statistics;
   }
 
-  public Boolean matchOrNull(ChangeData c, String query) {
-    if (query != null) {
-      try {
-        return matchWithExceptions(c, query);
-      } catch (QueryParseException | RuntimeException e) {
-      }
-    }
-    return null;
-  }
-
-  protected boolean matchWithExceptions(ChangeData c, String query)
+  public boolean matchWithExceptions(ChangeData c, String query)
       throws QueryParseException, StorageException {
     if ("true".equalsIgnoreCase(query)) {
       return true;
     }
+    if (statistics != null) {
+      statistics.numberOfMatches++;
+    }
     return getPredicate(query).asMatchable().match(c);
   }
 
   protected Predicate<ChangeData> getPredicate(String query) throws QueryParseException {
     ThrowingProvider<Predicate<ChangeData>, QueryParseException> predProvider =
-        predicatesByQuery.get(query);
+        predicatesByQuery.getOrStartLoad(query);
     if (predProvider != null) {
       return predProvider.get();
     }
@@ -78,4 +103,39 @@
       throw e;
     }
   }
+
+  /**
+   * Can this query's output be assumed to be constant given any Change destined for the same
+   * Branch.NameKey?
+   */
+  public boolean isCacheableByBranch(String query) throws QueryParseException {
+    if (query == null
+        || "".equals(query)
+        || "false".equalsIgnoreCase(query)
+        || "true".equalsIgnoreCase(query)) {
+      return true;
+    }
+    return isCacheableByBranch(getPredicate(query));
+  }
+
+  protected boolean isCacheableByBranch(Predicate<ChangeData> predicate) {
+    if (predicate instanceof AndPredicate
+        || predicate instanceof NotPredicate
+        || predicate instanceof OrPredicate) {
+      for (Predicate<ChangeData> subPred : predicate.getChildren()) {
+        if (!isCacheableByBranch(subPred)) {
+          return false;
+        }
+      }
+      return true;
+    }
+    if (predicate instanceof DestinationPredicate
+        || predicate instanceof ProjectPredicate
+        || predicate instanceof RefPredicate
+        || predicate instanceof RegexProjectPredicate
+        || predicate instanceof RegexRefPredicate) {
+      return true;
+    }
+    return cacheableByBranchPredicateClassNames.contains(predicate.getClass().getName());
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/Preloader.java b/src/main/java/com/googlesource/gerrit/plugins/task/Preloader.java
index 8babe1c..6721c86 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/Preloader.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/Preloader.java
@@ -14,68 +14,174 @@
 
 package com.googlesource.gerrit.plugins.task;
 
+import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.task.TaskConfig.Task;
+import com.googlesource.gerrit.plugins.task.cli.PatchSetArgument;
+import java.io.IOException;
 import java.lang.reflect.Field;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** Use to pre-load a task definition with values from its preload-task definition. */
 public class Preloader {
-  public static void preload(Task definition) throws ConfigInvalidException {
-    String name = definition.preloadTask;
-    if (name != null) {
-      Task task = definition.config.getTaskOptional(name);
-      if (task != null) {
-        preload(task);
-        preloadFrom(definition, task);
-      }
-    }
+  public static class Statistics {
+    protected Object optionalTaskByExpressionCache;
+    protected long loaded;
+    protected long preloaded;
+    protected long preloadedFromDefinition;
   }
 
-  protected static void preloadFrom(Task definition, Task preloadFrom) {
+  protected final TaskConfigFactory taskConfigFactory;
+  protected final StatisticsMap<TaskExpressionKey, Optional<Task>> optionalTaskByExpression =
+      new HitHashMap<>();
+
+  protected Statistics statistics;
+
+  @Inject
+  public Preloader(TaskConfigFactory taskConfigFactory) {
+    this.taskConfigFactory = taskConfigFactory;
+  }
+
+  public List<Task> getRootTasks() throws IOException, ConfigInvalidException {
+    return getTasks(taskConfigFactory.getRootConfig(), TaskConfig.SECTION_ROOT);
+  }
+
+  public List<Task> getTasks(FileKey file) throws IOException, ConfigInvalidException {
+    return getTasks(taskConfigFactory.getTaskConfig(file), TaskConfig.SECTION_TASK);
+  }
+
+  protected List<Task> getTasks(TaskConfig cfg, String type) throws IOException {
+    List<Task> preloaded = new ArrayList<>();
+    for (Task task : cfg.getTasks(type)) {
+      try {
+        preloaded.add(preload(task));
+      } catch (ConfigInvalidException e) {
+        preloaded.add(null);
+      }
+    }
+    return preloaded;
+  }
+
+  boolean inGetOptionalTask;
+
+  /**
+   * Get a preloaded Task for this TaskExpression.
+   *
+   * @param expression
+   * @return Optional<Task> which is empty if the expression is optional and no tasks are resolved
+   * @throws ConfigInvalidException if the expression requires a task and no tasks are resolved
+   */
+  public Optional<Task> getOptionalTask(TaskExpression expression)
+      throws ConfigInvalidException, IOException {
+    Optional<Task> task = optionalTaskByExpression.get(expression.key);
+    if (task == null) {
+      boolean firstInGetOptionalTask = !inGetOptionalTask;
+      inGetOptionalTask = true;
+      task = preloadOptionalTask(expression);
+      optionalTaskByExpression.put(expression.key, task);
+      if (firstInGetOptionalTask) {
+        inGetOptionalTask = false;
+      }
+    }
+    return task;
+  }
+
+  protected Optional<Task> preloadOptionalTask(TaskExpression expression)
+      throws ConfigInvalidException, IOException {
+    Optional<Task> definition = loadOptionalTask(expression);
+    return definition.isPresent() ? Optional.of(preload(definition.get())) : definition;
+  }
+
+  public Task preload(Task definition) throws ConfigInvalidException, IOException {
+    if (statistics != null && !inGetOptionalTask) {
+      statistics.preloadedFromDefinition++;
+    }
+    String expression = definition.preloadTask;
+    if (expression != null) {
+      if (statistics != null) {
+        statistics.preloaded++;
+      }
+      Optional<Task> preloadFrom =
+          getOptionalTask(new TaskExpression(definition.file(), expression));
+      if (preloadFrom.isPresent()) {
+        return preloadFrom(definition, preloadFrom.get());
+      }
+    }
+    return definition;
+  }
+
+  protected Optional<Task> loadOptionalTask(TaskExpression expression)
+      throws ConfigInvalidException, IOException {
+    if (statistics != null) {
+      statistics.loaded++;
+    }
+    try {
+      for (TaskKey key : expression) {
+        Optional<Task> task = getOptionalTask(key);
+        if (task.isPresent()) {
+          return task;
+        }
+      }
+    } catch (NoSuchElementException e) {
+      // expression was not optional but we ran out of names to try
+      throw new ConfigInvalidException("task not defined");
+    }
+    return Optional.empty();
+  }
+
+  protected static Task preloadFrom(Task definition, Task preloadFrom) {
+    Task preloadTo = definition.config.new Task(definition.subSection);
     for (Field field : definition.getClass().getFields()) {
       String name = field.getName();
-      if ("isVisible".equals(name) || "isTrusted".equals(name) || "config".equals(name)) {
+      if ("config".equals(name)) {
         continue;
       }
 
       try {
         field.setAccessible(true);
-        preloadField(field.getType(), field, definition, preloadFrom);
+        preloadField(field, definition, preloadFrom, preloadTo);
       } catch (IllegalAccessException | IllegalArgumentException e) {
         throw new RuntimeException();
       }
     }
+    return preloadTo;
   }
 
-  protected static <T, S, K, V> void preloadField(
-      Class<T> clz, Field field, Task definition, Task preloadFrom)
+  protected Optional<Task> getOptionalTask(TaskKey key) throws IOException, ConfigInvalidException {
+    return taskConfigFactory.getTaskConfig(key.subSection().file()).getOptionalTask(key.task());
+  }
+
+  public void masquerade(PatchSetArgument psa) {
+    taskConfigFactory.masquerade(psa);
+  }
+
+  protected static <S, K, V> void preloadField(
+      Field field, Task definition, Task preloadFrom, Task preloadTo)
       throws IllegalArgumentException, IllegalAccessException {
-    T pre = getField(clz, field, preloadFrom);
-    if (pre != null) {
-      T val = getField(clz, field, definition);
-      if (val == null) {
-        field.set(definition, pre);
-      } else if (val instanceof List) {
-        List<?> valList = List.class.cast(val);
-        List<?> preList = List.class.cast(pre);
-        field.set(definition, preloadListFrom(castUnchecked(valList), castUnchecked(preList)));
-      } else if (val instanceof Map) {
-        Map<?, ?> valMap = Map.class.cast(val);
-        Map<?, ?> preMap = Map.class.cast(pre);
-        field.set(definition, preloadMapFrom(castUnchecked(valMap), castUnchecked(preMap)));
-      } // nothing to do for overridden preloaded scalars
+    Object pre = field.get(preloadFrom);
+    Object val = field.get(definition);
+    if (val == null) {
+      field.set(preloadTo, pre);
+    } else if (pre == null) {
+      field.set(preloadTo, val);
+    } else if (val instanceof List) {
+      List<?> valList = List.class.cast(val);
+      List<?> preList = List.class.cast(pre);
+      field.set(preloadTo, preloadListFrom(castUnchecked(valList), castUnchecked(preList)));
+    } else if (val instanceof Map) {
+      Map<?, ?> valMap = Map.class.cast(val);
+      Map<?, ?> preMap = Map.class.cast(pre);
+      field.set(preloadTo, preloadMapFrom(castUnchecked(valMap), castUnchecked(preMap)));
+    } else {
+      field.set(preloadTo, val);
     }
   }
 
-  protected static <T> T getField(Class<T> clz, Field field, Object obj)
-      throws IllegalArgumentException, IllegalAccessException {
-    return clz.cast(field.get(obj));
-  }
-
   @SuppressWarnings("unchecked")
   protected static <S> List<S> castUnchecked(List<?> list) {
     List<S> forceCheck = (List<S>) list;
@@ -89,28 +195,40 @@
   }
 
   protected static <T> List<T> preloadListFrom(List<T> list, List<T> preList) {
-    List<T> extended = list;
-    if (!preList.isEmpty()) {
-      extended = preList;
-      if (!list.isEmpty()) {
-        extended = new ArrayList<>(list.size() + preList.size());
-        extended.addAll(preList);
-        extended.addAll(list);
-      }
+    if (preList.isEmpty()) {
+      return list;
     }
+    if (list.isEmpty()) {
+      return preList;
+    }
+
+    List<T> extended = new ArrayList<>(list.size() + preList.size());
+    extended.addAll(preList);
+    extended.addAll(list);
     return extended;
   }
 
   protected static <K, V> Map<K, V> preloadMapFrom(Map<K, V> map, Map<K, V> preMap) {
-    Map<K, V> extended = map;
-    if (!preMap.isEmpty()) {
-      extended = preMap;
-      if (!map.isEmpty()) {
-        extended = new HashMap<>(map.size() + preMap.size());
-        extended.putAll(preMap);
-        extended.putAll(map);
-      }
+    if (preMap.isEmpty()) {
+      return map;
     }
+    if (map.isEmpty()) {
+      return preMap;
+    }
+
+    Map<K, V> extended = new HashMap<>(map.size() + preMap.size());
+    extended.putAll(preMap);
+    extended.putAll(map);
     return extended;
   }
+
+  public void initStatistics() {
+    statistics = new Statistics();
+    optionalTaskByExpression.initStatistics();
+  }
+
+  public Statistics getStatistics() {
+    statistics.optionalTaskByExpressionCache = optionalTaskByExpression.getStatistics();
+    return statistics;
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/Properties.java b/src/main/java/com/googlesource/gerrit/plugins/task/Properties.java
deleted file mode 100644
index 028762c..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/task/Properties.java
+++ /dev/null
@@ -1,215 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// 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 com.googlesource.gerrit.plugins.task;
-
-import com.google.common.collect.Sets;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.googlesource.gerrit.plugins.task.TaskConfig.NamesFactory;
-import com.googlesource.gerrit.plugins.task.TaskConfig.Task;
-import java.lang.reflect.Field;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.ListIterator;
-import java.util.Map;
-import java.util.Set;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-/** Use to expand properties like ${_name} in the text of various definitions. */
-public class Properties {
-  /** Use to expand properties specifically for Tasks. */
-  public static class Task extends Expander {
-    public static final Task EMPTY_PARENT = new Task();
-
-    public Task() {
-      super(Collections.emptyMap());
-    }
-
-    public Task(ChangeData changeData, TaskConfig.Task definition, Task parentProperties)
-        throws StorageException {
-      super(parentProperties.forDescendants());
-      valueByName.putAll(getInternalProperties(definition, changeData));
-      new RecursiveExpander(valueByName).expand(definition.getAllProperties());
-
-      definition.setExpandedProperties(valueByName);
-
-      expandFieldValues(definition, Collections.emptySet());
-    }
-
-    protected Map<String, String> forDescendants() {
-      return new HashMap<>(valueByName);
-    }
-  }
-
-  /** Use to expand properties specifically for NamesFactories. */
-  public static class NamesFactory extends Expander {
-    public NamesFactory(TaskConfig.NamesFactory namesFactory, Task properties) {
-      super(properties.valueByName);
-      expandFieldValues(namesFactory, Sets.newHashSet(TaskConfig.KEY_TYPE));
-    }
-  }
-
-  protected static Map<String, String> getInternalProperties(
-      TaskConfig.Task definition, ChangeData changeData) throws StorageException {
-    Map<String, String> properties = new HashMap<>();
-
-    properties.put("_name", definition.name);
-
-    Change c = changeData.change();
-    properties.put("_change_number", String.valueOf(c.getId().get()));
-    properties.put("_change_id", c.getKey().get());
-    properties.put("_change_project", c.getProject().get());
-    properties.put("_change_branch", c.getDest().branch());
-    properties.put("_change_status", c.getStatus().toString());
-    properties.put("_change_topic", c.getTopic());
-
-    return properties;
-  }
-
-  /**
-   * Use to expand properties whose values may contain other references to properties.
-   *
-   * <p>Using a recursive expansion approach makes order of evaluation unimportant as long as there
-   * are no looping definitions.
-   *
-   * <p>Given some property name/value asssociations defined like this:
-   *
-   * <p><code>
-   * valueByName.put("obstacle", "fence");
-   * valueByName.put("action", "jumped over the ${obstacle}");
-   * </code>
-   *
-   * <p>a String like: <code>"The brown fox ${action}."</code>
-   *
-   * <p>will expand to: <code>"The brown fox jumped over the fence."</code>
-   */
-  protected static class RecursiveExpander {
-    protected final Expander expander;
-    protected Map<String, String> unexpandedByName;
-    protected Set<String> expanding;
-
-    public RecursiveExpander(Map<String, String> valueByName) {
-      expander =
-          new Expander(valueByName) {
-            @Override
-            protected String getValueForName(String name) {
-              expandUnexpanded(name); // recursive call
-              return super.getValueForName(name);
-            }
-          };
-    }
-
-    public void expand(Map<String, String> unexpandedByName) {
-      this.unexpandedByName = unexpandedByName;
-
-      // Copy keys to allow out of order removals during iteration
-      for (String unexpanedName : new ArrayList<>(unexpandedByName.keySet())) {
-        expanding = new HashSet<>();
-        expandUnexpanded(unexpanedName);
-      }
-    }
-
-    protected void expandUnexpanded(String name) {
-      if (!expanding.add(name)) {
-        throw new RuntimeException("Looping property definitions.");
-      }
-      String value = unexpandedByName.remove(name);
-      if (value != null) {
-        expander.valueByName.put(name, expander.expandText(value));
-      }
-    }
-  }
-
-  /**
-   * Use to expand properties like ${property} in Strings into their values.
-   *
-   * <p>Given some property name/value asssociations defined like this:
-   *
-   * <p><code>
-   * valueByName.put("animal", "fox");
-   * valueByName.put("bar", "foo");
-   * valueByName.put("obstacle", "fence");
-   * </code>
-   *
-   * <p>a String like: <code>"The brown ${animal} jumped over the ${obstacle}."</code>
-   *
-   * <p>will expand to: <code>"The brown fox jumped over the fence."</code>
-   */
-  protected static class Expander {
-    // "${_name}" -> group(1) = "_name"
-    protected static final Pattern PATTERN = Pattern.compile("\\$\\{([^}]+)\\}");
-
-    public final Map<String, String> valueByName;
-
-    public Expander(Map<String, String> valueByName) {
-      this.valueByName = valueByName;
-    }
-
-    /** Expand all properties in the Strings in the object's Fields (except the exclude ones) */
-    protected void expandFieldValues(Object object, Set<String> excludedFieldNames) {
-      for (Field field : object.getClass().getFields()) {
-        try {
-          if (!excludedFieldNames.contains(field.getName())) {
-            field.setAccessible(true);
-            Object o = field.get(object);
-            if (o instanceof String) {
-              field.set(object, expandText((String) o));
-            } else if (o instanceof List) {
-              @SuppressWarnings("unchecked")
-              List<String> forceCheck = List.class.cast(o);
-              expandElements(forceCheck);
-            }
-          }
-        } catch (IllegalAccessException e) {
-          throw new RuntimeException(e);
-        }
-      }
-    }
-
-    /** Expand all properties in the Strings in the List */
-    public void expandElements(List<String> list) {
-      if (list != null) {
-        for (ListIterator<String> it = list.listIterator(); it.hasNext(); ) {
-          it.set(expandText(it.next()));
-        }
-      }
-    }
-
-    /** Expand all properties (${property_name} -> property_value) in the given text */
-    public String expandText(String text) {
-      if (text == null) {
-        return null;
-      }
-      StringBuffer out = new StringBuffer();
-      Matcher m = PATTERN.matcher(text);
-      while (m.find()) {
-        m.appendReplacement(out, Matcher.quoteReplacement(getValueForName(m.group(1))));
-      }
-      m.appendTail(out);
-      return out.toString();
-    }
-
-    /** Get the replacement value for the property identified by name */
-    protected String getValueForName(String name) {
-      String value = valueByName.get(name);
-      return value == null ? "" : value;
-    }
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/StatisticsMap.java b/src/main/java/com/googlesource/gerrit/plugins/task/StatisticsMap.java
new file mode 100644
index 0000000..4ae8857
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/StatisticsMap.java
@@ -0,0 +1,19 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task;
+
+import java.util.Map;
+
+public interface StatisticsMap<K, V> extends Map<K, V>, TracksStatistics {}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/StopWatch.java b/src/main/java/com/googlesource/gerrit/plugins/task/StopWatch.java
new file mode 100644
index 0000000..f7d36e7
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/StopWatch.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task;
+
+import com.google.common.base.Stopwatch;
+import java.util.concurrent.TimeUnit;
+import java.util.function.LongConsumer;
+
+public class StopWatch {
+  protected Stopwatch stopwatch;
+  protected LongConsumer consumer;
+  protected long nanoseconds;
+
+  public StopWatch enableIfNonNull(Object statistics) {
+    if (statistics != null) {
+      enable();
+    }
+    return this;
+  }
+
+  public StopWatch enable() {
+    stopwatch = Stopwatch.createUnstarted();
+    return this;
+  }
+
+  public StopWatch run(Runnable runnable) {
+    start();
+    runnable.run();
+    stop();
+    return this;
+  }
+
+  public StopWatch start() {
+    if (stopwatch != null && !stopwatch.isRunning()) {
+      stopwatch.start();
+    }
+    return this;
+  }
+
+  public StopWatch stop() {
+    if (stopwatch != null && stopwatch.isRunning()) {
+      stopwatch.stop();
+      if (consumer != null) {
+        consume(consumer);
+      }
+    }
+    return this;
+  }
+
+  public StopWatch setConsumer(LongConsumer consumer) {
+    if (consumer != null) {
+      stopwatch = Stopwatch.createUnstarted();
+    }
+    this.consumer = consumer;
+    return this;
+  }
+
+  public StopWatch consume(LongConsumer consumer) {
+    if (stopwatch != null) {
+      consumer.accept(get());
+    }
+    return this;
+  }
+
+  public long get() {
+    nanoseconds += stopwatch.elapsed(TimeUnit.NANOSECONDS);
+    stopwatch.reset();
+    return nanoseconds;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/SubSectionKey.java b/src/main/java/com/googlesource/gerrit/plugins/task/SubSectionKey.java
new file mode 100644
index 0000000..54db1e4
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/SubSectionKey.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task;
+
+import com.google.auto.value.AutoValue;
+
+/** An immutable reference to a SubSection in fully qualified task config file. */
+@AutoValue
+public abstract class SubSectionKey {
+  public static SubSectionKey create(FileKey file, String section, String subSection) {
+    return new AutoValue_SubSectionKey(file, section, subSection == null ? "" : subSection);
+  }
+
+  public abstract FileKey file();
+
+  public abstract String section();
+
+  public abstract String subSection();
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java
index 1e19f7b..414ca25 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java
@@ -29,33 +29,60 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class TaskAttributeFactory implements ChangePluginDefinedInfoFactory {
   private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
   public enum Status {
     INVALID,
     UNKNOWN,
+    DUPLICATE,
     WAITING,
     READY,
     PASS,
     FAIL;
   }
 
+  public static class Statistics {
+    public long numberOfChanges;
+    public long numberOfChangeNodes;
+    public long numberOfDuplicates;
+    public long numberOfNodes;
+    public long numberOfTaskPluginAttributes;
+    public Object predicateCache;
+    public Object matchCache;
+    public Preloader.Statistics preloader;
+    public TaskTree.Statistics treeCaches;
+  }
+
   public static class TaskAttribute {
+    public static class Statistics {
+      public boolean isApplicableRefreshRequired;
+      public boolean isSubNodeReloadRequired;
+      public boolean isTaskRefreshNeeded;
+      public Boolean hasUnfilterableSubNodes;
+      public Object nodesByBranchCache;
+      public Object properties;
+    }
+
     public Boolean applicable;
     public Map<String, String> exported;
     public Boolean hasPass;
     public String hint;
     public Boolean inProgress;
     public String name;
+    public Integer change;
     public Status status;
     public List<TaskAttribute> subTasks;
     public Long evaluationMilliSeconds;
+    public Statistics statistics;
 
     public TaskAttribute(String name) {
       this.name = name;
@@ -64,12 +91,15 @@
 
   public static class TaskPluginAttribute extends PluginDefinedInfo {
     public List<TaskAttribute> roots = new ArrayList<>();
+    public Statistics queryStatistics;
   }
 
   protected final TaskTree definitions;
   protected final PredicateCache predicateCache;
 
   protected Modules.MyOptions options;
+  protected TaskPluginAttribute lastTaskPluginAttribute;
+  protected Statistics statistics;
 
   @Inject
   public TaskAttributeFactory(TaskTree definitions, PredicateCache predicateCache) {
@@ -79,14 +109,18 @@
 
   @Override
   public Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
-          Collection<ChangeData> cds, BeanProvider beanProvider, String plugin) {
+      Collection<ChangeData> cds, BeanProvider beanProvider, String plugin) {
     Map<Change.Id, PluginDefinedInfo> pluginInfosByChange = new HashMap<>();
     options = (Modules.MyOptions) beanProvider.getDynamicBean(plugin);
     if (options.all || options.onlyApplicable || options.onlyInvalid) {
+      initStatistics();
       for (PatchSetArgument psa : options.patchSetArguments) {
         definitions.masquerade(psa);
       }
       cds.forEach(cd -> pluginInfosByChange.put(cd.getId(), createWithExceptions(cd)));
+      if (lastTaskPluginAttribute != null) {
+        lastTaskPluginAttribute.queryStatistics = getStatistics(pluginInfosByChange);
+      }
     }
     return pluginInfosByChange;
   }
@@ -95,37 +129,43 @@
     TaskPluginAttribute a = new TaskPluginAttribute();
     try {
       for (Node node : definitions.getRootNodes(c)) {
-        if (node == null) {
+        if (node instanceof Node.Invalid) {
           a.roots.add(invalid());
         } else {
           new AttributeFactory(node).create().ifPresent(t -> a.roots.add(t));
         }
       }
-    } catch (ConfigInvalidException | IOException e) {
+    } catch (ConfigInvalidException | IOException | StorageException e) {
       a.roots.add(invalid());
     }
 
     if (a.roots.isEmpty()) {
       return null;
     }
+    lastTaskPluginAttribute = a;
     return a;
   }
 
   protected class AttributeFactory {
     public Node node;
-    public MatchCache matchCache;
     protected Task task;
     protected TaskAttribute attribute;
 
     protected AttributeFactory(Node node) {
-      this(node, new MatchCache(predicateCache, node.getChangeData()));
-    }
-
-    protected AttributeFactory(Node node, MatchCache matchCache) {
       this.node = node;
-      this.matchCache = matchCache;
       this.task = node.task;
-      this.attribute = new TaskAttribute(task.name);
+      attribute = new TaskAttribute(task.name());
+      if (options.includeStatistics) {
+        statistics.numberOfNodes++;
+        if (node.isChange()) {
+          statistics.numberOfChangeNodes++;
+        }
+        if (node.isDuplicate) {
+          statistics.numberOfDuplicates++;
+        }
+        attribute.statistics = new TaskAttribute.Statistics();
+        attribute.statistics.properties = node.propertiesStatistics;
+      }
     }
 
     public Optional<TaskAttribute> create() {
@@ -134,16 +174,21 @@
           attribute.evaluationMilliSeconds = millis();
         }
 
-        boolean applicable = matchCache.match(task.applicable);
+        boolean applicable = node.match(task.applicable);
         if (!task.isVisible) {
-          if (!task.isTrusted || (!applicable && !options.onlyApplicable)) {
+          if (!node.isTrusted() || (!applicable && !options.onlyApplicable)) {
             return Optional.of(unknown());
           }
         }
 
         if (applicable || !options.onlyApplicable) {
-          attribute.hasPass = task.pass != null || task.fail != null;
-          attribute.subTasks = getSubTasks();
+          if (node.isChange()) {
+            attribute.change = node.getChangeData().getId().get();
+          }
+          attribute.hasPass = !node.isDuplicate && (task.pass != null || task.fail != null);
+          if (!node.isDuplicate) {
+            attribute.subTasks = getSubTasks();
+          }
           attribute.status = getStatus();
           if (options.onlyInvalid && !isValidQueries()) {
             attribute.status = Status.INVALID;
@@ -157,26 +202,46 @@
               if (!options.onlyApplicable) {
                 attribute.applicable = applicable;
               }
-              if (task.inProgress != null) {
-                attribute.inProgress = matchCache.matchOrNull(task.inProgress);
+              if (!node.isDuplicate) {
+                if (task.inProgress != null) {
+                  attribute.inProgress = node.matchOrNull(task.inProgress);
+                }
+                attribute.exported = task.exported.isEmpty() ? null : task.exported;
               }
               attribute.hint = getHint(attribute.status, task);
-              attribute.exported = task.exported.isEmpty() ? null : task.exported;
 
               if (options.evaluationTime) {
                 attribute.evaluationMilliSeconds = millis() - attribute.evaluationMilliSeconds;
               }
+              addStatistics(attribute.statistics);
               return Optional.of(attribute);
             }
           }
         }
-      } catch (QueryParseException | RuntimeException e) {
+      } catch (ConfigInvalidException | IOException | QueryParseException | RuntimeException e) {
         return Optional.of(invalid()); // bad applicability query
       }
       return Optional.empty();
     }
 
+    public void addStatistics(TaskAttribute.Statistics statistics) {
+      if (statistics != null) {
+        statistics.isApplicableRefreshRequired = node.properties.isApplicableRefreshRequired();
+        statistics.isSubNodeReloadRequired = node.properties.isSubNodeReloadRequired();
+        statistics.isTaskRefreshNeeded = node.properties.isTaskRefreshRequired();
+        if (!statistics.isSubNodeReloadRequired) {
+          statistics.hasUnfilterableSubNodes = node.hasUnfilterableSubNodes;
+        }
+        if (node.nodesByBranch != null) {
+          statistics.nodesByBranchCache = node.nodesByBranch.getStatistics();
+        }
+      }
+    }
+
     protected Status getStatusWithExceptions() throws StorageException, QueryParseException {
+      if (node.isDuplicate) {
+        return Status.DUPLICATE;
+      }
       if (isAllNull(task.pass, task.fail, attribute.subTasks)) {
         // A leaf def has no defined subdefs.
         boolean hasDefinedSubtasks =
@@ -198,7 +263,7 @@
       }
 
       if (task.fail != null) {
-        if (matchCache.match(task.fail)) {
+        if (node.match(task.fail)) {
           // A FAIL definition is meant to be a hard blocking criteria
           // (like a CodeReview -2).  Thus, if hard blocked, it is
           // irrelevant what the subtask states, or the PASS criteria are.
@@ -212,7 +277,8 @@
         }
       }
 
-      if (attribute.subTasks != null && !isAll(attribute.subTasks, Status.PASS)) {
+      if (attribute.subTasks != null
+          && !isAll(attribute.subTasks, EnumSet.of(Status.PASS, Status.DUPLICATE))) {
         // It is possible for a subtask's PASS criteria to change while
         // a parent task is executing, or even after the parent task
         // completes.  This can result in the parent PASS criteria being
@@ -224,7 +290,7 @@
         return Status.WAITING;
       }
 
-      if (task.pass != null && !matchCache.match(task.pass)) {
+      if (task.pass != null && !node.match(task.pass)) {
         // Non-leaf tasks with no PASS criteria are supported in order
         // to support "grouping tasks" (tasks with no function aside from
         // organizing tasks).  A task without a PASS criteria, cannot ever
@@ -245,13 +311,15 @@
       }
     }
 
-    protected List<TaskAttribute> getSubTasks() throws StorageException {
+    protected List<TaskAttribute> getSubTasks()
+        throws ConfigInvalidException, IOException, StorageException {
       List<TaskAttribute> subTasks = new ArrayList<>();
-      for (Node subNode : node.getSubNodes()) {
-        if (subNode == null) {
+      for (Node subNode :
+          options.onlyApplicable ? node.getApplicableSubNodes() : node.getSubNodes()) {
+        if (subNode instanceof Node.Invalid) {
           subTasks.add(invalid());
         } else {
-          new AttributeFactory(subNode, matchCache).create().ifPresent(t -> subTasks.add(t));
+          new AttributeFactory(subNode).create().ifPresent(t -> subTasks.add(t));
         }
       }
       if (subTasks.isEmpty()) {
@@ -262,9 +330,9 @@
 
     protected boolean isValidQueries() {
       try {
-        matchCache.match(task.inProgress);
-        matchCache.match(task.fail);
-        matchCache.match(task.pass);
+        node.match(task.inProgress);
+        node.match(task.fail);
+        node.match(task.pass);
         return true;
       } catch (QueryParseException | RuntimeException e) {
         return false;
@@ -276,7 +344,30 @@
     return System.nanoTime() / 1000000;
   }
 
-  protected TaskAttribute invalid() {
+  public void initStatistics() {
+    if (options.includeStatistics) {
+      statistics = new Statistics();
+      definitions.predicateCache.initStatistics();
+      definitions.matchCache.initStatistics();
+      definitions.preloader.initStatistics();
+      definitions.initStatistics();
+    }
+  }
+
+  public Statistics getStatistics(Map<Change.Id, PluginDefinedInfo> pluginInfosByChange) {
+    if (statistics != null) {
+      statistics.numberOfChanges = pluginInfosByChange.size();
+      statistics.numberOfTaskPluginAttributes =
+          pluginInfosByChange.values().stream().filter(tpa -> tpa != null).count();
+      statistics.predicateCache = definitions.predicateCache.getStatistics();
+      statistics.matchCache = definitions.matchCache.getStatistics();
+      statistics.preloader = definitions.preloader.getStatistics();
+      statistics.treeCaches = definitions.getStatistics();
+    }
+    return statistics;
+  }
+
+  protected static TaskAttribute invalid() {
     // For security reasons, do not expose the task name without knowing
     // the visibility which is derived from its applicability.
     TaskAttribute a = unknown();
@@ -284,17 +375,23 @@
     return a;
   }
 
-  protected TaskAttribute unknown() {
+  protected static TaskAttribute unknown() {
     TaskAttribute a = new TaskAttribute("UNKNOWN");
     a.status = Status.UNKNOWN;
     return a;
   }
 
-  protected String getHint(Status status, Task def) {
-    if (status == Status.READY) {
-      return def.readyHint;
-    } else if (status == Status.FAIL) {
-      return def.failHint;
+  protected static String getHint(Status status, Task def) {
+    if (status != null) {
+      switch (status) {
+        case READY:
+          return def.readyHint;
+        case FAIL:
+          return def.failHint;
+        case DUPLICATE:
+          return "Duplicate task is non blocking and empty to break the loop";
+        default:
+      }
     }
     return null;
   }
@@ -308,9 +405,9 @@
     return true;
   }
 
-  protected static boolean isAll(Iterable<TaskAttribute> atts, Status state) {
+  protected static boolean isAll(Iterable<TaskAttribute> atts, Set<Status> states) {
     for (TaskAttribute att : atts) {
-      if (att.status != state) {
+      if (!states.contains(att.status)) {
         return false;
       }
     }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
index 5e987ca..33ac745 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
@@ -14,21 +14,18 @@
 
 package com.googlesource.gerrit.plugins.task;
 
-import com.google.common.primitives.Primitives;
 import com.google.gerrit.common.Container;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.server.git.meta.AbstractVersionedMetaData;
-import java.lang.reflect.Field;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** Task Configuration file living in git */
 public class TaskConfig extends AbstractVersionedMetaData {
@@ -44,16 +41,19 @@
     }
   }
 
-  protected class Section extends Container {
+  protected class SubSection extends Container {
     public TaskConfig config;
+    public final SubSectionKey subSection;
 
-    public Section() {
+    public SubSection(SubSectionKey s) {
       this.config = TaskConfig.this;
+      this.subSection = s;
     }
   }
 
-  public class TaskBase extends Section {
+  public class TaskBase extends SubSection {
     public String applicable;
+    public String duplicateKey;
     public Map<String, String> exported;
     public String fail;
     public String failHint;
@@ -68,12 +68,14 @@
     public List<String> subTasksFiles;
 
     public boolean isVisible;
-    public boolean isTrusted;
+    public boolean isMasqueraded;
 
-    public TaskBase(SubSection s, boolean isVisible, boolean isTrusted) {
+    public TaskBase(SubSectionKey s, boolean isVisible, boolean isMasqueraded) {
+      super(s);
       this.isVisible = isVisible;
-      this.isTrusted = isTrusted;
+      this.isMasqueraded = isMasqueraded;
       applicable = getString(s, KEY_APPLICABLE, null);
+      duplicateKey = getString(s, KEY_DUPLICATE_KEY, null);
       exported = getProperties(s, KEY_EXPORT_PREFIX);
       fail = getString(s, KEY_FAIL, null);
       failHint = getString(s, KEY_FAIL_HINT, null);
@@ -89,53 +91,31 @@
     }
 
     protected TaskBase(TaskBase base) {
-      copyDeclaredFields(TaskBase.class, base);
+      this(base.subSection);
+      Copier.shallowCopyDeclaredFields(TaskBase.class, base, this, false);
     }
 
-    protected <T> void copyDeclaredFields(Class<T> cls, T from) {
-      for (Field field : cls.getDeclaredFields()) {
-        try {
-          field.setAccessible(true);
-          Class<?> fieldCls = field.getType();
-          Object val = field.get(from);
-          if (field.getType().isPrimitive()
-              || Primitives.isWrapperType(fieldCls)
-              || (val instanceof String)
-              || val == null) {
-            field.set(this, val);
-          } else if (val instanceof List) {
-            List<?> list = List.class.cast(val);
-            field.set(this, new ArrayList<>(list));
-          } else if (val instanceof Map) {
-            Map<?, ?> map = Map.class.cast(val);
-            field.set(this, new HashMap<>(map));
-          } else if (field.getName().equals("this$0")) { // Don't copy internal final field
-          } else {
-            throw new RuntimeException(
-                "Don't know how to deep copy " + fieldValueToString(field, val));
-          }
-        } catch (IllegalAccessException e) {
-          throw new RuntimeException(
-              "Cannot access field to copy it " + fieldValueToString(field, "unknown"));
-        }
-      }
-    }
-
-    protected String fieldValueToString(Field field, Object val) {
-      return "field:" + field.getName() + " value:" + val + " type:" + field.getType();
+    protected TaskBase(SubSectionKey s) {
+      super(s);
     }
   }
 
-  public class Task extends TaskBase {
-    public String name;
+  public class Task extends TaskBase implements Cloneable {
+    public final TaskKey key;
 
-    public Task(SubSection s, boolean isVisible, boolean isTrusted) {
-      super(s, isVisible, isTrusted);
-      name = s.subSection;
+    public Task(SubSectionKey s, boolean isVisible, boolean isMasqueraded) {
+      super(s, isVisible, isMasqueraded);
+      key = TaskKey.create(s);
     }
 
-    protected Task(TaskBase base) {
-      super(base);
+    public Task(TasksFactory tasks, String name) {
+      super(tasks);
+      key = TaskKey.create(tasks.subSection, name);
+    }
+
+    public Task(SubSectionKey s) {
+      super(s);
+      key = TaskKey.create(s);
     }
 
     protected Map<String, String> getAllProperties() {
@@ -144,104 +124,103 @@
       return all;
     }
 
-    protected void setExpandedProperties(Map<String, String> expanded) {
-      properties.clear();
-      properties.putAll(expanded);
-      for (String property : exported.keySet()) {
-        exported.put(property, properties.get(property));
-      }
+    public String name() {
+      return key.task();
+    }
+
+    public FileKey file() {
+      return key.subSection().file();
+    }
+
+    public TaskKey key() {
+      return key;
     }
   }
 
   public class TasksFactory extends TaskBase {
     public String namesFactory;
 
-    public TasksFactory(SubSection s, boolean isVisible, boolean isTrusted) {
-      super(s, isVisible, isTrusted);
+    public TasksFactory(SubSectionKey s, boolean isVisible, boolean isMasqueraded) {
+      super(s, isVisible, isMasqueraded);
       namesFactory = getString(s, KEY_NAMES_FACTORY, null);
     }
   }
 
-  public class NamesFactory extends Section {
+  public class NamesFactory extends SubSection implements Cloneable {
     public String changes;
     public List<String> names;
     public String type;
 
-    public NamesFactory(SubSection s) {
+    public NamesFactory(SubSectionKey s) {
+      super(s);
       changes = getString(s, KEY_CHANGES, null);
       names = getStringList(s, KEY_NAME);
       type = getString(s, KEY_TYPE, null);
     }
   }
 
-  public class External extends Section {
+  public class External extends SubSection {
     public String name;
     public String file;
     public String user;
 
-    public External(SubSection s) {
-      name = s.subSection;
+    public External(SubSectionKey s) {
+      super(s);
+      name = s.subSection();
       file = getString(s, KEY_FILE, null);
       user = getString(s, KEY_USER, null);
     }
   }
 
-  protected static final Pattern OPTIONAL_TASK_PATTERN =
-      Pattern.compile("([^ |]*( *[^ |])*) *\\| *");
+  public static final String SEP = "\0";
 
-  protected static final String SECTION_EXTERNAL = "external";
-  protected static final String SECTION_NAMES_FACTORY = "names-factory";
-  protected static final String SECTION_ROOT = "root";
-  protected static final String SECTION_TASK = "task";
-  protected static final String SECTION_TASKS_FACTORY = "tasks-factory";
-  protected static final String KEY_APPLICABLE = "applicable";
-  protected static final String KEY_CHANGES = "changes";
-  protected static final String KEY_EXPORT_PREFIX = "export-";
-  protected static final String KEY_FAIL = "fail";
-  protected static final String KEY_FAIL_HINT = "fail-hint";
-  protected static final String KEY_FILE = "file";
-  protected static final String KEY_IN_PROGRESS = "in-progress";
-  protected static final String KEY_NAME = "name";
-  protected static final String KEY_NAMES_FACTORY = "names-factory";
-  protected static final String KEY_PASS = "pass";
-  protected static final String KEY_PRELOAD_TASK = "preload-task";
-  protected static final String KEY_PROPERTIES_PREFIX = "set-";
-  protected static final String KEY_READY_HINT = "ready-hint";
-  protected static final String KEY_SUBTASK = "subtask";
-  protected static final String KEY_SUBTASKS_EXTERNAL = "subtasks-external";
-  protected static final String KEY_SUBTASKS_FACTORY = "subtasks-factory";
-  protected static final String KEY_SUBTASKS_FILE = "subtasks-file";
-  protected static final String KEY_TYPE = "type";
-  protected static final String KEY_USER = "user";
+  public static final String SECTION_EXTERNAL = "external";
+  public static final String SECTION_NAMES_FACTORY = "names-factory";
+  public static final String SECTION_ROOT = "root";
+  public static final String SECTION_TASK = TaskKey.CONFIG_SECTION;
+  public static final String SECTION_TASKS_FACTORY = "tasks-factory";
+  public static final String KEY_APPLICABLE = "applicable";
+  public static final String KEY_CHANGES = "changes";
+  public static final String KEY_DUPLICATE_KEY = "duplicate-key";
+  public static final String KEY_EXPORT_PREFIX = "export-";
+  public static final String KEY_FAIL = "fail";
+  public static final String KEY_FAIL_HINT = "fail-hint";
+  public static final String KEY_FILE = "file";
+  public static final String KEY_IN_PROGRESS = "in-progress";
+  public static final String KEY_NAME = "name";
+  public static final String KEY_NAMES_FACTORY = "names-factory";
+  public static final String KEY_PASS = "pass";
+  public static final String KEY_PRELOAD_TASK = "preload-task";
+  public static final String KEY_PROPERTIES_PREFIX = "set-";
+  public static final String KEY_READY_HINT = "ready-hint";
+  public static final String KEY_SUBTASK = "subtask";
+  public static final String KEY_SUBTASKS_EXTERNAL = "subtasks-external";
+  public static final String KEY_SUBTASKS_FACTORY = "subtasks-factory";
+  public static final String KEY_SUBTASKS_FILE = "subtasks-file";
+  public static final String KEY_TYPE = "type";
+  public static final String KEY_USER = "user";
 
+  protected final FileKey file;
   public boolean isVisible;
-  public boolean isTrusted;
+  public boolean isMasqueraded;
 
-  public Task createTask(TasksFactory tasks, String name) {
-    Task task = new Task(tasks);
-    task.name = name;
-    return task;
+  public TaskConfig(FileKey file, boolean isVisible, boolean isMasqueraded) {
+    this(file.branch(), file, isVisible, isMasqueraded);
   }
 
-  public TaskConfig(BranchNameKey branch, String fileName, boolean isVisible, boolean isTrusted) {
-    super(branch, fileName);
+  public TaskConfig(
+      BranchNameKey masqueraded, FileKey file, boolean isVisible, boolean isMasqueraded) {
+    super(masqueraded, file.file());
+    this.file = file;
     this.isVisible = isVisible;
-    this.isTrusted = isTrusted;
-  }
-
-  public List<Task> getRootTasks() {
-    return getTasks(SECTION_ROOT);
-  }
-
-  public List<Task> getTasks() {
-    return getTasks(SECTION_TASK);
+    this.isMasqueraded = isMasqueraded;
   }
 
   protected List<Task> getTasks(String type) {
     List<Task> tasks = new ArrayList<>();
     // No need to get a task with no name (what would we call it?)
     for (String task : cfg.getSubsections(type)) {
-      tasks.add(new Task(new SubSection(type, task), isVisible, isTrusted));
+      tasks.add(new Task(subSectionKey(type, task), isVisible, isMasqueraded));
     }
     return tasks;
   }
@@ -255,103 +234,76 @@
     return externals;
   }
 
-  /* returs null only if optional and not found */
-  public Task getTaskOptional(String name) throws ConfigInvalidException {
-    int end = 0;
-    Matcher m = OPTIONAL_TASK_PATTERN.matcher(name);
-    while (m.find()) {
-      end = m.end();
-      Task task = getTaskOrNull(m.group(1));
-      if (task != null) {
-        return task;
-      }
-    }
-
-    String last = name.substring(end);
-    if (!"".equals(last)) { // Last entry was not optional
-      Task task = getTaskOrNull(last);
-      if (task != null) {
-        return task;
-      }
-      throw new ConfigInvalidException("task not defined");
-    }
-    return null;
-  }
-
-  /* returns null if not found */
-  protected Task getTaskOrNull(String name) {
-    SubSection subSection = new SubSection(SECTION_TASK, name);
-    return getNames(subSection).isEmpty() ? null : new Task(subSection, isVisible, isTrusted);
+  protected Optional<Task> getOptionalTask(String name) {
+    SubSectionKey subSection = subSectionKey(SECTION_TASK, name);
+    return getNames(subSection).isEmpty()
+        ? Optional.empty()
+        : Optional.of(new Task(subSection, isVisible, isMasqueraded));
   }
 
   public TasksFactory getTasksFactory(String name) {
-    return new TasksFactory(new SubSection(SECTION_TASKS_FACTORY, name), isVisible, isTrusted);
+    return new TasksFactory(subSectionKey(SECTION_TASKS_FACTORY, name), isVisible, isMasqueraded);
   }
 
   public NamesFactory getNamesFactory(String name) {
-    return new NamesFactory(new SubSection(SECTION_NAMES_FACTORY, name));
+    return new NamesFactory(subSectionKey(SECTION_NAMES_FACTORY, name));
   }
 
   public External getExternal(String name) {
-    return getExternal(new SubSection(SECTION_EXTERNAL, name));
+    return getExternal(subSectionKey(SECTION_EXTERNAL, name));
   }
 
-  protected External getExternal(SubSection s) {
+  protected External getExternal(SubSectionKey s) {
     return new External(s);
   }
 
-  protected Map<String, String> getProperties(SubSection s, String prefix) {
+  protected Map<String, String> getProperties(SubSectionKey s, String prefix) {
     Map<String, String> valueByName = new HashMap<>();
     for (Map.Entry<String, String> e :
         getStringByName(s, getMatchingNames(s, prefix + ".+")).entrySet()) {
       String name = e.getKey();
       valueByName.put(name.substring(prefix.length()), e.getValue());
     }
-    return valueByName;
+    return Collections.unmodifiableMap(valueByName);
   }
 
-  protected Map<String, String> getStringByName(SubSection s, Iterable<String> names) {
+  protected Map<String, String> getStringByName(SubSectionKey s, Iterable<String> names) {
     Map<String, String> valueByName = new HashMap<>();
     for (String name : names) {
       valueByName.put(name, getString(s, name));
     }
-    return valueByName;
+    return Collections.unmodifiableMap(valueByName);
   }
 
-  protected Set<String> getMatchingNames(SubSection s, String match) {
+  protected Set<String> getMatchingNames(SubSectionKey s, String match) {
     Set<String> matched = new HashSet<>();
     for (String name : getNames(s)) {
       if (name.matches(match)) {
         matched.add(name);
       }
     }
-    return matched;
+    return Collections.unmodifiableSet(matched);
   }
 
-  protected Set<String> getNames(SubSection s) {
-    return cfg.getNames(s.section, s.subSection);
+  protected Set<String> getNames(SubSectionKey s) {
+    return cfg.getNames(s.section(), s.subSection());
   }
 
-  protected String getString(SubSection s, String key, String def) {
+  protected String getString(SubSectionKey s, String key, String def) {
     String v = getString(s, key);
     return v != null ? v : def;
   }
 
-  protected String getString(SubSection s, String key) {
-    return cfg.getString(s.section, s.subSection, key);
+  protected String getString(SubSectionKey s, String key) {
+    return cfg.getString(s.section(), s.subSection(), key);
   }
 
-  protected List<String> getStringList(SubSection s, String key) {
-    return Arrays.asList(cfg.getStringList(s.section, s.subSection, key));
+  protected List<String> getStringList(SubSectionKey s, String key) {
+    return Collections.unmodifiableList(
+        Arrays.asList(cfg.getStringList(s.section(), s.subSection(), key)));
   }
 
-  protected static class SubSection {
-    public final String section;
-    public final String subSection;
-
-    protected SubSection(String section, String subSection) {
-      this.section = section;
-      this.subSection = subSection;
-    }
+  protected SubSectionKey subSectionKey(String section, String subSection) {
+    return SubSectionKey.create(file, section, subSection);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java
index f790ccc..908ff0b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java
@@ -17,9 +17,11 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -45,21 +47,22 @@
   protected final AllProjectsName allProjects;
 
   protected final Map<BranchNameKey, PatchSetArgument> psaMasquerades = new HashMap<>();
+  protected final Map<FileKey, TaskConfig> taskCfgByFile = new HashMap<>();
 
   @Inject
   protected TaskConfigFactory(
-      AllProjectsName allProjects,
+      AllProjectsNameProvider allProjectsNameProvider,
       GitRepositoryManager gitMgr,
       PermissionBackend permissionBackend,
       CurrentUser user) {
-    this.allProjects = allProjects;
+    this.allProjects = allProjectsNameProvider.get();
     this.gitMgr = gitMgr;
     this.permissionBackend = permissionBackend;
     this.user = user;
   }
 
   public TaskConfig getRootConfig() throws ConfigInvalidException, IOException {
-    return getTaskConfig(getRootBranch(), DEFAULT, true);
+    return getTaskConfig(FileKey.create(getRootBranch(), DEFAULT));
   }
 
   public void masquerade(PatchSetArgument psa) {
@@ -67,26 +70,39 @@
   }
 
   protected BranchNameKey getRootBranch() {
-    return BranchNameKey.create(allProjects, "refs/meta/config");
+    return BranchNameKey.create(allProjects, RefNames.REFS_CONFIG);
   }
 
-  public TaskConfig getTaskConfig(BranchNameKey branch, String fileName, boolean isTrusted)
-      throws ConfigInvalidException, IOException {
+  public TaskConfig getTaskConfig(FileKey key) throws ConfigInvalidException, IOException {
+    TaskConfig cfg = taskCfgByFile.get(key);
+    if (cfg == null) {
+      cfg = loadTaskConfig(key);
+      taskCfgByFile.put(key, cfg);
+    }
+    return cfg;
+  }
+
+  private TaskConfig loadTaskConfig(FileKey file) throws ConfigInvalidException, IOException {
+    BranchNameKey branch = file.branch();
     PatchSetArgument psa = psaMasquerades.get(branch);
     boolean visible = true; // invisible psas are filtered out by commandline
+    boolean isMasqueraded = false;
     if (psa == null) {
       visible = canRead(branch);
     } else {
-      isTrusted = false;
+      isMasqueraded = true;
       branch = BranchNameKey.create(psa.change.getProject(), psa.patchSet.refName());
     }
 
-    Project.NameKey project = branch.project();
-    TaskConfig cfg = new TaskConfig(branch, fileName, visible, isTrusted);
+    Project.NameKey project = file.branch().project();
+    TaskConfig cfg =
+        isMasqueraded
+            ? new TaskConfig(branch, file, visible, isMasqueraded)
+            : new TaskConfig(file, visible, isMasqueraded);
     try (Repository git = gitMgr.openRepository(project)) {
       cfg.load(project, git);
     } catch (IOException e) {
-      log.atWarning().withCause(e).log("Failed to load %s for %s", fileName, project);
+      log.atWarning().withCause(e).log("Failed to load %s for %s", file.file(), project);
       throw e;
     } catch (ConfigInvalidException e) {
       throw e;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpression.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpression.java
new file mode 100644
index 0000000..5a61d29
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpression.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task;
+
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A TaskExpression represents a config string pointing to an expression which includes zero or more
+ * task names separated by a '|', and potentially termintated by a '|'. If the expression is not
+ * terminated by a '|' it indicates that task resolution of at least one task is required. Task
+ * selection priority is from left to right. This can be expressed as: <code>
+ * EXPR = [ TASK_NAME '|' ] TASK_NAME [ '|' ]</code>
+ *
+ * <p>Example expressions to prioritized names and requirements:
+ *
+ * <ul>
+ *   <li><code> "simple"        -> ("simple")         required</code>
+ *   <li><code> "world | peace" -> ("world", "peace") required</code>
+ *   <li><code> "shadenfreud |" -> ("shadenfreud")    optional</code>
+ *   <li><code> "foo | bar |"   -> ("foo", "bar")     optional</code>
+ * </ul>
+ */
+public class TaskExpression implements Iterable<TaskKey> {
+  protected static final Pattern EXPRESSION_PATTERN = Pattern.compile("([^ |]+[^|]*)(\\|)?");
+  protected final TaskExpressionKey key;
+
+  public TaskExpression(FileKey key, String expression) {
+    this.key = TaskExpressionKey.create(key, expression);
+  }
+
+  @Override
+  public Iterator<TaskKey> iterator() {
+    return new Iterator<TaskKey>() {
+      Matcher m = EXPRESSION_PATTERN.matcher(key.expression());
+      Boolean hasNext;
+      boolean optional;
+
+      @Override
+      public boolean hasNext() {
+        if (hasNext == null) {
+          hasNext = m.find();
+          if (hasNext) {
+            optional = m.group(2) != null;
+          }
+        }
+        if (!hasNext && !optional) {
+          return true; // fake it so next() throws an Exception
+        }
+        return hasNext;
+      }
+
+      @Override
+      public TaskKey next() {
+        // Can't use @SuppressWarnings("ReturnValueIgnored") on method call
+        boolean ignored = hasNext(); // in case next() was (re)called w/o calling hasNext()
+        if (!hasNext) {
+          throw new NoSuchElementException("No more names, yet expression was not optional");
+        }
+        hasNext = null;
+        return TaskKey.create(key.file(), m.group(1).trim());
+      }
+    };
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpressionKey.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpressionKey.java
new file mode 100644
index 0000000..0a05b2e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpressionKey.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task;
+
+import com.google.auto.value.AutoValue;
+
+/** A key for TaskExpression. */
+@AutoValue
+public abstract class TaskExpressionKey {
+  public static TaskExpressionKey create(FileKey file, String expression) {
+    return new AutoValue_TaskExpressionKey(file, expression);
+  }
+
+  public abstract FileKey file();
+
+  public abstract String expression();
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskKey.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskKey.java
new file mode 100644
index 0000000..c56202d
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskKey.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.BranchNameKey;
+
+/** An immutable reference to a task in task config file. */
+@AutoValue
+public abstract class TaskKey {
+  protected static final String CONFIG_SECTION = "task";
+
+  /** Creates a TaskKey with task name as the name of sub section. */
+  public static TaskKey create(SubSectionKey section) {
+    return create(section, section.subSection());
+  }
+
+  /** Creates a TaskKey with given FileKey and task name and sub section's name as 'task'. */
+  public static TaskKey create(FileKey file, String task) {
+    return create(SubSectionKey.create(file, CONFIG_SECTION, task));
+  }
+
+  /** Creates a TaskKey from a sub section and task name, generally used by TasksFactory. */
+  public static TaskKey create(SubSectionKey section, String task) {
+    return new AutoValue_TaskKey(section, task);
+  }
+
+  public BranchNameKey branch() {
+    return subSection().file().branch();
+  }
+
+  public abstract SubSectionKey subSection();
+
+  public abstract String task();
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskStatus.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskStatus.java
new file mode 100644
index 0000000..5dc7afd
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskStatus.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task;
+
+public class TaskStatus {
+  public enum Status {
+    INVALID,
+    UNKNOWN,
+    DUPLICATE,
+    WAITING,
+    READY,
+    PASS,
+    FAIL;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskTree.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskTree.java
index 9c4b476..172208a 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskTree.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskTree.java
@@ -14,6 +14,8 @@
 
 package com.googlesource.gerrit.plugins.task;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
@@ -36,15 +38,20 @@
 import com.googlesource.gerrit.plugins.task.TaskConfig.Task;
 import com.googlesource.gerrit.plugins.task.TaskConfig.TasksFactory;
 import com.googlesource.gerrit.plugins.task.cli.PatchSetArgument;
+import com.googlesource.gerrit.plugins.task.properties.Properties;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
-import java.util.function.BiFunction;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /**
@@ -55,17 +62,39 @@
  */
 public class TaskTree {
   private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+  @FunctionalInterface
+  public interface NodeFactory {
+    Node create(NodeList parent, Task definition) throws Exception;
+  }
+
+  public static class Statistics {
+    public Object definitionsPerSubSectionCache;
+    public Object definitionsByBranchBySubSectionCache;
+    public Object changesByNamesFactoryQueryCache;
+    public Properties.Statistics properties;
+  }
+
   protected static final String TASK_DIR = "task";
 
   protected final AccountResolver accountResolver;
   protected final AllUsersNameProvider allUsers;
   protected final CurrentUser user;
-  protected final TaskConfigFactory taskFactory;
-  protected final Root root = new Root();
+  protected final PredicateCache predicateCache;
+  protected final MatchCache matchCache;
+  protected final Preloader preloader;
+  protected final NodeList root = new NodeList();
   protected final Provider<ChangeQueryBuilder> changeQueryBuilderProvider;
   protected final Provider<ChangeQueryProcessor> changeQueryProcessorProvider;
+  protected final HitHashMap<String, List<ChangeData>> changesByNamesFactoryQuery =
+      new HitHashMap<>();
+  protected final StatisticsMap<SubSectionKey, List<Task>> definitionsBySubSection =
+      new HitHashMapOfCollection<>();
+  protected final StatisticsMap<SubSectionKey, Map<BranchNameKey, List<Task>>>
+      definitionsByBranchBySubSection = new HitHashMap<>();
 
   protected ChangeData changeData;
+  protected Statistics statistics;
 
   @Inject
   public TaskTree(
@@ -73,266 +102,531 @@
       AllUsersNameProvider allUsers,
       AnonymousUser anonymousUser,
       CurrentUser user,
-      TaskConfigFactory taskFactory,
       Provider<ChangeQueryBuilder> changeQueryBuilderProvider,
-      Provider<ChangeQueryProcessor> changeQueryProcessorProvider) {
+      Provider<ChangeQueryProcessor> changeQueryProcessorProvider,
+      PredicateCache predicateCache,
+      Preloader preloader) {
     this.accountResolver = accountResolver;
     this.allUsers = allUsers;
     this.user = user != null ? user : anonymousUser;
-    this.taskFactory = taskFactory;
     this.changeQueryProcessorProvider = changeQueryProcessorProvider;
     this.changeQueryBuilderProvider = changeQueryBuilderProvider;
+    this.predicateCache = predicateCache;
+    this.matchCache = new MatchCache(predicateCache);
+    this.preloader = preloader;
   }
 
   public void masquerade(PatchSetArgument psa) {
-    taskFactory.masquerade(psa);
+    preloader.masquerade(psa);
   }
 
-  public List<Node> getRootNodes(ChangeData changeData) throws ConfigInvalidException, IOException {
+  public List<Node> getRootNodes(ChangeData changeData)
+      throws ConfigInvalidException, IOException, StorageException {
     this.changeData = changeData;
-    return root.getRootNodes();
-  }
-
-  public Node createNodeOrNull(NodeList parent, Task definition) {
-    try {
-      return new Node(parent, definition);
-    } catch (Exception e) {
-      return null;
-    }
+    root.path = Collections.emptyList();
+    root.duplicateKeys = Collections.emptyList();
+    return root.getSubNodes();
   }
 
   protected class NodeList {
     protected NodeList parent = null;
-    protected LinkedList<String> path = new LinkedList<>();
-    protected List<Node> nodes;
-    protected Set<String> names = new HashSet<>();
+    protected Collection<String> path;
+    protected Collection<String> duplicateKeys;
+    protected Map<TaskKey, Node> cachedNodeByTask = new HashMap<>();
+    protected List<Node> cachedNodes;
 
-    protected void addSubDefinitions(List<Task> defs) {
-      for (Task def : defs) {
-        addSubDefinition(def);
+    protected List<Node> getSubNodes()
+        throws ConfigInvalidException, IOException, StorageException {
+      if (cachedNodes != null) {
+        return refresh(cachedNodes);
       }
+      return cachedNodes = loadSubNodes();
     }
 
-    protected void addSubDefinition(Task def) {
-      addSubDefinition(def, (d, c) -> createNodeOrNull(d, c));
-    }
-
-    protected void addSubDefinition(Task def, BiFunction<NodeList, Task, Node> nodeConstructor) {
-      Node node = null;
-      if (def != null && !path.contains(def.name) && names.add(def.name)) {
-        // path check above detects looping definitions
-        // names check above detects duplicate subtasks
-        node = nodeConstructor.apply(this, def);
-      }
-      nodes.add(node);
+    protected List<Node> loadSubNodes()
+        throws ConfigInvalidException, IOException, StorageException {
+      return new SubNodeFactory().createFromPreloaded(preloader.getRootTasks());
     }
 
     public ChangeData getChangeData() {
-      return parent == null ? TaskTree.this.changeData : parent.getChangeData();
+      return TaskTree.this.changeData;
     }
 
-    protected Properties.Task getProperties() {
-      return Properties.Task.EMPTY_PARENT;
+    protected boolean isTrusted() {
+      return true;
     }
-  }
 
-  protected class Root extends NodeList {
-    public List<Node> getRootNodes() throws ConfigInvalidException, IOException {
-      if (nodes == null) {
-        nodes = new ArrayList<>();
-        addSubDefinitions(getRootDefinitions());
+    protected class SubNodeFactory {
+      protected Set<String> names = new HashSet<>();
+
+      public List<Node> createFromPreloaded(List<Task> defs)
+          throws ConfigInvalidException, StorageException {
+        List<Node> nodes = new ArrayList<>();
+        for (Task def : defs) {
+          nodes.add(createFromPreloaded(def));
+        }
+        return nodes;
       }
-      return nodes;
-    }
 
-    protected List<Task> getRootDefinitions() throws ConfigInvalidException, IOException {
-      return taskFactory.getRootConfig().getRootTasks();
+      public Node createFromPreloaded(Task def) throws ConfigInvalidException, StorageException {
+        return createFromPreloaded(def, (parent, definition) -> new Node(parent, definition));
+      }
+
+      public Node createFromPreloaded(Task def, ChangeData changeData)
+          throws ConfigInvalidException, StorageException {
+        return createFromPreloaded(
+            def,
+            (parent, definition) ->
+                new Node(parent, definition) {
+                  @Override
+                  public ChangeData getChangeData() {
+                    return changeData;
+                  }
+
+                  @Override
+                  public boolean isChange() {
+                    return true;
+                  }
+                });
+      }
+
+      protected Node createFromPreloaded(Task def, NodeFactory nodeFactory)
+          throws ConfigInvalidException, StorageException {
+        if (def != null) {
+          try {
+            Node node = cachedNodeByTask.get(def.key());
+            boolean isRefreshNeeded = node != null;
+            if (node == null) {
+              node = nodeFactory.create(NodeList.this, def);
+            }
+
+            if (names.add(def.name())) {
+              // names check above detects duplicate subtasks
+              if (isRefreshNeeded) {
+                node.refreshTask();
+              }
+              return node;
+            }
+          } catch (Exception e) {
+          }
+        }
+        return createInvalid();
+      }
+
+      protected Node createInvalid() {
+        return new Node().new Invalid();
+      }
     }
   }
 
   public class Node extends NodeList {
-    public final Task task;
-    protected final Properties.Task properties;
+    public class Invalid extends Node {
+      @Override
+      public void refreshTask() throws ConfigInvalidException, StorageException {}
 
-    public Node(NodeList parent, Task definition) throws ConfigInvalidException, StorageException {
-      this.parent = parent;
-      this.task = definition;
-      this.path.addAll(parent.path);
-      this.path.add(definition.name);
-      Preloader.preload(definition);
-      properties = new Properties.Task(getChangeData(), definition, parent.getProperties());
+      @Override
+      public Task getDefinition() {
+        return null;
+      }
     }
 
-    public List<Node> getSubNodes() {
-      if (nodes == null) {
-        nodes = new ArrayList<>();
-        addSubDefinitions();
+    public Task task;
+    public boolean isDuplicate;
+
+    protected Properties.Statistics propertiesStatistics;
+    protected final Properties properties;
+    protected final TaskKey taskKey;
+    protected StatisticsMap<BranchNameKey, List<Node>> nodesByBranch;
+    protected boolean hasUnfilterableSubNodes = false;
+
+    protected Node() { // Only for Invalid
+      taskKey = null;
+      properties = null;
+    }
+
+    public Node(NodeList parent, Task task) throws ConfigInvalidException, StorageException {
+      this.parent = parent;
+      taskKey = task.key();
+      properties = new Properties(this, task);
+      refreshTask();
+    }
+
+    public String key() {
+      return String.valueOf(getChangeData().getId().get()) + TaskConfig.SEP + taskKey;
+    }
+
+    public List<Node> getSubNodes() throws ConfigInvalidException, IOException, StorageException {
+      if (cachedNodes != null) {
+        return refresh(cachedNodes);
+      }
+      List<Node> nodes = loadSubNodes();
+      if (!properties.isSubNodeReloadRequired()) {
+        if (!isChange()) {
+          return cachedNodes = nodes;
+        }
+        definitionsBySubSection.computeIfAbsent(
+            task.key().subSection(),
+            k -> nodes.stream().map(n -> n.getDefinition()).collect(toList()));
+      } else {
+        hasUnfilterableSubNodes = true;
+        cachedNodeByTask.clear();
+        nodes.stream()
+            .filter(n -> !(n instanceof Invalid) && !n.isChange())
+            .forEach(n -> cachedNodeByTask.put(n.task.key(), n));
       }
       return nodes;
     }
 
-    protected void addSubDefinitions() throws StorageException {
-      addSubTaskDefinitions();
-      addSubTasksFactoryDefinitions();
-      addSubFileDefinitions();
-      addExternalDefinitions();
-    }
-
-    protected void addSubTaskDefinitions() {
-      for (String name : task.subTasks) {
-        try {
-          Task def = task.config.getTaskOptional(name);
-          if (def != null) {
-            addSubDefinition(def);
-          }
-        } catch (ConfigInvalidException e) {
-          addSubDefinition(null);
-        }
-      }
-    }
-
-    protected void addSubFileDefinitions() {
-      for (String file : task.subTasksFiles) {
-        try {
-          addSubDefinitions(getTaskDefinitions(task.config.getBranch(), file));
-        } catch (ConfigInvalidException | IOException e) {
-          addSubDefinition(null);
-        }
-      }
-    }
-
-    protected void addExternalDefinitions() throws StorageException {
-      for (String external : task.subTasksExternals) {
-        try {
-          External ext = task.config.getExternal(external);
-          if (ext == null) {
-            addSubDefinition(null);
-          } else {
-            addSubDefinitions(getTaskDefinitions(ext));
-          }
-        } catch (ConfigInvalidException | IOException e) {
-          addSubDefinition(null);
-        }
-      }
-    }
-
-    protected void addSubTasksFactoryDefinitions() throws StorageException {
-      for (String taskFactoryName : task.subTasksFactories) {
-        TasksFactory tasksFactory = task.config.getTasksFactory(taskFactoryName);
-        if (tasksFactory != null) {
-          NamesFactory namesFactory = task.config.getNamesFactory(tasksFactory.namesFactory);
-          if (namesFactory != null && namesFactory.type != null) {
-            new Properties.NamesFactory(namesFactory, getProperties());
-            switch (NamesFactoryType.getNamesFactoryType(namesFactory.type)) {
-              case STATIC:
-                addStaticTypeTasksDefinitions(tasksFactory, namesFactory);
-                continue;
-              case CHANGE:
-                addChangesTypeTaskDefinitions(tasksFactory, namesFactory);
-                continue;
-            }
-          }
-        }
-        addSubDefinition(null);
-      }
-    }
-
-    protected void addStaticTypeTasksDefinitions(
-        TasksFactory tasksFactory, NamesFactory namesFactory) {
-      for (String name : namesFactory.names) {
-        addSubDefinition(task.config.createTask(tasksFactory, name));
-      }
-    }
-
-    protected void addChangesTypeTaskDefinitions(
-        TasksFactory tasksFactory, NamesFactory namesFactory) {
-      try {
-        if (namesFactory.changes != null) {
-          List<ChangeData> changeDataList =
-              changeQueryProcessorProvider
-                  .get()
-                  .query(changeQueryBuilderProvider.get().parse(namesFactory.changes))
-                  .entities();
-          for (ChangeData changeData : changeDataList) {
-            addSubDefinition(
-                task.config.createTask(tasksFactory, changeData.getId().toString()),
-                new ChangeNodeFactory(changeData)::createChangeNodeOrNull);
-          }
-          return;
-        }
-      } catch (StorageException e) {
-        log.atSevere().withCause(e).log("ERROR: running changes query: " + namesFactory.changes);
-      } catch (QueryParseException e) {
-      }
-      addSubDefinition(null);
-    }
-
-    protected List<Task> getTaskDefinitions(External external)
+    public List<Node> getApplicableSubNodes()
         throws ConfigInvalidException, IOException, StorageException {
-      return getTaskDefinitions(resolveUserBranch(external.user), external.file);
-    }
-
-    protected List<Task> getTaskDefinitions(BranchNameKey branch, String file)
-        throws ConfigInvalidException, IOException {
-      return taskFactory
-          .getTaskConfig(branch, resolveTaskFileName(file), task.isTrusted)
-          .getTasks();
+      return hasUnfilterableSubNodes ? getSubNodes() : new ApplicableNodeFilter().getSubNodes();
     }
 
     @Override
-    protected Properties.Task getProperties() {
-      return properties;
-    }
-
-    protected String resolveTaskFileName(String file) throws ConfigInvalidException {
-      if (file == null) {
-        throw new ConfigInvalidException("External file not defined");
-      }
-      Path p = Paths.get(TASK_DIR, file);
-      if (!p.startsWith(TASK_DIR)) {
-        throw new ConfigInvalidException("task file not under " + TASK_DIR + " directory: " + file);
-      }
-      return p.toString();
-    }
-
-    protected BranchNameKey resolveUserBranch(String user)
+    protected List<Node> loadSubNodes()
         throws ConfigInvalidException, IOException, StorageException {
-      if (user == null) {
-        throw new ConfigInvalidException("External user not defined");
+      List<Task> cachedDefinitions = definitionsBySubSection.get(task.key().subSection());
+      if (cachedDefinitions != null) {
+        return new SubNodeFactory().createFromPreloaded(cachedDefinitions);
       }
-      Account.Id acct;
-      try {
-        acct = accountResolver.resolve(user).asUnique().account().id();
-      } catch (UnprocessableEntityException e) {
-        throw new ConfigInvalidException("Cannot resolve user: " + user);
+      List<Node> nodes = new SubNodeAdder().getSubNodes();
+      properties.expansionComplete();
+      return nodes;
+    }
+
+    /* The task needs to be refreshed before a node is used, however
+    subNode refreshing can wait until they are fetched since they may
+    not be needed. */
+    public void refreshTask() throws ConfigInvalidException, StorageException {
+      this.path = new LinkedList<>(parent.path);
+      String key = key();
+      isDuplicate = path.contains(key);
+      path.add(key);
+
+      if (statistics != null) {
+        properties.setStatisticsConsumer(
+            s -> statistics.properties = (propertiesStatistics = s).sum(statistics.properties));
       }
-      return BranchNameKey.create(allUsers.get(), RefNames.refsUsers(acct));
+      this.task = properties.getTask(getChangeData());
+
+      this.duplicateKeys = new LinkedList<>(parent.duplicateKeys);
+      if (task.duplicateKey != null) {
+        isDuplicate |= duplicateKeys.contains(task.duplicateKey);
+        duplicateKeys.add(task.duplicateKey);
+      }
+    }
+
+    public Properties getParentProperties() {
+      return (parent instanceof Node) ? ((Node) parent).properties : Properties.EMPTY;
+    }
+
+    @Override
+    protected boolean isTrusted() {
+      return parent.isTrusted() && !task.isMasqueraded;
+    }
+
+    @Override
+    public ChangeData getChangeData() {
+      return parent.getChangeData();
+    }
+
+    public Task getDefinition() {
+      return properties.isTaskRefreshRequired() ? properties.origTask : task;
+    }
+
+    public boolean isChange() {
+      return false;
+    }
+
+    public boolean match(String query) throws StorageException, QueryParseException {
+      return matchCache.match(getChangeData(), query);
+    }
+
+    public Boolean matchOrNull(String query) {
+      return matchCache.matchOrNull(getChangeData(), query);
+    }
+
+    protected class SubNodeAdder {
+      protected List<Node> nodes = new ArrayList<>();
+      protected SubNodeFactory factory = new SubNodeFactory();
+
+      public List<Node> getSubNodes() throws ConfigInvalidException, IOException, StorageException {
+        addSubTasks();
+        addSubTasksFactoryTasks();
+        addSubTasksFiles();
+        addSubTasksExternals();
+        return nodes;
+      }
+
+      protected void addSubTasks() throws ConfigInvalidException, IOException, StorageException {
+        for (String expression : task.subTasks) {
+          try {
+            Optional<Task> def =
+                preloader.getOptionalTask(new TaskExpression(task.file(), expression));
+            if (def.isPresent()) {
+              addPreloaded(def.get());
+            }
+          } catch (ConfigInvalidException e) {
+            addInvalidNode();
+          }
+        }
+      }
+
+      protected void addSubTasksFiles() throws ConfigInvalidException, StorageException {
+        for (String file : task.subTasksFiles) {
+          try {
+            addPreloaded(
+                preloader.getTasks(FileKey.create(task.key().branch(), resolveTaskFileName(file))));
+          } catch (ConfigInvalidException | IOException e) {
+            addInvalidNode();
+          }
+        }
+      }
+
+      protected void addSubTasksExternals() throws ConfigInvalidException, StorageException {
+        for (String external : task.subTasksExternals) {
+          try {
+            External ext = task.config.getExternal(external);
+            if (ext == null) {
+              addInvalidNode();
+            } else {
+              addPreloaded(getPreloadedTasks(ext));
+            }
+          } catch (ConfigInvalidException | IOException e) {
+            addInvalidNode();
+          }
+        }
+      }
+
+      protected void addSubTasksFactoryTasks()
+          throws ConfigInvalidException, IOException, StorageException {
+        for (String tasksFactoryName : task.subTasksFactories) {
+          TasksFactory tasksFactory = task.config.getTasksFactory(tasksFactoryName);
+          if (tasksFactory != null) {
+            NamesFactory namesFactory = task.config.getNamesFactory(tasksFactory.namesFactory);
+            if (namesFactory != null && namesFactory.type != null) {
+              namesFactory = properties.getNamesFactory(namesFactory);
+              switch (NamesFactoryType.getNamesFactoryType(namesFactory.type)) {
+                case STATIC:
+                  addStaticTypeTasks(tasksFactory, namesFactory);
+                  continue;
+                case CHANGE:
+                  addChangeTypeTasks(tasksFactory, namesFactory);
+                  continue;
+              }
+            }
+          }
+          addInvalidNode();
+        }
+      }
+
+      protected void addStaticTypeTasks(TasksFactory tasksFactory, NamesFactory namesFactory)
+          throws ConfigInvalidException, IOException, StorageException {
+        for (String name : namesFactory.names) {
+          addPreloaded(preloader.preload(task.config.new Task(tasksFactory, name)));
+        }
+      }
+
+      protected void addChangeTypeTasks(TasksFactory tasksFactory, NamesFactory namesFactory)
+          throws ConfigInvalidException, IOException, StorageException {
+        try {
+          if (namesFactory.changes != null) {
+            for (ChangeData changeData : query(namesFactory.changes)) {
+              addPreloaded(
+                  preloader.preload(
+                      task.config.new Task(tasksFactory, changeData.getId().toString())),
+                  changeData);
+            }
+            return;
+          }
+        } catch (StorageException e) {
+          log.atSevere().withCause(e).log("ERROR: running changes query: " + namesFactory.changes);
+        } catch (QueryParseException e) {
+        }
+        addInvalidNode();
+      }
+
+      public void addPreloaded(List<Task> defs) throws ConfigInvalidException, StorageException {
+        nodes.addAll(factory.createFromPreloaded(defs));
+      }
+
+      public void addPreloaded(Task def, ChangeData changeData)
+          throws ConfigInvalidException, StorageException {
+        nodes.add(factory.createFromPreloaded(def, changeData));
+      }
+
+      public void addPreloaded(Task def) throws ConfigInvalidException, StorageException {
+        nodes.add(factory.createFromPreloaded(def));
+      }
+
+      public void addInvalidNode() {
+        nodes.add(factory.createInvalid());
+      }
+
+      protected List<Task> getPreloadedTasks(External external)
+          throws ConfigInvalidException, IOException, StorageException {
+        return preloader.getTasks(
+            FileKey.create(resolveUserBranch(external.user), resolveTaskFileName(external.file)));
+      }
+    }
+
+    public class ApplicableNodeFilter {
+      protected BranchNameKey branch = getChangeData().change().getDest();
+      protected SubSectionKey subSection = task.key.subSection();
+      protected Map<BranchNameKey, List<Task>> definitionsByBranch =
+          definitionsByBranchBySubSection.get(subSection);
+
+      public ApplicableNodeFilter() throws ConfigInvalidException, IOException, StorageException {}
+
+      public List<Node> getSubNodes() throws ConfigInvalidException, IOException, StorageException {
+        if (nodesByBranch != null) {
+          List<Node> nodes = nodesByBranch.get(branch);
+          if (nodes != null) {
+            return refresh(nodes);
+          }
+        }
+        if (definitionsByBranch != null) {
+          List<Task> branchDefinitions = definitionsByBranch.get(branch);
+          if (branchDefinitions != null) {
+            return new SubNodeFactory().createFromPreloaded(branchDefinitions);
+          }
+        }
+        List<Node> nodes = Node.this.getSubNodes();
+        if (isChange()
+            && definitionsByBranch == null
+            && definitionsByBranchBySubSection.containsKey(subSection)) {
+          hasUnfilterableSubNodes = true;
+        }
+
+        if (!hasUnfilterableSubNodes && !nodes.isEmpty()) {
+          Optional<List<Node>> filterable = getOptionalApplicableForBranch(nodes);
+          if (filterable.isPresent()) {
+            if (!isChange()) {
+              if (nodesByBranch == null) {
+                nodesByBranch = new HitHashMapOfCollection<>(statistics != null);
+              }
+              nodesByBranch.put(branch, filterable.get());
+            } else {
+              if (definitionsByBranch == null) {
+                definitionsByBranch = new HitHashMap<>(statistics != null);
+                definitionsByBranchBySubSection.put(subSection, definitionsByBranch);
+              }
+              definitionsByBranch.put(
+                  branch,
+                  filterable.get().stream().map(node -> node.getDefinition()).collect(toList()));
+            }
+            return filterable.get();
+          }
+          hasUnfilterableSubNodes = true;
+          if (isChange()) {
+            definitionsByBranchBySubSection.put(subSection, null);
+          }
+        }
+        return nodes;
+      }
+
+      protected Optional<List<Node>> getOptionalApplicableForBranch(List<Node> nodes)
+          throws ConfigInvalidException, IOException, StorageException {
+        int filterable = 0;
+        List<Node> applicableNodes = new ArrayList<>();
+        for (Node node : nodes) {
+          if (node instanceof Invalid) {
+            filterable++;
+          } else if (isApplicableCacheableByBranch(node)) {
+            filterable++;
+            try {
+              if (!node.match(node.task.applicable)) {
+                // Correctness will not be affected if more nodes are added than necessary
+                // (i.e. if isApplicableCacheableByBranch() does not realize a Node is cacheable
+                // based on its Branch), but it is incorrect to filter out a Node now that could
+                // later be applicable when a property, other than its Change's destination, is
+                // altered.
+                continue;
+              }
+            } catch (QueryParseException e) {
+            }
+          }
+          applicableNodes.add(node);
+        }
+        // Simple heuristic to determine whether storing the filtered nodes is worth it. There
+        // is minor evidence to suggest that storing a large list actually hurts performance.
+        return (filterable > nodes.size() / 2) ? Optional.of(applicableNodes) : Optional.empty();
+      }
+
+      protected boolean isApplicableCacheableByBranch(Node node) {
+        String applicable = node.task.applicable;
+        if (node.properties.isApplicableRefreshRequired()) {
+          return false;
+        }
+        try {
+          return predicateCache.isCacheableByBranch(applicable);
+        } catch (QueryParseException e) {
+          return false;
+        }
+      }
     }
   }
 
-  public class ChangeNodeFactory {
-    public class ChangeNode extends Node {
-      public ChangeNode(NodeList parent, Task definition) throws ConfigInvalidException {
-        super(parent, definition);
-      }
-
-      public ChangeData getChangeData() {
-        return ChangeNodeFactory.this.changeData;
-      }
+  protected String resolveTaskFileName(String file) throws ConfigInvalidException {
+    if (file == null) {
+      throw new ConfigInvalidException("External file not defined");
     }
-
-    protected ChangeData changeData;
-
-    public ChangeNodeFactory(ChangeData changeData) {
-      this.changeData = changeData;
+    Path p = Paths.get(TASK_DIR, file);
+    if (!p.startsWith(TASK_DIR)) {
+      throw new ConfigInvalidException("task file not under " + TASK_DIR + " directory: " + file);
     }
+    return p.toString();
+  }
 
-    public ChangeNode createChangeNodeOrNull(NodeList parent, Task definition) {
-      try {
-        return new ChangeNode(parent, definition);
-      } catch (Exception e) {
-        return null;
-      }
+  protected BranchNameKey resolveUserBranch(String user)
+      throws ConfigInvalidException, IOException, StorageException {
+    if (user == null) {
+      throw new ConfigInvalidException("External user not defined");
     }
+    Account.Id acct;
+    try {
+      acct = accountResolver.resolve(user).asUnique().account().id();
+    } catch (UnprocessableEntityException e) {
+      throw new ConfigInvalidException("Cannot resolve user: " + user);
+    }
+    return BranchNameKey.create(allUsers.get(), RefNames.refsUsers(acct));
+  }
+
+  public List<ChangeData> query(String query) throws StorageException, QueryParseException {
+    List<ChangeData> changeDataList = changesByNamesFactoryQuery.getOrStartLoad(query);
+    if (changeDataList == null) {
+      changeDataList =
+          changeQueryProcessorProvider
+              .get()
+              .query(changeQueryBuilderProvider.get().parse(query))
+              .entities();
+      changesByNamesFactoryQuery.put(query, changeDataList);
+    }
+    return changeDataList;
+  }
+
+  public void initStatistics() {
+    statistics = new Statistics();
+    definitionsBySubSection.initStatistics();
+    definitionsByBranchBySubSection.initStatistics();
+    changesByNamesFactoryQuery.initStatistics();
+  }
+
+  public Statistics getStatistics() {
+    if (statistics != null) {
+      statistics.definitionsPerSubSectionCache = definitionsBySubSection.getStatistics();
+      statistics.definitionsByBranchBySubSectionCache =
+          definitionsByBranchBySubSection.getStatistics();
+      statistics.changesByNamesFactoryQueryCache = changesByNamesFactoryQuery.getStatistics();
+    }
+    return statistics;
+  }
+
+  protected static List<Node> refresh(List<Node> nodes)
+      throws ConfigInvalidException, StorageException {
+    for (Node node : nodes) {
+      node.refreshTask();
+    }
+    return nodes;
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TracksStatistics.java b/src/main/java/com/googlesource/gerrit/plugins/task/TracksStatistics.java
new file mode 100644
index 0000000..d7d0651
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TracksStatistics.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task;
+
+public interface TracksStatistics {
+  void initStatistics();
+
+  void ensureStatistics();
+
+  Object getStatistics();
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/properties/AbstractExpander.java b/src/main/java/com/googlesource/gerrit/plugins/task/properties/AbstractExpander.java
new file mode 100644
index 0000000..c970cec
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/properties/AbstractExpander.java
@@ -0,0 +1,165 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task.properties;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+/**
+ * Use to expand properties like ${property} in Strings into their values.
+ *
+ * <p>Given some property name/value associations like this:
+ *
+ * <p><code>
+ * "animal" -> "fox"
+ * "bar" -> "foo"
+ * "obstacle" -> "fence"
+ * </code>
+ *
+ * <p>a String like: <code>"The brown ${animal} jumped over the ${obstacle}."</code>
+ *
+ * <p>will expand to: <code>"The brown fox jumped over the fence."</code> This class is meant to be
+ * used as a building block for other full featured expanders and thus must be overriden to provide
+ * the name/value associations via the getValueForName() method.
+ */
+public abstract class AbstractExpander {
+  protected Consumer<Matcher.Statistics> statisticsConsumer;
+
+  public void setStatisticsConsumer(Consumer<Matcher.Statistics> statisticsConsumer) {
+    this.statisticsConsumer = statisticsConsumer;
+  }
+
+  /**
+   * Returns expanded object if property found in the Strings in the object's Fields (except the
+   * excluded ones). Returns same object if no expansions occurred.
+   */
+  public <C extends Cloneable> C expand(C object, Set<String> excludedFieldNames) {
+    return expand(new CopyOnWrite.CloneOnWrite<>(object), excludedFieldNames);
+  }
+
+  /**
+   * Returns expanded object if property found in the Strings in the object's Fields (except the
+   * excluded ones). Returns same object if no expansions occurred.
+   */
+  public <T> T expand(T object, Function<T, T> copier, Set<String> excludedFieldNames) {
+    return expand(new CopyOnWrite<>(object, copier), excludedFieldNames);
+  }
+
+  /**
+   * Returns expanded object if property found in the Strings in the object's Fields (except the
+   * excluded ones). Returns same object if no expansions occurred.
+   */
+  public <T> T expand(CopyOnWrite<T> cow, Set<String> excludedFieldNames) {
+    for (Field field : cow.getOriginal().getClass().getFields()) {
+      if (!excludedFieldNames.contains(field.getName())) {
+        expand(cow, field);
+      }
+    }
+    return cow.getForRead();
+  }
+
+  /**
+   * Returns expanded object if property found in the fieldName Field if it is a String, or in the
+   * List's Strings if it is a List. Returns same object if no expansions occurred.
+   */
+  public <T> T expand(CopyOnWrite<T> cow, String fieldName) {
+    try {
+      return expand(cow, cow.getOriginal().getClass().getField(fieldName));
+    } catch (NoSuchFieldException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * Returns expanded object if property found in the Field if it is a String, or in the List's
+   * Strings if it is a List. Returns same object if no expansions occurred.
+   */
+  public <T> T expand(CopyOnWrite<T> cow, Field field) {
+    try {
+      field.setAccessible(true);
+      Object o = field.get(cow.getOriginal());
+      if (o instanceof String) {
+        String expanded = expandText((String) o);
+        if (expanded != o) {
+          field.set(cow.getForWrite(), expanded);
+        }
+      } else if (o instanceof List) {
+        @SuppressWarnings("unchecked")
+        List<String> forceCheck = List.class.cast(o);
+        List<String> expanded = expand(forceCheck);
+        if (expanded != o) {
+          field.set(cow.getForWrite(), expanded);
+        }
+      }
+    } catch (IllegalAccessException e) {
+      throw new RuntimeException(e);
+    }
+    return cow.getForRead();
+  }
+
+  /**
+   * Returns expanded unmodifiable List if property found. Returns same object if no expansions
+   * occurred.
+   */
+  public List<String> expand(List<String> list) {
+    if (list != null) {
+      boolean hasProperty = false;
+      List<String> expandedList = new ArrayList<>(list.size());
+      for (String value : list) {
+        String expanded = expandText(value);
+        hasProperty = hasProperty || value != expanded;
+        expandedList.add(expanded);
+      }
+      return hasProperty ? Collections.unmodifiableList(expandedList) : list;
+    }
+    return null;
+  }
+
+  /**
+   * Expand all properties (${property_name} -> property_value) in the given text. Returns same
+   * object if no expansions occurred.
+   */
+  public String expandText(String text) {
+    if (text == null) {
+      return null;
+    }
+    Matcher m = new Matcher(text);
+    m.setStatisticsConsumer(statisticsConsumer);
+    if (!m.find()) {
+      return text;
+    }
+    StringBuffer out = new StringBuffer();
+    do {
+      m.appendValue(out, getValueForName(m.getName()));
+    } while (m.find());
+    m.appendTail(out);
+    return out.toString();
+  }
+
+  /**
+   * Get the replacement value for the property identified by name
+   *
+   * @param name of the property to get the replacement value for
+   * @return the replacement value. Since the expandText() method alwyas needs a String to replace
+   *     '${property-name}' reference with, even when the property does not exist, this will never
+   *     return null, instead it will returns the empty string if the property is not found.
+   */
+  protected abstract String getValueForName(String name);
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/properties/CopyOnWrite.java b/src/main/java/com/googlesource/gerrit/plugins/task/properties/CopyOnWrite.java
new file mode 100644
index 0000000..7d94438
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/properties/CopyOnWrite.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task.properties;
+
+import com.googlesource.gerrit.plugins.task.StopWatch;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.LongConsumer;
+
+public class CopyOnWrite<T> {
+  public static class CloneOnWrite<C extends Cloneable> extends CopyOnWrite<C> {
+    public CloneOnWrite(C cloneable) {
+      super(cloneable, copier(cloneable));
+    }
+  }
+
+  public static <C extends Cloneable> Function<C, C> copier(C cloneable) {
+    return c -> clone(c);
+  }
+
+  @SuppressWarnings("unchecked")
+  public static <C extends Cloneable> C clone(C cloneable) {
+    try {
+      for (Class<?> cls = cloneable.getClass(); cls != null; cls = cls.getSuperclass()) {
+        Optional<Method> optional = getOptionalDeclaredMethod(cls, "clone");
+        if (optional.isPresent()) {
+          Method clone = optional.get();
+          clone.setAccessible(true);
+          return (C) cloneable.getClass().cast(clone.invoke(cloneable));
+        }
+      }
+      throw new RuntimeException("Cannot find clone() method");
+    } catch (SecurityException
+        | IllegalAccessException
+        | IllegalArgumentException
+        | InvocationTargetException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  /**
+   * A faster getDeclaredMethod() without exceptions. The original apparently does a linear search
+   * anyway, and it is significantly slower when it throws NoSuchMethodExceptions.
+   */
+  public static Optional<Method> getOptionalDeclaredMethod(
+      Class<?> cls, String name, Class<?>... parameterTypes) {
+    for (Method method : cls.getDeclaredMethods()) {
+      if (method.getName().equals(name)
+          && Arrays.equals(method.getParameterTypes(), parameterTypes)) {
+        return Optional.of(method);
+      }
+    }
+    return Optional.empty();
+  }
+
+  protected Function<T, T> copier;
+  protected StopWatch stopWatch = new StopWatch();
+  protected T original;
+  protected T copy;
+
+  public CopyOnWrite(T original, Function<T, T> copier) {
+    this.original = original;
+    this.copier = copier;
+  }
+
+  protected void setNanosecondsConsumer(LongConsumer nanosConsumer) {
+    stopWatch.setConsumer(nanosConsumer);
+  }
+
+  public T getOriginal() {
+    return original;
+  }
+
+  public T getForRead() {
+    return isCopy() ? copy : original;
+  }
+
+  public T getForWrite() {
+    if (!isCopy()) {
+      stopWatch.run(() -> copy = copier.apply(original));
+    }
+    return copy;
+  }
+
+  public boolean isCopy() {
+    return copy != null;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/properties/Expander.java b/src/main/java/com/googlesource/gerrit/plugins/task/properties/Expander.java
new file mode 100644
index 0000000..df817a1
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/properties/Expander.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task.properties;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+/**
+ * Use to expand properties whose values may contain other references to properties.
+ *
+ * <p>Using a recursive expansion approach makes order of evaluation unimportant as long as there
+ * are no looping definitions.
+ *
+ * <p>Given some property name/value asssociations defined like this:
+ *
+ * <p><code>
+ * valueByName.put("obstacle", "fence");
+ * valueByName.put("action", "jumped over the ${obstacle}");
+ * </code>
+ *
+ * <p>a String like: <code>"The brown fox ${action}."</code>
+ *
+ * <p>will expand to: <code>"The brown fox jumped over the fence."</code>
+ */
+public class Expander extends AbstractExpander {
+  protected final Function<String, String> loadingFunction;
+  protected final Map<String, String> valueByName = new HashMap<>();
+  protected final Set<String> expanding = new HashSet<>();
+
+  public Expander(Function<String, String> loadingFunction) {
+    this.loadingFunction = loadingFunction;
+  }
+
+  /**
+   * Expand all properties (${property_name} -> property_value) in the given text. Returns same
+   * object if no expansions occurred.
+   */
+  public Map<String, String> expand(Map<String, String> map) {
+    if (map != null) {
+      boolean hasProperty = false;
+      Map<String, String> expandedMap = new HashMap<>(map.size());
+      for (Map.Entry<String, String> e : map.entrySet()) {
+        String name = e.getKey();
+        String value = e.getValue();
+        String expanded = getValueForName(name);
+        hasProperty = hasProperty || value != expanded;
+        expandedMap.put(name, expanded);
+      }
+      return hasProperty ? Collections.unmodifiableMap(expandedMap) : map;
+    }
+    return null;
+  }
+
+  @Override
+  public String getValueForName(String name) {
+    String value = valueByName.get(name);
+    if (value != null) {
+      return value;
+    }
+    value = loadingFunction.apply(name);
+    if (value == null) {
+      value = "";
+    } else if (!value.isEmpty()) {
+      if (!expanding.add(name)) {
+        throw new RuntimeException("Looping property definitions.");
+      }
+      value = expandText(value);
+      expanding.remove(name);
+    }
+    valueByName.put(name, value);
+    return value;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/properties/Loader.java b/src/main/java/com/googlesource/gerrit/plugins/task/properties/Loader.java
new file mode 100644
index 0000000..05120b3
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/properties/Loader.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task.properties;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.googlesource.gerrit.plugins.task.TaskConfig.Task;
+import java.util.function.Function;
+
+public class Loader {
+  protected final Task task;
+  protected final ChangeData changeData;
+  protected final Function<String, String> inherritedMapper;
+  protected Change change;
+  protected boolean isInheritedPropertyLoaded;
+
+  public Loader(Task task, ChangeData changeData, Function<String, String> inherritedMapper) {
+    this.task = task;
+    this.changeData = changeData;
+    this.inherritedMapper = inherritedMapper;
+  }
+
+  public boolean isNonTaskDefinedPropertyLoaded() {
+    return change != null || isInheritedPropertyLoaded;
+  }
+
+  public String load(String name) throws StorageException {
+    if (name.startsWith("_")) {
+      return internal(name);
+    }
+    String value = task.exported.get(name);
+    if (value == null) {
+      value = task.properties.get(name);
+      if (value == null) {
+        value = inherritedMapper.apply(name);
+        if (!value.isEmpty()) {
+          isInheritedPropertyLoaded = true;
+        }
+      }
+    }
+    return value;
+  }
+
+  protected String internal(String name) throws StorageException {
+    if ("_name".equals(name)) {
+      return task.name();
+    }
+    String changeProp = name.replace("_change_", "");
+    if (changeProp != name) {
+      return change(changeProp);
+    }
+    return "";
+  }
+
+  protected String change(String changeProp) throws StorageException {
+    switch (changeProp) {
+      case "number":
+        return String.valueOf(change().getId().get());
+      case "id":
+        return change().getKey().get();
+      case "project":
+        return change().getProject().get();
+      case "branch":
+        return change().getDest().branch();
+      case "status":
+        return change().getStatus().toString();
+      case "topic":
+        return change().getTopic();
+      default:
+        return "";
+    }
+  }
+
+  protected Change change() {
+    if (change == null) {
+      change = changeData.change();
+    }
+    return change;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/properties/Matcher.java b/src/main/java/com/googlesource/gerrit/plugins/task/properties/Matcher.java
new file mode 100644
index 0000000..dc7bc18
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/properties/Matcher.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task.properties;
+
+import com.googlesource.gerrit.plugins.task.StopWatch;
+import java.util.function.Consumer;
+
+/** A handcrafted properties Matcher which has an API similar to an RE Matcher, but is faster. */
+public class Matcher {
+  public static class Statistics {
+    public long appendNanoseconds;
+    public long findNanoseconds;
+
+    public Statistics sum(Statistics other) {
+      if (other == null) {
+        return this;
+      }
+      Statistics statistics = new Statistics();
+      statistics.appendNanoseconds = appendNanoseconds + other.appendNanoseconds;
+      statistics.findNanoseconds = findNanoseconds + other.findNanoseconds;
+      return statistics;
+    }
+  }
+
+  protected String text;
+  protected int start;
+  protected int nameStart;
+  protected int end;
+  protected int cursor;
+
+  protected Statistics statistics;
+  protected StopWatch appendNanoseconds = new StopWatch();
+  protected StopWatch findNanoseconds = new StopWatch();
+
+  public Matcher(String text) {
+    this.text = text;
+  }
+
+  protected void setStatisticsConsumer(Consumer<Statistics> statisticsConsumer) {
+    if (statisticsConsumer != null) {
+      statistics = new Statistics();
+      statisticsConsumer.accept(statistics);
+      appendNanoseconds.setConsumer(ns -> statistics.appendNanoseconds = ns);
+      findNanoseconds.setConsumer(ns -> statistics.findNanoseconds = ns);
+    }
+  }
+
+  public boolean find() {
+    findNanoseconds.start();
+    start = text.indexOf("${", cursor);
+    nameStart = start + 2;
+    if (start < 0 || text.length() < nameStart + 1) {
+      findNanoseconds.stop();
+      return false;
+    }
+    end = text.indexOf('}', nameStart);
+    boolean found = end >= 0;
+    findNanoseconds.stop();
+    return found;
+  }
+
+  public String getName() {
+    return text.substring(nameStart, end);
+  }
+
+  public void appendValue(StringBuffer buffer, String value) {
+    appendNanoseconds.start();
+    if (start > cursor) {
+      buffer.append(text.substring(cursor, start));
+    }
+    buffer.append(value);
+    cursor = end + 1;
+    appendNanoseconds.stop();
+  }
+
+  public void appendTail(StringBuffer buffer) {
+    appendNanoseconds.start();
+    if (cursor < text.length()) {
+      buffer.append(text.substring(cursor));
+      cursor = text.length();
+    }
+    appendNanoseconds.stop();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/properties/Properties.java b/src/main/java/com/googlesource/gerrit/plugins/task/properties/Properties.java
new file mode 100644
index 0000000..5fc6c12
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/properties/Properties.java
@@ -0,0 +1,150 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task.properties;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.googlesource.gerrit.plugins.task.StopWatch;
+import com.googlesource.gerrit.plugins.task.TaskConfig;
+import com.googlesource.gerrit.plugins.task.TaskConfig.NamesFactory;
+import com.googlesource.gerrit.plugins.task.TaskConfig.Task;
+import com.googlesource.gerrit.plugins.task.TaskTree;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+/** Use to expand properties like ${_name} in the text of various definitions. */
+public class Properties {
+  public static class Statistics {
+    public long getTaskNanoseconds;
+    public long copierNanoseconds;
+    public Matcher.Statistics matcher;
+
+    public Statistics sum(Statistics other) {
+      if (other == null) {
+        return this;
+      }
+      Statistics statistics = new Statistics();
+      statistics.getTaskNanoseconds = getTaskNanoseconds + other.getTaskNanoseconds;
+      statistics.copierNanoseconds = copierNanoseconds + other.copierNanoseconds;
+      statistics.matcher = matcher == null ? other.matcher : matcher.sum(other.matcher);
+      return statistics;
+    }
+
+    protected StopWatch getTask;
+  }
+
+  public static final Properties EMPTY =
+      new Properties() {
+        @Override
+        protected Function<String, String> getParentMapper() {
+          return n -> "";
+        }
+      };
+
+  public final Task origTask;
+  protected final TaskTree.Node node;
+  protected final CopyOnWrite<Task> task;
+  protected Statistics statistics;
+  protected Consumer<Statistics> statisticsConsumer;
+  protected Consumer<Matcher.Statistics> matcherStatisticsConsumer;
+  protected Expander expander;
+  protected Loader loader;
+  protected boolean init = true;
+  protected boolean isTaskRefreshRequired;
+  protected boolean isApplicableRefreshRequired;
+  protected boolean isSubNodeReloadRequired;
+
+  public Properties() {
+    this(null, null);
+    expander = new Expander(n -> "");
+  }
+
+  public Properties(TaskTree.Node node, Task origTask) {
+    this.node = node;
+    this.origTask = origTask;
+    task = new CopyOnWrite.CloneOnWrite<>(origTask);
+  }
+
+  /** Use to expand properties specifically for Tasks. */
+  public Task getTask(ChangeData changeData) throws StorageException {
+    if (statistics != null) {
+      statistics.getTask = new StopWatch().enable().start();
+    }
+    loader = new Loader(origTask, changeData, getParentMapper());
+    expander = new Expander(n -> loader.load(n));
+    expander.setStatisticsConsumer(matcherStatisticsConsumer);
+    if (isTaskRefreshRequired || init) {
+      expander.expand(task, TaskConfig.KEY_APPLICABLE);
+      isApplicableRefreshRequired = loader.isNonTaskDefinedPropertyLoaded();
+
+      expander.expand(task, ImmutableSet.of(TaskConfig.KEY_APPLICABLE, TaskConfig.KEY_NAME));
+
+      Map<String, String> exported = expander.expand(origTask.exported);
+      if (exported != origTask.exported) {
+        task.getForWrite().exported = exported;
+      }
+
+      if (init) {
+        init = false;
+        isTaskRefreshRequired = loader.isNonTaskDefinedPropertyLoaded();
+      }
+    }
+    if (statisticsConsumer != null) {
+      statistics.getTaskNanoseconds = statistics.getTask.stop().get();
+      statistics.getTask = null;
+      statisticsConsumer.accept(statistics);
+    }
+    return task.getForRead();
+  }
+
+  public void setStatisticsConsumer(Consumer<Statistics> statisticsConsumer) {
+    if (statisticsConsumer != null) {
+      this.statisticsConsumer = statisticsConsumer;
+      statistics = new Statistics();
+      matcherStatisticsConsumer = s -> statistics.matcher = s;
+      task.setNanosecondsConsumer(ns -> statistics.copierNanoseconds = ns);
+    }
+  }
+
+  // To detect NamesFactories dependent on non task defined properties, the checking must be
+  // done after subnodes are fully loaded, which unfortunately happens after getTask() is
+  // called, therefore this must be called after all subnodes have been loaded.
+  public void expansionComplete() {
+    isSubNodeReloadRequired = loader.isNonTaskDefinedPropertyLoaded();
+  }
+
+  public boolean isApplicableRefreshRequired() {
+    return isApplicableRefreshRequired;
+  }
+
+  public boolean isTaskRefreshRequired() {
+    return isTaskRefreshRequired;
+  }
+
+  public boolean isSubNodeReloadRequired() {
+    return isSubNodeReloadRequired;
+  }
+
+  /** Use to expand properties specifically for NamesFactories. */
+  public NamesFactory getNamesFactory(NamesFactory namesFactory) {
+    return expander.expand(namesFactory, ImmutableSet.of(TaskConfig.KEY_TYPE));
+  }
+
+  protected Function<String, String> getParentMapper() {
+    return n -> node.getParentProperties().expander.getValueForName(n);
+  }
+}
diff --git a/src/main/resources/Documentation/config-gerrit.md b/src/main/resources/Documentation/config-gerrit.md
new file mode 100644
index 0000000..6f0b0a5
--- /dev/null
+++ b/src/main/resources/Documentation/config-gerrit.md
@@ -0,0 +1,25 @@
+# Admin User Guide - Configuration
+
+## File `etc/gerrit.config`
+
+The file `'$site_path'/etc/gerrit.config` is a Git-style config file
+that controls many host specific settings for Gerrit.
+
+### Section @PLUGIN@ "cacheable-predicates"
+
+The @PLUGIN@.cacheable-predicates section configures Change Predicate
+optimizations which the @PLUGIN@ plugin may use when evaluating tasks.
+
+#### @PLUGIN@.cacheable-predicates.byBranch-className
+
+The value set with this key specifies a fully qualified class name
+of a Predicate which can be assumed to always return the same match
+result to all Changes destined for the same project/branch
+combinations. This key may be specified more than once.
+
+Example:
+
+```
+[@PLUGIN@ "cacheable-predicates"]
+        byBranch-className = com.google.gerrit.server.query.change.BranchSetPredicate
+```
\ No newline at end of file
diff --git a/src/main/resources/Documentation/task.md b/src/main/resources/Documentation/task.md
index fa5e834..ce2dd40 100644
--- a/src/main/resources/Documentation/task.md
+++ b/src/main/resources/Documentation/task.md
@@ -51,11 +51,17 @@
 completes.
 
 A task with a `WAITING` status is not yet ready to execute. A task in this
-state is blocked by its subtasks which are not yet in the `PASS` state.
+state is blocked by its subtasks which are not yet in the `PASS` or `DUPLICATE`
+state.
 
 A task with a `READY` status is ready to be executed. All of its subtasks are
 in the `PASS` state.
 
+A task with a `DUPLICATE` status has the same task key as one of its ancestors.
+Task keys are generally made up of the canonical task name and the change to
+which it applies. To avoid infinite loops, subtasks are ignored on duplicate
+tasks.
+
 A task with a `PASS` status meets all the criteria for `READY`, and has
 executed and was successful.
 
@@ -231,6 +237,48 @@
     subtasks-file = common.config  # references the file named task/common.config
 ```
 
+`duplicate-key`
+
+: This key defines an identifier to help identify tasks which should be
+considered duplicates even if they are not exact duplicates. When the task
+plugin encounters a task with the same duplicate-key as one of its
+ancestors, it will be considered a duplicate of that ancestor. Tasks such as
+a starting task and a looping tasks-factory that preload the same base task
+are not exact duplicates, yet they may logically represent duplicates. In
+this case, defining a `duplicate-key` on the base task which is preloaded
+from two different places (usually a root and a change tasks-factory), will
+ensure that any loops are halted once the original change is reached. Without
+a duplicate-key, the walking would generally walk one task further than
+desired.
+
+Outlined below is a simple way to walk a change's git dependencies in the
+task plugin. While Git does not allow loops in commit histories, sometimes
+in Gerrit when changes get rebased, it can cause loops (because Gerrit
+sometimes tracks outdated dependencies). The use of the duplicate-key
+below results in the loop being detected when you would expect it to be.
+
+Example:
+
+```
+[root "git dependencies"]
+    applicable = status:new
+    preload-task = git dependencies
+
+[task "git dependencies"]
+    fail = -status:new
+    fail-hint = [${_change_status}] dependency needs to be OPEN
+    subtasks-factory = git dependencies
+    duplicate-key = git dependencies ${_change_number}
+
+[tasks-factory "git dependencies"]
+    names-factory = git dependencies
+    preload-task = git dependencies
+
+[names-factory "git dependencies"]
+    type = change
+    changes = -status:merged parentof:${_change_number} project:${_change_project} branch:${_change_branch}
+```
+
 Root Tasks
 ----------
 Root tasks typically define the "final verification" tasks for changes. Each
diff --git a/src/main/resources/Documentation/test/preview.md b/src/main/resources/Documentation/test/preview.md
index e53b7d8..6aff577 100644
--- a/src/main/resources/Documentation/test/preview.md
+++ b/src/main/resources/Documentation/test/preview.md
@@ -122,18 +122,6 @@
          "status" : "INVALID"   # Only Test Suite: invalid
       },
       {
-         "applicable" : true,
-         "hasPass" : false,
-         "name" : "Looping",
-         "status" : "WAITING",
-         "subTasks" : [
-            {
-               "name" : "UNKNOWN",
-               "status" : "INVALID"
-            }
-         ]
-      },
-      {
          "name" : "UNKNOWN",
          "status" : "INVALID"
       },
@@ -226,38 +214,6 @@
                "status" : "INVALID"
             }
          ]
-      },
-      {
-         "applicable" : true,
-         "hasPass" : false,
-         "name" : "task (tasks-factory changes loop)",
-         "status" : "WAITING",
-         "subTasks" : [
-            {
-               "applicable" : true,
-               "hasPass" : true,
-               "name" : "_change1_number",
-               "status" : "FAIL",
-               "subTasks" : [
-                  {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
-                  }
-               ]
-            },
-            {
-               "applicable" : true,
-               "hasPass" : true,
-               "name" : "_change2_number",
-               "status" : "FAIL",
-               "subTasks" : [
-                  {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
-                  }
-               ]
-            }
-         ]
       }
    ]
 }
@@ -322,6 +278,12 @@
          "hasPass" : true,
          "name" : "userfile task/special.config FAIL",
          "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "file task/common.config Preload PASS",
+         "status" : "PASS"
       }
    ]
 }
@@ -462,18 +424,6 @@
          "status" : "INVALID"   # Only Test Suite: invalid
       },
       {
-         "applicable" : true,
-         "hasPass" : false,
-         "name" : "Looping",
-         "status" : "WAITING",
-         "subTasks" : [
-            {
-               "name" : "UNKNOWN",
-               "status" : "INVALID"
-            }
-         ]
-      },
-      {
          "name" : "UNKNOWN",
          "status" : "INVALID"
       },
@@ -566,38 +516,6 @@
                "status" : "INVALID"
             }
          ]
-      },
-      {
-         "applicable" : true,
-         "hasPass" : false,
-         "name" : "task (tasks-factory changes loop)",
-         "status" : "WAITING",
-         "subTasks" : [
-            {
-               "applicable" : true,
-               "hasPass" : true,
-               "name" : "_change1_number",
-               "status" : "FAIL",
-               "subTasks" : [
-                  {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
-                  }
-               ]
-            },
-            {
-               "applicable" : true,
-               "hasPass" : true,
-               "name" : "_change2_number",
-               "status" : "FAIL",
-               "subTasks" : [
-                  {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
-                  }
-               ]
-            }
-         ]
       }
    ]
 }
diff --git a/src/main/resources/Documentation/test/task_states.md b/src/main/resources/Documentation/test/task_states.md
index ea09541..f48f528 100644
--- a/src/main/resources/Documentation/test/task_states.md
+++ b/src/main/resources/Documentation/test/task_states.md
@@ -570,6 +570,12 @@
          "hasPass" : true,
          "name" : "userfile task/special.config FAIL",
          "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "file task/common.config Preload PASS",
+         "status" : "PASS"
       }
    ]
 }
@@ -597,6 +603,12 @@
          "status" : "FAIL"
       },
       {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "file task/common.config Preload PASS",
+         "status" : "PASS"
+      },
+      {
          "name" : "UNKNOWN",
          "status" : "INVALID"
       }
@@ -630,6 +642,12 @@
          "status" : "FAIL"
       },
       {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "file task/common.config Preload PASS",
+         "status" : "PASS"
+      },
+      {
          "name" : "UNKNOWN",
          "status" : "INVALID"
       }
@@ -661,6 +679,12 @@
          "hasPass" : true,
          "name" : "userfile task/special.config FAIL",
          "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "file task/common.config Preload PASS",
+         "status" : "PASS"
       }
    ]
 }
@@ -724,12 +748,14 @@
    "subTasks" : [
       {
          "applicable" : true,
+         "change" : _change1_number,
          "hasPass" : true,
          "name" : "_change1_number",
          "status" : "FAIL"
       },
       {
          "applicable" : true,
+         "change" : _change2_number,
          "hasPass" : true,
          "name" : "_change2_number",
          "status" : "FAIL"
@@ -765,37 +791,192 @@
    "status" : "PASS"
 }
 
-[root "Root Properties"]
-  set-root-property = root-value
-  subtask = Subtask Properties
+[root "Root Same Name - Different Tasks-Factory"]
+  subtasks-factory = parent tasks-factory Same Name - Different Tasks-Factory
 
-[task "Subtask Properties"]
-  subtask = Subtask Properties Hints
-
-[task "Subtask Properties Hints"]
-  set-first-property = first-value
-  set-second-property = ${first-property} second-extra ${third-property}
-  set-third-property = third-value
+[tasks-factory "parent tasks-factory Same Name - Different Tasks-Factory"]
+  names-factory = parent names-factory Same Name - Different Tasks-Factory
   fail = True
-  fail-hint = root-property(${root-property}) first-property(${first-property}) second-property(${second-property})
+  subtasks-factory = child tasks-factory Same Name - Different Tasks-Factory
+
+[names-factory "parent names-factory Same Name - Different Tasks-Factory"]
+  type = static
+  name = Same Name
+
+[tasks-factory "child tasks-factory Same Name - Different Tasks-Factory"]
+  names-factory = child names-factory Same Name - Different Tasks-Factory
+  fail = False
+
+[names-factory "child names-factory Same Name - Different Tasks-Factory"]
+  type = static
+  name = Same Name
 
 {
    "applicable" : true,
    "hasPass" : false,
-   "name" : "Root Properties",
+   "name" : "Root Same Name - Different Tasks-Factory",
    "status" : "WAITING",
    "subTasks" : [
       {
          "applicable" : true,
+         "hasPass" : true,
+         "name" : "Same Name",
+         "status" : "FAIL",
+         "subTasks" : [
+            {
+               "applicable" : true,
+               "hasPass" : true,
+               "name" : "Same Name",
+               "status" : "PASS"
+            }
+         ]
+      }
+   ]
+}
+
+[root "Root Same Name - Different Change"]
+  subtasks-factory = init tasks-factory Same Name - Different Change
+
+[tasks-factory "init tasks-factory Same Name - Different Change"]
+  names-factory = init names-factory Same Name - Different Change
+  subtask = Same Name - Different Change
+
+[names-factory "init names-factory Same Name - Different Change"]
+  type = change
+  changes = change:_change2_number
+
+[task "Same Name - Different Change"]
+  subtasks-factory = tasks-factory Same Name - Different Change
+  pass = False
+  ready-hint = continues on to change _change1_number
+  fail-hint = stops here since we are change _change1_number
+  fail = change:_change1_number
+
+[tasks-factory "tasks-factory Same Name - Different Change"]
+  names-factory = names-factory Same Name - Different Change
+  subtask = Same Name - Different Change
+
+[names-factory "names-factory Same Name - Different Change"]
+  type = change
+  changes = change:_change1_number NOT change:${_change_number}
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Same Name - Different Change",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "change" : _change2_number,
          "hasPass" : false,
-         "name" : "Subtask Properties",
+         "name" : "_change2_number",
          "status" : "WAITING",
          "subTasks" : [
             {
                "applicable" : true,
                "hasPass" : true,
-               "hint" : "root-property(root-value) first-property(first-value) second-property(first-value second-extra third-value)",
-               "name" : "Subtask Properties Hints",
+               "name" : "Same Name - Different Change",
+               "status" : "WAITING",
+               "subTasks" : [
+                  {
+                     "applicable" : true,
+                     "change" : _change1_number,
+                     "hasPass" : false,
+                     "name" : "_change1_number",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "hasPass" : true,
+                           "hint" : "stops here since we are change _change1_number",
+                           "name" : "Same Name - Different Change",
+                           "status" : "FAIL"
+                        }
+                     ]
+                  }
+               ]
+            }
+         ]
+      }
+   ]
+}
+
+[root "Root Property References"]
+  set-first-property = first-value
+  set-backward-reference = first-[${first-property}]
+  set-forward-reference = last-[${last-property}]
+  set-last-property = last-value
+  fail = True
+  fail-hint = backward-reference(${backward-reference}) forward-reference(${forward-reference})
+
+{
+   "applicable" : true,
+   "hasPass" : true,
+   "hint" : "backward-reference(first-[first-value]) forward-reference(last-[last-value])",
+   "name" : "Root Property References",
+   "status" : "FAIL"
+}
+
+[root "Root Deep Property References"]
+  set-first-property = first-value
+  set-direct-reference = first-[${first-property}]
+  set-deep-reference = deep-{${direct-reference}}
+  fail = True
+  fail-hint = deep-reference(${deep-reference})
+
+{
+   "applicable" : true,
+   "hasPass" : true,
+   "hint" : "deep-reference(deep-{first-[first-value]})",
+   "name" : "Root Deep Property References",
+   "status" : "FAIL"
+}
+
+[root "Root Properties Referenced Twice"]
+  set-first-property = first-value
+  set-referenced-twice = first-[${first-property}] first-[${first-property}]
+  fail = True
+  fail-hint = first-[${first-property}] referenced-twice(${referenced-twice}) referenced-twice(${referenced-twice})
+
+{
+   "applicable" : true,
+   "hasPass" : true,
+   "hint" : "first-[first-value] referenced-twice(first-[first-value] first-[first-value]) referenced-twice(first-[first-value] first-[first-value])",
+   "name" : "Root Properties Referenced Twice",
+   "status" : "FAIL"
+}
+
+[root "Root Inherited Properties"]
+  set-root-property = root-value
+  subtask = Subtask Parent Inherited Properties
+
+[task "Subtask Parent Inherited Properties"]
+  set-parent-property = parent-value
+  subtask = Subtask Inherited Properties
+
+[task "Subtask Inherited Properties"]
+  set-my-property = my-value
+  fail = True
+  fail-hint = root-property(${root-property}) parent-property(${parent-property}) my-property(${my-property})
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Inherited Properties",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : false,
+         "name" : "Subtask Parent Inherited Properties",
+         "status" : "WAITING",
+         "subTasks" : [
+            {
+               "applicable" : true,
+               "hasPass" : true,
+               "hint" : "root-property(root-value) parent-property(parent-value) my-property(my-value)",
+               "name" : "Subtask Inherited Properties",
                "status" : "FAIL"
             }
          ]
@@ -803,6 +984,41 @@
    ]
 }
 
+[root "Root Inherited Distant Properties"]
+  set-root-property = root-value
+  set-root-change-property = ${_change_number}
+  subtask = Subtask Parent Inherited Distant Properties
+
+[task "Subtask Parent Inherited Distant Properties"]
+  subtask = Subtask Inherited Distant Properties
+
+[task "Subtask Inherited Distant Properties"]
+  fail = True
+  fail-hint = root-property(${root-property}) root-change-property(${root-change-property})
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Inherited Distant Properties",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : false,
+         "name" : "Subtask Parent Inherited Distant Properties",
+         "status" : "WAITING",
+         "subTasks" : [
+            {
+               "applicable" : true,
+               "hasPass" : true,
+               "hint" : "root-property(root-value) root-change-property(_change_number)",
+               "name" : "Subtask Inherited Distant Properties",
+               "status" : "FAIL"
+            }
+         ]
+      }
+   ]
+}
 
 [root "Root Properties Reset By Subtask"]
   set-root-to-reset-by-subtask = reset-my-root-value
@@ -829,6 +1045,46 @@
    ]
 }
 
+[root "Root Inherited Property References"]
+  set-root-property = root-value
+  subtask = Subtask Parent Inherited Property References
+
+[task "Subtask Parent Inherited Property References"]
+  set-parent-property = parent-value
+  set-parent-inherited-root-reference = root-property(${root-property})
+  subtask = Subtask Inherited Property References
+
+[task "Subtask Inherited Property References"]
+  set-inherited-root-reference = root-[${root-property}]
+  set-inherited-parent-reference = parent-[${parent-property}]
+  set-inherited-root-deep-reference = parent-inherited-root-reference-[${parent-inherited-root-reference}]
+  fail = True
+  fail-hint = inherited-root-reference(${inherited-root-reference}) inherited-parent-reference(${inherited-parent-reference}) inherited-root-deep-reference(${inherited-root-deep-reference})
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Inherited Property References",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : false,
+         "name" : "Subtask Parent Inherited Property References",
+         "status" : "WAITING",
+         "subTasks" : [
+            {
+               "applicable" : true,
+               "hasPass" : true,
+               "hint" : "inherited-root-reference(root-[root-value]) inherited-parent-reference(parent-[parent-value]) inherited-root-deep-reference(parent-inherited-root-reference-[root-property(root-value)])",
+               "name" : "Subtask Inherited Property References",
+               "status" : "FAIL"
+            }
+         ]
+      }
+   ]
+}
+
 [root "Root Properties Exports"]
   export-root-exported = ${_name}
   subtask = Subtask Properties Exports
@@ -927,6 +1183,99 @@
    ]
 }
 
+[root "Root applicable Property"]
+  subtask = Subtask applicable Property
+  subtasks-factory = tasks-factory branch NOT applicable Property
+
+[tasks-factory "tasks-factory branch NOT applicable Property"]
+  names-factory = names-factory branch NOT applicable Property
+  applicable = branch:dev
+  fail = True
+
+[names-factory "names-factory branch NOT applicable Property"]
+  type = static
+  name = NOT Applicable 1
+  name = NOT Applicable 2
+  name = NOT Applicable 3
+
+[task "Subtask applicable Property"]
+  applicable = change:${_change_number}
+  fail = True
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root applicable Property",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "Subtask applicable Property",
+         "status" : "FAIL"
+      },                              # Only Test Suite: all
+      {                               # Only Test Suite: all
+         "applicable" : false,        # Only Test Suite: all
+         "hasPass" : true,            # Only Test Suite: all
+         "name" : "NOT Applicable 1", # Only Test Suite: all
+         "status" : "FAIL"            # Only Test Suite: all
+      },                              # Only Test Suite: all
+      {                               # Only Test Suite: all
+         "applicable" : false,        # Only Test Suite: all
+         "hasPass" : true,            # Only Test Suite: all
+         "name" : "NOT Applicable 2", # Only Test Suite: all
+         "status" : "FAIL"            # Only Test Suite: all
+      },                              # Only Test Suite: all
+      {                               # Only Test Suite: all
+         "applicable" : false,        # Only Test Suite: all
+         "hasPass" : true,            # Only Test Suite: all
+         "name" : "NOT Applicable 3", # Only Test Suite: all
+         "status" : "FAIL"            # Only Test Suite: all
+      }
+   ]
+}
+
+[root "Root branch applicable Property"]
+  subtasks-factory = tasks-factory branch applicable Property
+
+[tasks-factory "tasks-factory branch applicable Property"]
+  names-factory = names-factory branch applicable Property
+  applicable = branch:master
+  fail = True
+
+[names-factory "names-factory branch applicable Property"]
+  type = static
+  name = Applicable 1
+  name = Applicable 2
+  name = Applicable 3
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root branch applicable Property",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "Applicable 1",
+         "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "Applicable 2",
+         "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "Applicable 3",
+         "status" : "FAIL"
+      }
+   ]
+}
+
 [root "Root Properties tasks-factory STATIC"]
   subtasks-factory = tasks-factory STATIC Properties
 
@@ -973,7 +1322,7 @@
   set-welcome-message = Welcome to the pleasuredome
   names-factory = names-factory a change
   fail-hint = ${welcome-message} Name(${_name}) Change Number(${_change_number}) Change Id(${_change_id}) Change Project(${_change_project}) Change Branch(${_change_branch}) Change Status(${_change_status}) Change Topic(${_change_topic})
-  fail = True
+  fail = change:_change1_number
 
 [names-factory "names-factory a change"]
   type = change
@@ -987,6 +1336,7 @@
    "subTasks" : [
       {
          "applicable" : true,
+         "change" : _change1_number,
          "hasPass" : true,
          "hint" : "Welcome to the pleasuredome Name(_change1_number) Change Number(_change1_number) Change Id(_change1_id) Change Project(_change1_project) Change Branch(_change1_branch) Change Status(_change1_status) Change Topic(_change1_topic)",
          "name" : "_change1_number",
@@ -994,9 +1344,48 @@
       },
       {
          "applicable" : true,
+         "change" : _change2_number,
          "hasPass" : true,
-         "hint" : "Welcome to the pleasuredome Name(_change2_number) Change Number(_change2_number) Change Id(_change2_id) Change Project(_change2_project) Change Branch(_change2_branch) Change Status(_change2_status) Change Topic(_change2_topic)",
          "name" : "_change2_number",
+         "status" : "PASS"
+      }
+   ]
+}
+
+[root "Root tasks-factory _name Property Reference"]
+  subtasks-factory = Properties tasks-factory _name Property Reference
+
+[tasks-factory "Properties tasks-factory _name Property Reference"]
+  set-name-reference = first-property ${_name}
+  fail-hint = ${name-reference}
+  fail = true
+  names-factory = names-factory static list
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root tasks-factory _name Property Reference",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "hint" : "first-property my a task",
+         "name" : "my a task",
+         "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "hint" : "first-property my b task",
+         "name" : "my b task",
+         "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "hint" : "first-property my c task",
+         "name" : "my c task",
          "status" : "FAIL"
       }
    ]
@@ -1082,12 +1471,14 @@
    "subTasks" : [
       {
          "applicable" : true,
+         "change" : _change_number,
          "hasPass" : true,
          "name" : "_change_number",
          "status" : "FAIL"
       },
       {
          "applicable" : true,
+         "change" : _change1_number,
          "hasPass" : true,
          "name" : "_change1_number",
          "status" : "FAIL"
@@ -1095,50 +1486,39 @@
    ]
 }
 
-[root "Root Properties Expansion"]
-  applicable = status:open
-  subtask = Subtask Property Expansion fail-hint
+[root "Root CHANGE constant subtask list CHANGE Properties"]
+  subtasks-factory = tasks-factory Properties CHANGE names-factory CHANGE
 
-[task "Subtask Property Expansion fail-hint"]
-  subtasks-factory = tasks-factory Property Expansion fail-hint
+[tasks-factory "tasks-factory Properties CHANGE names-factory CHANGE"]
+  names-factory = Properties names-factory current CHANGE
+  subtask = Current CHANGE Property
 
-[tasks-factory "tasks-factory Property Expansion fail-hint"]
-  set-first-property = first-property ${_name}
-  fail-hint = ${first-property}
-  fail = true
-  names-factory = names-factory static list
+[task "Current CHANGE Property"]
+  fail = True
+  fail-hint = Current Change: ${_change_number}
+
+[names-factory "Properties names-factory current CHANGE"]
+  type = change
+  changes = change:${_change_number}
 
 {
    "applicable" : true,
    "hasPass" : false,
-   "name" : "Root Properties Expansion",
+   "name" : "Root CHANGE constant subtask list CHANGE Properties",
    "status" : "WAITING",
    "subTasks" : [
       {
          "applicable" : true,
+         "change" : _change_number,
          "hasPass" : false,
-         "name" : "Subtask Property Expansion fail-hint",
+         "name" : "_change_number",
          "status" : "WAITING",
          "subTasks" : [
             {
                "applicable" : true,
                "hasPass" : true,
-               "hint" : "first-property my a task",
-               "name" : "my a task",
-               "status" : "FAIL"
-            },
-            {
-               "applicable" : true,
-               "hasPass" : true,
-               "hint" : "first-property my b task",
-               "name" : "my b task",
-               "status" : "FAIL"
-            },
-            {
-               "applicable" : true,
-               "hasPass" : true,
-               "hint" : "first-property my c task",
-               "name" : "my c task",
+               "hint" : "Current Change: _change_number",
+               "name" : "Current CHANGE Property",
                "status" : "FAIL"
             }
          ]
@@ -1176,6 +1556,158 @@
    ]
 }
 
+[root "Root Properties names-factory Reference"]
+  subtasks-factory = tasks-factory Properties names-factory Reference
+  set-predicate = change:_change1_number
+
+[tasks-factory "tasks-factory Properties names-factory Reference"]
+  names-factory = Properties names-factory Reference
+  fail = True
+
+[names-factory "Properties names-factory Reference"]
+  type = change
+  changes = ${predicate} OR change:${_change_number} project:${_change_project} branch:${_change_branch}
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Properties names-factory Reference",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "change" : _change_number,
+         "hasPass" : true,
+         "name" : "_change_number",
+         "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "change" : _change1_number,
+         "hasPass" : true,
+         "name" : "_change1_number",
+         "status" : "FAIL"
+      }
+   ]
+}
+
+[root "Root Properties names-factory Deep Reference"]
+  subtasks-factory = tasks-factory Properties names-factory Deep Reference
+  set-predicate-reference = ${predicate}
+  set-predicate = change:_change1_number
+
+[tasks-factory "tasks-factory Properties names-factory Deep Reference"]
+  names-factory = Properties names-factory Deep Reference
+  fail = True
+
+[names-factory "Properties names-factory Deep Reference"]
+  type = change
+  changes = ${predicate-reference} OR change:${_change_number} project:${_change_project} branch:${_change_branch}
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Properties names-factory Deep Reference",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "change" : _change_number,
+         "hasPass" : true,
+         "name" : "_change_number",
+         "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "change" : _change1_number,
+         "hasPass" : true,
+         "name" : "_change1_number",
+         "status" : "FAIL"
+      }
+   ]
+}
+
+[root "Root Properties names-factory Reference Internal"]
+  subtasks-factory = tasks-factory Properties names-factory Reference Internal
+  set-predicate = change:${_change_number} project:${_change_project} branch:${_change_branch}
+
+[tasks-factory "tasks-factory Properties names-factory Reference Internal"]
+  names-factory = Properties names-factory Reference Internal
+  fail = True
+
+[names-factory "Properties names-factory Reference Internal"]
+  type = change
+  changes = change:_change1_number OR ${predicate}
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Properties names-factory Reference Internal",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "change" : _change_number,
+         "hasPass" : true,
+         "name" : "_change_number",
+         "status" : "FAIL"
+      },
+      {
+         "applicable" : true,
+         "change" : _change1_number,
+         "hasPass" : true,
+         "name" : "_change1_number",
+         "status" : "FAIL"
+      }
+   ]
+}
+
+[root "Root Properties names-factory Reference Inherited"]
+  subtask = task Properties names-factory Reference Inherited
+  set-predicate = change:${_change_number} project:${_change_project} branch:${_change_branch}
+
+[task "task Properties names-factory Reference Inherited"]
+  subtasks-factory = tasks-factory Properties names-factory Reference Inherited
+
+[tasks-factory "tasks-factory Properties names-factory Reference Inherited"]
+  names-factory = Properties names-factory Reference Inherited
+  fail = True
+
+[names-factory "Properties names-factory Reference Inherited"]
+  type = change
+  changes = change:_change1_number OR ${predicate}
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Properties names-factory Reference Inherited",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : false,
+         "name" : "task Properties names-factory Reference Inherited",
+         "status" : "WAITING",
+         "subTasks" : [
+            {
+               "applicable" : true,
+               "change" : _change_number,
+               "hasPass" : true,
+               "name" : "_change_number",
+               "status" : "FAIL"
+            },
+            {
+               "applicable" : true,
+               "change" : _change1_number,
+               "hasPass" : true,
+               "name" : "_change1_number",
+               "status" : "FAIL"
+            }
+         ]
+      }
+   ]
+}
+
 [root "Root Preload Preload"]
   subtask = Subtask Preload Preload
 
@@ -1354,10 +1886,17 @@
   subtask = Subtask Preload Properties
 
 [task "Subtask Preload Properties"]
-  preload-task = Subtask Properties Hints
+  preload-task = Subtask Preload Properties Hints
   set-fourth-property = fourth-value
   fail-hint = second-property(${second-property}) fourth-property(${fourth-property})
 
+[task "Subtask Preload Properties Hints"]
+  set-first-property = first-value
+  set-second-property = ${first-property} second-extra ${third-property}
+  set-third-property = third-value
+  fail = True
+  fail-hint = root-property(${root-property}) first-property(${first-property}) second-property(${second-property})
+
 {
    "applicable" : true,
    "hasPass" : false,
@@ -1374,6 +1913,247 @@
    ]
 }
 
+[root "Root Preload tasks-factory"]
+  subtasks-factory = tasks-factory Preload tasks-factory
+
+[tasks-factory "tasks-factory Preload tasks-factory"]
+  names-factory = names-factory static list
+  preload-task = Subtask PASS
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Preload tasks-factory",
+   "status" : "PASS",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "my a task",
+         "status" : "PASS"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "my b task",
+         "status" : "PASS"
+      },
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "my c task",
+         "status" : "PASS"
+      }
+   ]
+}
+
+[root "Root Looping"]
+  subtask = Looping
+
+[task "Looping"]
+  subtask = Looping
+  pass = True
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Looping",
+   "status" : "PASS",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "Looping",
+         "status" : "PASS",
+         "subTasks" : [
+            {
+               "applicable" : true,
+               "hasPass" : false,
+               "hint" : "Duplicate task is non blocking and empty to break the loop",
+               "name" : "Looping",
+               "status" : "DUPLICATE"
+            }
+         ]
+      }
+   ]
+}
+
+[root "Root Looping DuplicateKey"]
+  preload-task = DuplicateKey
+
+[task "Looping DuplicateKey"]
+  preload-task = DuplicateKey
+  pass = True
+
+[task "DuplicateKey"]
+  duplicate-key = 1234
+  subtask = Looping DuplicateKey
+
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Looping DuplicateKey",
+   "status" : "PASS",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : false,
+         "hint" : "Duplicate task is non blocking and empty to break the loop",
+         "name" : "Looping DuplicateKey",
+         "status" : "DUPLICATE"
+      }
+   ]
+}
+
+[root "Root changes loop"]
+  subtask = task (tasks-factory changes loop)
+
+[task "task (tasks-factory changes loop)"]
+  subtasks-factory = tasks-factory change loop
+
+[tasks-factory "tasks-factory change loop"]
+  names-factory = names-factory change constant
+  subtask = task (tasks-factory changes loop)
+  fail = True
+
+[names-factory "names-factory change constant"]
+  changes = change:_change1_number OR change:_change2_number
+  type = change
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root changes loop",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : false,
+         "name" : "task (tasks-factory changes loop)",
+         "status" : "WAITING",
+         "subTasks" : [
+            {
+               "applicable" : true,
+               "change" : _change1_number,
+               "hasPass" : true,
+               "name" : "_change1_number",
+               "status" : "FAIL",
+               "subTasks" : [
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory changes loop)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "change" : _change1_number,
+                           "hasPass" : false,
+                           "hint" : "Duplicate task is non blocking and empty to break the loop",
+                           "name" : "_change1_number",
+                           "status" : "DUPLICATE"
+                        },
+                        {
+                           "applicable" : true,
+                           "change" : _change2_number,
+                           "hasPass" : true,
+                           "name" : "_change2_number",
+                           "status" : "FAIL",
+                           "subTasks" : [
+                              {
+                                 "applicable" : true,
+                                 "hasPass" : false,
+                                 "name" : "task (tasks-factory changes loop)",
+                                 "status" : "PASS",
+                                 "subTasks" : [
+                                    {
+                                       "applicable" : true,
+                                       "change" : _change1_number,
+                                       "hasPass" : false,
+                                       "hint" : "Duplicate task is non blocking and empty to break the loop",
+                                       "name" : "_change1_number",
+                                       "status" : "DUPLICATE"
+                                    },
+                                    {
+                                       "applicable" : true,
+                                       "change" : _change2_number,
+                                       "hasPass" : false,
+                                       "hint" : "Duplicate task is non blocking and empty to break the loop",
+                                       "name" : "_change2_number",
+                                       "status" : "DUPLICATE"
+                                    }
+                                 ]
+                              }
+                           ]
+                        }
+                     ]
+                  }
+               ]
+            },
+            {
+               "applicable" : true,
+               "change" : _change2_number,
+               "hasPass" : true,
+               "name" : "_change2_number",
+               "status" : "FAIL",
+               "subTasks" : [
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory changes loop)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "change" : _change1_number,
+                           "hasPass" : true,
+                           "name" : "_change1_number",
+                           "status" : "FAIL",
+                           "subTasks" : [
+                              {
+                                 "applicable" : true,
+                                 "hasPass" : false,
+                                 "name" : "task (tasks-factory changes loop)",
+                                 "status" : "PASS",
+                                 "subTasks" : [
+                                    {
+                                       "applicable" : true,
+                                       "change" : _change1_number,
+                                       "hasPass" : false,
+                                       "hint" : "Duplicate task is non blocking and empty to break the loop",
+                                       "name" : "_change1_number",
+                                       "status" : "DUPLICATE"
+                                    },
+                                    {
+                                       "applicable" : true,
+                                       "change" : _change2_number,
+                                       "hasPass" : false,
+                                       "hint" : "Duplicate task is non blocking and empty to break the loop",
+                                       "name" : "_change2_number",
+                                       "status" : "DUPLICATE"
+                                    }
+                                 ]
+                              }
+                           ]
+                        },
+                        {
+                           "applicable" : true,
+                           "change" : _change2_number,
+                           "hasPass" : false,
+                           "hint" : "Duplicate task is non blocking and empty to break the loop",
+                           "name" : "_change2_number",
+                           "status" : "DUPLICATE"
+                        }
+                     ]
+                  }
+               ]
+            }
+         ]
+      }
+   ]
+}
+
 [root "Root INVALID Preload"]
   preload-task = missing
 
@@ -1506,18 +2286,6 @@
          "status" : "INVALID"   # Only Test Suite: !all
       },
       {
-         "applicable" : true,
-         "hasPass" : false,
-         "name" : "Looping",
-         "status" : "WAITING",
-         "subTasks" : [
-            {
-               "name" : "UNKNOWN",
-               "status" : "INVALID"
-            }
-         ]
-      },
-      {
          "name" : "UNKNOWN",
          "status" : "INVALID"
       },
@@ -1610,38 +2378,6 @@
                "status" : "INVALID"
             }
          ]
-      },
-      {
-         "applicable" : true,
-         "hasPass" : false,
-         "name" : "task (tasks-factory changes loop)",
-         "status" : "WAITING",
-         "subTasks" : [
-            {
-               "applicable" : true,
-               "hasPass" : true,
-               "name" : "_change1_number",
-               "status" : "FAIL",
-               "subTasks" : [
-                  {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
-                  }
-               ]
-            },
-            {
-               "applicable" : true,
-               "hasPass" : true,
-               "name" : "_change2_number",
-               "status" : "FAIL",
-               "subTasks" : [
-                  {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
-                  }
-               ]
-            }
-         ]
       }
    ]
 }
@@ -1793,18 +2529,6 @@
          "status" : "INVALID"   # Only Test Suite: !all
       },
       {
-         "applicable" : true,
-         "hasPass" : false,
-         "name" : "Looping",
-         "status" : "WAITING",
-         "subTasks" : [
-            {
-               "name" : "UNKNOWN",
-               "status" : "INVALID"
-            }
-         ]
-      },
-      {
          "name" : "UNKNOWN",
          "status" : "INVALID"
       },
@@ -1897,38 +2621,6 @@
                "status" : "INVALID"
             }
          ]
-      },
-      {
-         "applicable" : true,
-         "hasPass" : false,
-         "name" : "task (tasks-factory changes loop)",
-         "status" : "WAITING",
-         "subTasks" : [
-            {
-               "applicable" : true,
-               "hasPass" : true,
-               "name" : "_change1_number",
-               "status" : "FAIL",
-               "subTasks" : [
-                  {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
-                  }
-               ]
-            },
-            {
-               "applicable" : true,
-               "hasPass" : true,
-               "name" : "_change2_number",
-               "status" : "FAIL",
-               "subTasks" : [
-                  {
-                     "name" : "UNKNOWN",
-                     "status" : "INVALID"
-                  }
-               ]
-            }
-         ]
       }
    ]
 }
@@ -1992,12 +2684,10 @@
   fail = True
   in-progress = has:bad
 
-[task "Looping"]
-  subtask = Looping
-
 [task "Looping Properties"]
   set-A = ${B}
   set-B = ${A}
+  fail-hint = ${A}
   fail = True
 
 [task "task (tasks-factory missing)"]
@@ -2021,9 +2711,6 @@
 [task "task (names-factory changes invalid)"]
   subtasks-factory = tasks-factory change (names-factory changes invalid)
 
-[task "task (tasks-factory changes loop)"]
-  subtasks-factory = tasks-factory change loop
-
 [tasks-factory "tasks-factory (names-factory type missing)"]
   names-factory = names-factory (type missing)
   fail = True
@@ -2047,11 +2734,6 @@
   names-factory = names-factory change list (changes invalid)
   fail = True
 
-[tasks-factory "tasks-factory change loop"]
-  names-factory = names-factory change constant
-  subtask = task (tasks-factory changes loop)
-  fail = True
-
 [names-factory "names-factory (type missing)"]
   name = no type test
 
@@ -2071,11 +2753,7 @@
   type = change
 
 [names-factory "names-factory change list (changes invalid)"]
-  change = change:invalidChange
-  type = change
-
-[names-factory "names-factory change constant"]
-  changes = change:_change1_number OR change:_change2_number
+  changes = change:invalidChange
   type = change
 
 ```
@@ -2090,4 +2768,7 @@
 [task "userfile task/special.config FAIL"]
   applicable = is:open
   fail = is:open
+
+[task "file task/common.config Preload PASS"]
+  preload-task = userfile task/special.config PASS
 ```
diff --git a/src/test/java/com/google/gerrit/common/BooleanTableTest.java b/src/test/java/com/google/gerrit/common/BooleanTableTest.java
new file mode 100644
index 0000000..746a0e9
--- /dev/null
+++ b/src/test/java/com/google/gerrit/common/BooleanTableTest.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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 com.google.gerrit.common;
+
+import junit.framework.TestCase;
+
+public class BooleanTableTest extends TestCase {
+
+  public void testNulls() {
+    BooleanTable<String, String> cbt = new BooleanTable<>();
+    assertNull(cbt.get("r1", "c1"));
+    assertNull(cbt.get("r0", "c0"));
+
+    cbt.put("r1", "c0", true);
+    assertNull(cbt.get("r1", "c1"));
+    assertNull(cbt.get("r0", "c0"));
+
+    cbt.put("r0", "c1", true);
+    assertNull(cbt.get("r1", "c1"));
+    assertNull(cbt.get("r0", "c0"));
+  }
+
+  public void testRowColumn() {
+    BooleanTable<String, String> cbt = new BooleanTable<>();
+    cbt.put("r1", "c1", true);
+    cbt.put("r2", "c2", false);
+    assertTrue(cbt.get("r1", "c1"));
+    assertNull(cbt.get("r1", "c2"));
+    assertNull(cbt.get("r2", "c1"));
+    assertFalse(cbt.get("r2", "c2"));
+  }
+
+  public void testRowColumnOverride() {
+    BooleanTable<String, String> cbt = new BooleanTable<>();
+    cbt.put("r1", "c1", true);
+    assertTrue(cbt.get("r1", "c1"));
+
+    cbt.put("r1", "c1", false);
+    assertFalse(cbt.get("r1", "c1"));
+  }
+
+  public void testRepeatedColumns() {
+    BooleanTable<String, String> cbt = new BooleanTable<>();
+    cbt.put("r1", "c1", true);
+    cbt.put("r2", "c1", false);
+    assertTrue(cbt.get("r1", "c1"));
+    assertFalse(cbt.get("r2", "c1"));
+  }
+
+  public void testRepeatedRows() {
+    BooleanTable<String, String> cbt = new BooleanTable<>();
+    cbt.put("r1", "c1", true);
+    cbt.put("r1", "c2", false);
+    assertTrue(cbt.get("r1", "c1"));
+    assertFalse(cbt.get("r1", "c2"));
+  }
+
+  public void testRepeatedRowsAndColumns() {
+    BooleanTable<String, String> cbt = new BooleanTable<>();
+    cbt.put("r1", "c1", true);
+    cbt.put("r2", "c1", false);
+    cbt.put("r1", "c2", true);
+    cbt.put("r2", "c2", false);
+    assertTrue(cbt.get("r1", "c1"));
+    assertFalse(cbt.get("r2", "c1"));
+    assertTrue(cbt.get("r1", "c2"));
+    assertFalse(cbt.get("r2", "c2"));
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/task/TaskExpressionTest.java b/src/test/java/com/googlesource/gerrit/plugins/task/TaskExpressionTest.java
new file mode 100644
index 0000000..2f27439
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/task/TaskExpressionTest.java
@@ -0,0 +1,190 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.task;
+
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+import junit.framework.TestCase;
+
+/*
+ * <ul>
+ *   <li><code> "simple"        -> ("simple")         required</code>
+ *   <li><code> "world | peace" -> ("world", "peace") required</code>
+ *   <li><code> "shadenfreud |" -> ("shadenfreud")    optional</code>
+ *   <li><code> "foo | bar |"   -> ("foo", "bar")     optional</code>
+ * </ul>
+ */
+public class TaskExpressionTest extends TestCase {
+  public static String SIMPLE = "simple";
+  public static String WORLD = "world";
+  public static String PEACE = "peace";
+  public static FileKey file = createFileKey("foo", "bar", "baz");
+
+  public static TaskKey SIMPLE_TASK = TaskKey.create(file, SIMPLE);
+  public static TaskKey WORLD_TASK = TaskKey.create(file, WORLD);
+  public static TaskKey PEACE_TASK = TaskKey.create(file, PEACE);
+
+  public void testBlank() {
+    TaskExpression exp = getTaskExpression("");
+    Iterator<TaskKey> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testRequiredSingleName() {
+    TaskExpression exp = getTaskExpression(SIMPLE);
+    Iterator<TaskKey> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), SIMPLE_TASK);
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testOptionalSingleName() {
+    TaskExpression exp = getTaskExpression(SIMPLE + "|");
+    Iterator<TaskKey> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), SIMPLE_TASK);
+    assertFalse(it.hasNext());
+  }
+
+  public void testRequiredTwoNames() {
+    TaskExpression exp = getTaskExpression(WORLD + "|" + PEACE);
+    Iterator<TaskKey> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), WORLD_TASK);
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), PEACE_TASK);
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testOptionalTwoNames() {
+    TaskExpression exp = getTaskExpression(WORLD + "|" + PEACE + "|");
+    Iterator<TaskKey> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), WORLD_TASK);
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), PEACE_TASK);
+    assertFalse(it.hasNext());
+  }
+
+  public void testBlankSpaces() {
+    TaskExpression exp = getTaskExpression("  ");
+    Iterator<TaskKey> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testRequiredSingleNameLeadingSpaces() {
+    TaskExpression exp = getTaskExpression("  " + SIMPLE);
+    Iterator<TaskKey> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), SIMPLE_TASK);
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testRequiredSingleNameTrailingSpaces() {
+    TaskExpression exp = getTaskExpression(SIMPLE + "  ");
+    Iterator<TaskKey> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), SIMPLE_TASK);
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testOptionalSingleNameLeadingSpaces() {
+    TaskExpression exp = getTaskExpression("  " + SIMPLE + "|");
+    Iterator<TaskKey> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), SIMPLE_TASK);
+    assertFalse(it.hasNext());
+  }
+
+  public void testOptionalSingleNameTrailingSpaces() {
+    TaskExpression exp = getTaskExpression(SIMPLE + "|  ");
+    Iterator<TaskKey> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), SIMPLE_TASK);
+    assertFalse(it.hasNext());
+  }
+
+  public void testOptionalSingleNameMiddleSpaces() {
+    TaskExpression exp = getTaskExpression(SIMPLE + "  |");
+    Iterator<TaskKey> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), SIMPLE_TASK);
+    assertFalse(it.hasNext());
+  }
+
+  public void testRequiredTwoNamesMiddleSpaces() {
+    TaskExpression exp = getTaskExpression(WORLD + "  |  " + PEACE);
+    Iterator<TaskKey> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), WORLD_TASK);
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), PEACE_TASK);
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testDifferentKeyOnDifferentFile() {
+    TaskExpression exp = getTaskExpression(createFileKey("foo", "bar", "baz"), SIMPLE);
+    TaskExpression otherExp = getTaskExpression(createFileKey("foo", "bar", "other"), SIMPLE);
+    assertFalse(exp.key.equals(otherExp.key));
+  }
+
+  public void testDifferentKeyOnDifferentBranch() {
+    TaskExpression exp = getTaskExpression(createFileKey("foo", "bar", "baz"), SIMPLE);
+    TaskExpression otherExp = getTaskExpression(createFileKey("foo", "other", "baz"), SIMPLE);
+    assertFalse(exp.key.equals(otherExp.key));
+  }
+
+  public void testDifferentKeyOnDifferentProject() {
+    TaskExpression exp = getTaskExpression(createFileKey("foo", "bar", "baz"), SIMPLE);
+    TaskExpression otherExp = getTaskExpression(createFileKey("other", "bar", "baz"), SIMPLE);
+    assertFalse(exp.key.equals(otherExp.key));
+  }
+
+  public void testDifferentKeyOnDifferentExpression() {
+    TaskExpression exp = getTaskExpression(SIMPLE);
+    TaskExpression otherExp = getTaskExpression(PEACE);
+    assertFalse(exp.key.equals(otherExp.key));
+  }
+
+  protected static void assertNoSuchElementException(Iterator<TaskKey> it) {
+    try {
+      it.next();
+      assertTrue(false);
+    } catch (NoSuchElementException e) {
+      assertTrue(true);
+    }
+  }
+
+  protected TaskExpression getTaskExpression(String expression) {
+    return getTaskExpression(file, expression);
+  }
+
+  protected TaskExpression getTaskExpression(FileKey file, String expression) {
+    return new TaskExpression(file, expression);
+  }
+
+  protected static FileKey createFileKey(String project, String branch, String file) {
+    return FileKey.create(BranchNameKey.create(Project.NameKey.parse(project), branch), file);
+  }
+}
diff --git a/test/check_task_statuses.sh b/test/check_task_statuses.sh
index 5b7e161..1d3efbd 100755
--- a/test/check_task_statuses.sh
+++ b/test/check_task_statuses.sh
@@ -37,9 +37,9 @@
     result "$name" "$(diff <(echo "$expected") <(echo "$actual"))"
 }
 
-result_root() { # group root expected_file actual_file
+result_root() { # group root
     local name="$1 - $(echo "$2" | sed -es'/Root //')"
-    result_out "$name" "$(get_root "$2" < "$3")" "$(get_root "$2" < "$4")"
+    result_out "$name" "${EXPECTED_ROOTS[$2]}" "${OUTPUT_ROOTS[$2]}"
 }
 
 # -------- Git Config
@@ -189,17 +189,36 @@
 strip_non_applicable() { ensure "$MYDIR"/strip_non_applicable.py ; } # < json > json
 strip_non_invalid() { ensure "$MYDIR"/strip_non_invalid.py ; } # < json > json
 
-get_root() { # root < task_plugin_ouptut > root_json
-    python -c "if True: # NOP to start indent
+define_jsonByRoot() { # task_plugin_ouptut > jsonByRoot_array_definition
+    local record root=''
+    local -A jsonByRoot
+    while IFS= read -r -d '' record ; do
+        if [ -z "$root" ] ; then
+            root=$record
+        else
+            jsonByRoot[$root]=$record
+            root=''
+        fi
+    done < <(python -c "if True: # NOP to start indent
         import sys, json
 
         roots=json.loads(sys.stdin.read())['plugins'][0]['roots']
         for root in roots:
-            if 'name' in root.keys() and root['name']=='$1':
-                print json.dumps(root, indent=3, separators=(',', ' : '), sort_keys=True)"
+            root_json = json.dumps(root, indent=3, separators=(',', ' : '), sort_keys=True)
+            print root['name'] + '\x00' + root_json + '\x00',"
+    )
+
+    local def=$(declare -p jsonByRoot)
+    echo "${def#*=}" # declare -A jsonByRoot='(...)' > '(...)'
 }
 
-example() { # example_num
+get_plugins() { # < change_json > plugins_json
+    python -c "import sys, json; \
+        plugins={}; plugins['plugins']=json.loads(sys.stdin.read())['plugins']; \
+        print json.dumps(plugins, indent=3, separators=(',', ' : '), sort_keys=True)"
+}
+
+example() { # example_num > text_for_example_num
     echo "$DOC_STATES" | awk '/```/{Q++;E=(Q+1)/2};E=='"$1" | grep -v '```' | replace_user
 }
 
@@ -243,7 +262,7 @@
     local repo=$1 remote=$2 ref=$3 change_id=$4 msg="Test change"
     (
         q cd "$repo"
-        date > file
+        uuidgen > file
         q git add .
         [ -n "$change_id" ] && msg=$(commit_message "$msg" "$change_id")
         q git commit -m "$msg"
@@ -251,28 +270,37 @@
     )
 }
 
-query_plugins() { # query
-    gssh query "$@" --format json | head -1 | python -c "import sys, json; \
-        plugins={}; plugins['plugins']=json.loads(sys.stdin.read())['plugins']; \
-        print json.dumps(plugins, indent=3, separators=(',', ' : '), sort_keys=True)"
-}
+query() { gssh query "$@" --format json ; } # query > json lines
 
-test_tasks() { # name expected_file task_args...
-    local name=$1 expected=$2 ; shift 2
-    local output=$STATUSES.$name out root
+# N < json lines > changeN_json
+change_plugins() { awk "NR==$1" | get_plugins | json_pp ; }
 
-    query_plugins "$@" > "$output"
+results_suite() { # name expected_file plugins_json
+    local name=$1 expected=$2 actual=$3
+
+    local -A EXPECTED_ROOTS=$(define_jsonByRoot < "$expected")
+    local -A OUTPUT_ROOTS=$(echo "$actual" | define_jsonByRoot)
+
+    local out root
     echo "$ROOTS" | while read root ; do
-        result_root "$name" "$root" "$expected" "$output"
+        result_root "$name" "$root"
     done
-    out=$(diff "$expected" "$output" | head -15)
+    out=$(diff "$expected" <(echo "$actual") | head -15)
     [ -z "$out" ]
     result "$name - Full Test Suite" "$out"
 }
 
+test_2generated() { # name task_args...
+    local name=$1 ; shift
+    local out=$(query "$@")
+    results_suite "$name" "$EXPECTED.$name" "$(echo "$out" | change_plugins 1)"
+    results_suite "$name 2nd change" "$EXPECTED.$name"2 "$(echo "$out" | change_plugins 2)"
+}
+
 test_generated() { # name task_args...
     local name=$1 ; shift
-    test_tasks "$name" "$EXPECTED.$name" "$@"
+    query "$@" | change_plugins 1 > "$ACTUAL.$name"
+    results_suite "$name" "$EXPECTED.$name" "$( < "$ACTUAL.$name")"
 }
 
 test_file() { # name task_args...
@@ -297,7 +325,7 @@
 
 DOC_PREVIEW=$DOCS/preview.md
 EXPECTED=$OUT/expected
-STATUSES=$OUT/statuses
+ACTUAL=$OUT/actual
 
 ROOT_CFG=$ALL/task.config
 COMMON_CFG=$ALL_TASKS/common.config
@@ -342,9 +370,12 @@
 q_setup update_repo "$USERS" "$REMOTE_USERS" "$REF_USERS"
 
 change3_id=$(gen_change_id)
+change4_id=$(gen_change_id)
+change4_number=$(create_repo_change "$OUT/$PROJECT" "$REMOTE_TEST" "$BRANCH" "$change4_id")
 change3_number=$(create_repo_change "$OUT/$PROJECT" "$REMOTE_TEST" "$BRANCH" "$change3_id")
 
-all_pjson=$(example 2 | testdoc_2_pjson | \
+ex2_pjson=$(example 2 | testdoc_2_pjson)
+all_pjson=$(echo "$ex2_pjson" | \
     replace_change_properties \
         "" \
         "$change3_number" \
@@ -354,10 +385,23 @@
         "NEW" \
         "")
 
+all2_pjson=$(echo "$ex2_pjson" | \
+    replace_change_properties \
+        "" \
+        "$change4_number" \
+        "$change4_id" \
+        "$PROJECT" \
+        "refs\/heads\/$BRANCH" \
+        "NEW" \
+        "")
+
 no_all_json=$(echo "$all_pjson" | remove_suite all)
+no_all2_json=$(echo "$all2_pjson" | remove_suite all)
 
 echo "$no_all_json" | strip_non_applicable | \
     grep -v "\"applicable\" :" > "$EXPECTED".applicable
+echo "$no_all2_json" | strip_non_applicable | \
+    grep -v "\"applicable\" :" > "$EXPECTED".applicable2
 
 echo "$all_pjson" | remove_not_suite all | ensure json_pp > "$EXPECTED".all
 
@@ -376,8 +420,8 @@
 
 
 RESULT=0
-query="change:$change3_number status:open"
-test_generated applicable --task--applicable "$query"
+query="(change:$change3_number OR change:$change4_number) status:open"
+test_2generated applicable --task--applicable "$query"
 test_generated all --task--all "$query"
 
 test_generated invalid --task--invalid "$query"
diff --git a/test/docker/run_tests/start.sh b/test/docker/run_tests/start.sh
index ac185d8..dd2cb63 100755
--- a/test/docker/run_tests/start.sh
+++ b/test/docker/run_tests/start.sh
@@ -1,7 +1,7 @@
 #!/usr/bin/env bash
 
 USER_RUN_TESTS_DIR="$USER_HOME"/"$RUN_TESTS_DIR"
-cp -r /task "$USER_HOME"/
+mkdir "$USER_HOME"/task && cp -r /task/{src,test} "$USER_HOME"/task
 
 if [ "$1" = "retest" ] ; then
     cd "$USER_RUN_TESTS_DIR"/../../ && ./check_task_statuses.sh "$GERRIT_HOST"
diff --git a/test/strip_non_applicable.py b/test/strip_non_applicable.py
index 1ff097a..41c21fa 100755
--- a/test/strip_non_applicable.py
+++ b/test/strip_non_applicable.py
@@ -43,7 +43,7 @@
                     status=''
                     if STATUS in task.keys():
                         status = task[STATUS]
-                    if status != 'INVALID':
+                    if status != 'INVALID' and status != 'DUPLICATE':
                         del tasks[i]
                         nexti = i
 
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
new file mode 100644
index 0000000..5df79bb
--- /dev/null
+++ b/tools/bzl/junit.bzl
@@ -0,0 +1,6 @@
+load(
+    "@com_googlesource_gerrit_bazlets//tools:junit.bzl",
+    _junit_tests = "junit_tests",
+)
+
+junit_tests = _junit_tests
\ No newline at end of file
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
new file mode 100644
index 0000000..4871c7b
--- /dev/null
+++ b/tools/bzl/maven_jar.bzl
@@ -0,0 +1,4 @@
+load("@com_googlesource_gerrit_bazlets//tools:maven_jar.bzl", _gerrit = "GERRIT", _maven_jar = "maven_jar")
+
+maven_jar = _maven_jar
+GERRIT = _gerrit
\ No newline at end of file
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index 89a1643..67536ef 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -2,7 +2,9 @@
     "@com_googlesource_gerrit_bazlets//:gerrit_plugin.bzl",
     _gerrit_plugin = "gerrit_plugin",
     _plugin_deps = "PLUGIN_DEPS",
+    _plugin_test_deps = "PLUGIN_TEST_DEPS",
 )
 
 gerrit_plugin = _gerrit_plugin
 PLUGIN_DEPS = _plugin_deps
+PLUGIN_TEST_DEPS = _plugin_test_deps
\ No newline at end of file
diff --git a/tools/playbooks/install_python3-distutils.yaml b/tools/playbooks/install_python3-distutils.yaml
new file mode 100644
index 0000000..75f5a2a
--- /dev/null
+++ b/tools/playbooks/install_python3-distutils.yaml
@@ -0,0 +1,10 @@
+- hosts: all
+  roles:
+    - name: ensure-python
+  tasks:
+    - name: Install python3-distutils
+      become: true
+      package:
+        name:
+          - python3-distutils
+        state: present