Merge  branch 'stable-3.5'

* stable-3.5:
  Allow polling filesystem to be run on a specified queue
  fixup!: Poll the filesystem for new events
  Poll the filesystem for new events
  Allow events from other primaries to be dispatched
  Add ability to drop events by classname
  Cleanup the event capturing for diffs
  Run formatter on classes
  test/docker: Support installing multiple plugins
  test/docker/run: Stop caching artifacts
  test/docker/run: Clarify --gerrit-war usage
  Bump to Gerrit API 3.5.0.1
  Add docker tag to docker-tests target

Change-Id: Ie77344b2173cfd62cd88556c0deee59a0d2327ed
diff --git a/.gitignore b/.gitignore
index 922d9e7..0503db2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@
 *.iml
 /.apt_generated/
 /.apt_generated_tests/
+/test/docker/gerrit/artifacts
diff --git a/BUILD b/BUILD
index 4843927..a2152c9 100644
--- a/BUILD
+++ b/BUILD
@@ -38,9 +38,10 @@
     size = "medium",
     srcs = ["test/docker/run.sh"],
     args = [
-        "--events-plugin-jar",
+        "--plugin", plugin_name,
         "$(location :events)",
     ],
     data = [plugin_name] + glob(["test/**"]),
     local = True,
+    tags = ["docker"],
 )
diff --git a/src/main/java/com/googlesource/gerrit/plugins/events/CoreListener.java b/src/main/java/com/googlesource/gerrit/plugins/events/CoreListener.java
deleted file mode 100644
index e23c9e2..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/events/CoreListener.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2016 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.events;
-
-import com.google.common.base.Supplier;
-import com.google.gerrit.entities.EntitiesAdapterFactory;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.server.events.Event;
-import com.google.gerrit.server.events.EventListener;
-import com.google.gerrit.server.events.ProjectNameKeyAdapter;
-import com.google.gerrit.server.events.SupplierSerializer;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class CoreListener implements EventListener {
-  private static Logger log = LoggerFactory.getLogger(CoreListener.class);
-
-  protected static final Gson gson =
-      new GsonBuilder()
-          .registerTypeAdapter(Supplier.class, new SupplierSerializer())
-          .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeyAdapter())
-          .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
-          .create();
-  protected final DynamicSet<StreamEventListener> listeners;
-  protected final EventStore store;
-
-  @Inject
-  protected CoreListener(EventStore store, DynamicSet<StreamEventListener> listeners) {
-    this.store = store;
-    this.listeners = listeners;
-  }
-
-  @Override
-  public void onEvent(Event event) {
-    try {
-      store.add(gson.toJson(event));
-    } catch (IOException e) {
-      log.error("Cannot add event to event store", e);
-    }
-    for (StreamEventListener l : listeners) {
-      l.onStreamEventUpdate();
-    }
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/events/FileSystemEventBroker.java b/src/main/java/com/googlesource/gerrit/plugins/events/FileSystemEventBroker.java
new file mode 100644
index 0000000..f3f5855
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/events/FileSystemEventBroker.java
@@ -0,0 +1,157 @@
+// 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.events;
+
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.config.GerritInstanceId;
+import com.google.gerrit.server.config.GerritServerConfigProvider;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.events.ChangeEvent;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.EventBroker;
+import com.google.gerrit.server.events.EventGson;
+import com.google.gerrit.server.events.EventListener;
+import com.google.gerrit.server.events.ProjectEvent;
+import com.google.gerrit.server.events.RefEvent;
+import com.google.gerrit.server.events.UserScopedEventListener;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gson.Gson;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class FileSystemEventBroker extends EventBroker {
+  private static final Logger log = LoggerFactory.getLogger(FileSystemEventBroker.class);
+  protected final EventStore store;
+  protected final Gson gson;
+  protected final DynamicSet<StreamEventListener> streamEventListeners;
+  protected volatile long lastSent;
+  protected static final String KEY_FILTER = "filter";
+  protected static final String FILTER_TYPE_DROP = "DROP";
+  protected static final String FILTER_ELEMENT_CLASSNAME = "classname";
+  protected Set<String> dropEventNames = new HashSet<>();
+
+  @Inject
+  public FileSystemEventBroker(
+      PluginSetContext<UserScopedEventListener> listeners,
+      PluginSetContext<EventListener> unrestrictedListeners,
+      PermissionBackend permissionBackend,
+      ProjectCache projectCache,
+      ChangeNotes.Factory notesFactory,
+      @Nullable @GerritInstanceId String gerritInstanceId,
+      EventStore store,
+      @EventGson Gson gson,
+      DynamicSet<StreamEventListener> streamEventListeners,
+      GerritServerConfigProvider gerritServerConfigProvider,
+      @PluginName String pluginName)
+      throws IOException {
+    super(
+        listeners,
+        unrestrictedListeners,
+        permissionBackend,
+        projectCache,
+        notesFactory,
+        gerritInstanceId);
+    this.store = store;
+    this.gson = gson;
+    this.streamEventListeners = streamEventListeners;
+    lastSent = store.getHead();
+    readAndParseCfg(pluginName, gerritServerConfigProvider);
+  }
+
+  @Override
+  public void postEvent(Change change, ChangeEvent event) throws PermissionBackendException {
+    storeEvent(event);
+    sendAllPendingEvents();
+  }
+
+  @Override
+  public void postEvent(Project.NameKey projectName, ProjectEvent event) {
+    storeEvent(event);
+    try {
+      sendAllPendingEvents();
+    } catch (PermissionBackendException e) {
+      log.error("Permission Exception while dispatching the event. Will be tried again.", e);
+    }
+  }
+
+  @Override
+  protected void fireEvent(BranchNameKey branchName, RefEvent event)
+      throws PermissionBackendException {
+    storeEvent(event);
+    sendAllPendingEvents();
+  }
+
+  @Override
+  public void postEvent(Event event) throws PermissionBackendException {
+    storeEvent(event);
+    sendAllPendingEvents();
+  }
+
+  protected void storeEvent(Event event) {
+    if (dropEventNames.contains(event.getClass().getName())) {
+      return;
+    }
+    try {
+      store.add(gson.toJson(event));
+    } catch (IOException ex) {
+      log.error("Cannot add event to event store", ex);
+    }
+  }
+
+  public synchronized void sendAllPendingEvents() throws PermissionBackendException {
+    try {
+      long current = store.getHead();
+      while (lastSent < current) {
+        long next = lastSent + 1;
+        fireEvent(gson.fromJson(store.get(next), Event.class));
+        lastSent = next;
+      }
+    } catch (IOException e) {
+      // Next Event would re-try the events.
+    }
+    for (StreamEventListener l : streamEventListeners) {
+      l.onStreamEventUpdate();
+    }
+  }
+
+  protected void readAndParseCfg(String pluginName, GerritServerConfigProvider configProvider) {
+    PluginConfig cfg = PluginConfig.createFromGerritConfig(pluginName, configProvider.loadConfig());
+    for (String filter : cfg.getStringList(KEY_FILTER)) {
+      String pieces[] = filter.split(" ");
+      if (pieces.length == 3
+          && FILTER_TYPE_DROP.equals(pieces[0])
+          && FILTER_ELEMENT_CLASSNAME.equals(pieces[1])) {
+        dropEventNames.add(pieces[2]);
+      } else {
+        log.error("Ignoring invalid filter: " + filter);
+      }
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/events/Module.java b/src/main/java/com/googlesource/gerrit/plugins/events/Module.java
index 27830fb..504f740 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/events/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/events/Module.java
@@ -14,16 +14,45 @@
 
 package com.googlesource.gerrit.plugins.events;
 
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.server.events.EventListener;
-import com.google.inject.AbstractModule;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.events.EventDispatcher;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.events.fsstore.FsListener.FsLifecycleListener;
 import com.googlesource.gerrit.plugins.events.fsstore.FsStore;
 
-public class Module extends AbstractModule {
+public class Module extends LifecycleModule {
+  private static final int DEFAULT_POLLING_INTERVAL = 0;
+
+  @Provides
+  @Singleton
+  @PollingInterval
+  protected Long getCleanupInterval(PluginConfigFactory cfg, @PluginName String pluginName) {
+    String fromConfig =
+        Strings.nullToEmpty(cfg.getFromGerritConfig(pluginName).getString("pollingInterval"));
+    return SECONDS.toMillis(ConfigUtil.getTimeUnit(fromConfig, DEFAULT_POLLING_INTERVAL, SECONDS));
+  }
+
+  @Provides
+  @Singleton
+  @PollingQueue
+  protected String getPollingQueue(PluginConfigFactory cfg, @PluginName String pluginName) {
+    return Strings.nullToEmpty(cfg.getFromGerritConfig(pluginName).getString("queue"));
+  }
+
   @Override
   protected void configure() {
     DynamicSet.setOf(binder(), StreamEventListener.class);
     bind(EventStore.class).to(FsStore.class);
-    DynamicSet.bind(binder(), EventListener.class).to(CoreListener.class);
+    DynamicItem.bind(binder(), EventDispatcher.class).to(FileSystemEventBroker.class);
+    listener().to(FsLifecycleListener.class);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/events/PollingInterval.java b/src/main/java/com/googlesource/gerrit/plugins/events/PollingInterval.java
new file mode 100644
index 0000000..bf05885
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/events/PollingInterval.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2017 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.events;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface PollingInterval {}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/events/PollingQueue.java b/src/main/java/com/googlesource/gerrit/plugins/events/PollingQueue.java
new file mode 100644
index 0000000..375b11b
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/events/PollingQueue.java
@@ -0,0 +1,24 @@
+// 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.events;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface PollingQueue {}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/events/TrimCommand.java b/src/main/java/com/googlesource/gerrit/plugins/events/TrimCommand.java
index 79a08c8..7eb562e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/events/TrimCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/events/TrimCommand.java
@@ -23,15 +23,13 @@
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(
-  name = "trim",
-  description = "Trim old events up to and including trim-id from EventStore"
-)
+    name = "trim",
+    description = "Trim old events up to and including trim-id from EventStore")
 final class TrimCommand extends SshCommand {
   @Option(
-    name = "--trim-id",
-    metaVar = "TRIM_ID",
-    usage = "Trim old events up to and including trim-id from EventStore"
-  )
+      name = "--trim-id",
+      metaVar = "TRIM_ID",
+      usage = "Trim old events up to and including trim-id from EventStore")
   protected long trim = -1;
 
   @Option(name = "--size", metaVar = "SIZE", usage = "Trim and keep SIZE events")
diff --git a/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FsListener.java b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FsListener.java
new file mode 100644
index 0000000..7327700
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/FsListener.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2017 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.events.fsstore;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.events.EventDispatcher;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.events.FileSystemEventBroker;
+import com.googlesource.gerrit.plugins.events.PollingInterval;
+import com.googlesource.gerrit.plugins.events.PollingQueue;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+
+@Singleton
+public class FsListener implements Runnable {
+  public static class FsLifecycleListener implements LifecycleListener {
+    protected final String workQueue;
+    protected final WorkQueue queue;
+    protected final long pollingInterval;
+    protected final FileSystemEventBroker broker;
+    protected ScheduledFuture<?> future;
+
+    @Inject
+    protected FsLifecycleListener(
+        WorkQueue queue,
+        @PollingInterval long pollingInterval,
+        EventDispatcher dispatcher,
+        @PollingQueue String workQueue) {
+      this.queue = queue;
+      this.pollingInterval = pollingInterval;
+      this.broker = (FileSystemEventBroker) dispatcher;
+      this.workQueue = workQueue;
+    }
+
+    @Override
+    public void start() {
+      if (pollingInterval > 0) {
+        ScheduledExecutorService executor = queue.getExecutor(workQueue);
+        if (executor == null) {
+          executor = queue.getDefaultQueue();
+        }
+        future =
+            executor.scheduleAtFixedRate(
+                new FsListener(broker), pollingInterval, pollingInterval, MILLISECONDS);
+      }
+    }
+
+    @Override
+    public void stop() {
+      if (future != null) {
+        future.cancel(true);
+      }
+    }
+  }
+
+  protected final FileSystemEventBroker broker;
+
+  @Inject
+  protected FsListener(FileSystemEventBroker broker) {
+    this.broker = broker;
+  }
+
+  @Override
+  public void run() {
+    try {
+      broker.sendAllPendingEvents();
+    } catch (PermissionBackendException e) {
+      // Ignore
+    }
+  }
+
+  @Override
+  public String toString() {
+    return "Events FS Polling Listener";
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/Nfs.java b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/Nfs.java
index 1454fbf..3573369 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/Nfs.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/events/fsstore/Nfs.java
@@ -22,12 +22,11 @@
 /** Some NFS utilities */
 public class Nfs {
   /**
-   * Determine if a throwable or a cause in its causal chain is a Stale NFS
-   * File Handle
+   * Determine if a throwable or a cause in its causal chain is a Stale NFS File Handle
    *
    * @param throwable
-   * @return a boolean true if the throwable or a cause in its causal chain is
-   *         a Stale NFS File Handle
+   * @return a boolean true if the throwable or a cause in its causal chain is a Stale NFS File
+   *     Handle
    */
   public static boolean isStaleFileHandleInCausalChain(Throwable throwable) {
     while (throwable != null) {
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
index f0639f5..f4d8079 100644
--- a/src/main/resources/Documentation/about.md
+++ b/src/main/resources/Documentation/about.md
@@ -11,3 +11,34 @@
 are stored under "<site_dir>/data/plugin/events".  Events do not use
 significant disk space, however it might still make sense to regularly
 trim them with a cron job.
+
+<a id="filtering"/>
+@PLUGIN@ Filtering
+------------------
+
+The @PLUGIN@ plugin is able to drop events which site admins do not
+want stored or sent out to users. Event filtering can be configured
+in the `gerrit.config`, using the following "git-config" style
+parameter:
+
+*`plugin.@PLUGIN@.filter`
+
+: rule to filter events with. Supported rules look like:
+
+ DROP classname fully.qualified.java.ClassName
+
+If the `plugin.@PLUGIN@.filter` key is specified more than once it
+will cause events matching any of the rules to be dropped.
+
+The example config below drops all known replication plugin events:
+
+```
+[plugin "events"]
+  filter = DROP classname com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationDoneEvent
+  filter = DROP classname com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationFailedEvent
+  filter = DROP classname com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationScheduledEvent
+  filter = DROP classname com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationSucceededEvent
+  filter = DROP classname com.googlesource.gerrit.plugins.replication.events.RefReplicatedEvent
+  filter = DROP classname com.googlesource.gerrit.plugins.replication.events.RefReplicationDoneEvent
+  filter = DROP classname com.googlesource.gerrit.plugins.replication.events.ReplicationScheduledEvent
+```
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
new file mode 100644
index 0000000..cf01fd3
--- /dev/null
+++ b/src/main/resources/Documentation/config.md
@@ -0,0 +1,59 @@
+@PLUGIN@ Configuration
+======================
+
+In a multi-primary environment events may be created and written
+to the filestore from any primary.  Without a notification system,
+events written by other servers might only be seen by the current
+server when the current server creates a new event.  If the traffic
+on the current server is low, this delay may be significant and
+unacceptable.
+
+In order to get events from other primaries output in a timely manner,
+the @PLUGIN@ plugin can be configured to poll and recongnize when
+new events may have been written to the filestore and output them.
+
+Reload the plugin on each primary for the changes to take effect.
+
+Config
+------
+
+`pollingInterval`
+
+: The polling frequency can be specified in the configuration.
+For example:
+
+```
+  [plugin "@PLUGIN@"]
+    pollingInterval = 3s
+```
+
+causes polling to be done every 3 seconds.
+
+Values should use common time unit suffixes to express their setting:
+
+* s, sec, second, seconds
+* m, min, minute, minutes
+* h, hr, hour, hours
+* d, day, days
+* w, week, weeks (`1 week` is treated as `7 days`)
+* mon, month, months (`1 month` is treated as `30 days`)
+* y, year, years (`1 year` is treated as `365 days`)
+
+If a time unit suffix is not specified, `seconds` is assumed.
+
+If 'pollingInterval' is not present in the configuration, polling
+will not be enabled.
+
+`queue`
+
+: The work queue on which the polling is scheduled can be provided
+using the queue option. Example
+
+```
+  [plugin "@PLUGIN@"]
+    queue = myQueue
+```
+
+causes polling to be scheduled on myQueue executor. If the specified
+queue is not found or the queue is not specified, default queue
+(WorkQueue) is used.
diff --git a/test/docker/docker-compose.yaml b/test/docker/docker-compose.yaml
index 7014537..de23263 100755
--- a/test/docker/docker-compose.yaml
+++ b/test/docker/docker-compose.yaml
@@ -4,9 +4,6 @@
   gerrit-01:
     build:
       context: gerrit
-      args:
-        - GERRIT_WAR
-        - EVENTS_PLUGIN_JAR
     networks:
       - gerrit-net
     volumes:
@@ -19,6 +16,7 @@
     volumes:
       - "../../:/events:ro"
       - "gerrit-site-etc:/server-ssh-key:ro"
+      - "gerrit-site-etc:/gerrit.config"
     depends_on:
       - gerrit-01
     environment:
diff --git a/test/docker/gerrit/Dockerfile b/test/docker/gerrit/Dockerfile
index 0bc19e2..86e21dc 100755
--- a/test/docker/gerrit/Dockerfile
+++ b/test/docker/gerrit/Dockerfile
@@ -1,13 +1,12 @@
 FROM gerritcodereview/gerrit:3.6.1-ubuntu20
 
-USER root
-
 ENV GERRIT_SITE /var/gerrit
-RUN git config -f "$GERRIT_SITE/etc/gerrit.config" auth.type \
-    DEVELOPMENT_BECOME_ANY_ACCOUNT
+COPY artifacts/plugins/ $GERRIT_SITE/plugins/
 
-COPY artifacts /tmp/
-RUN cp /tmp/events.jar "$GERRIT_SITE/plugins/events.jar"
+USER root
+COPY artifacts/bin/ /tmp/
 RUN { [ -e /tmp/gerrit.war ] && cp /tmp/gerrit.war "$GERRIT_SITE/bin/gerrit.war" ; } || true
+COPY start.sh /
 
 USER gerrit
+ENTRYPOINT /start.sh
diff --git a/test/docker/gerrit/start.sh b/test/docker/gerrit/start.sh
new file mode 100755
index 0000000..4e00b41
--- /dev/null
+++ b/test/docker/gerrit/start.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+
+echo "Initializing Gerrit site ..."
+java -jar "$GERRIT_SITE/bin/gerrit.war" init -d "$GERRIT_SITE" \
+    --batch --dev
+
+echo "Running Gerrit ..."
+exec "$GERRIT_SITE"/bin/gerrit.sh run
diff --git a/test/docker/run.sh b/test/docker/run.sh
index 5b1894d..40aa32e 100755
--- a/test/docker/run.sh
+++ b/test/docker/run.sh
@@ -3,6 +3,8 @@
 readlink -f / &> /dev/null || readlink() { greadlink "$@" ; } # for MacOS
 MYDIR=$(dirname -- "$(readlink -f -- "$0")")
 ARTIFACTS=$MYDIR/gerrit/artifacts
+PLUGIN=events
+BAZEL_BUILT_JAR=$MYDIR/../../bazel-bin/$PLUGIN.jar
 
 die() { echo -e "\nERROR: $@" ; kill $$ ; exit 1 ; } # error_message
 
@@ -23,20 +25,21 @@
     local prog=$(basename "$0")
     cat <<EOF
 Usage:
-    $prog [--events-plugin-jar|-t <FILE_PATH>] [--gerrit-war|-g <FILE_PATH>]
+    $prog [--plugin <plugin name> <JAR path>] [--gerrit-war|-g <FILE_PATH>]
 
     This tool runs the plugin functional tests in a Docker environment built
     from the gerritcodereview/gerrit base Docker image.
 
-    The events plugin JAR and optionally a Gerrit WAR are expected to be in the
-    $ARTIFACTS dir;
-    however, the --events-plugin-jar and --gerrit-war switches may be used as
-    helpers to specify which files to copy there.
-
     Options:
     --help|-h
-    --gerrit-war|-g             path to Gerrit WAR file
-    --events-plugin-jar|-e      path to events plugin JAR file
+    --gerrit-war|-g             optional path to Gerrit WAR file. Will likely
+                                not function correctly if it's a different
+                                MAJOR.MINOR version than the image version
+                                in test/docker/gerrit/Dockerfile.
+    --plugin                    optional plugin name and path to JAR file
+                                Defaults to '$PLUGIN' '$BAZEL_BUILT_JAR'
+                                Can be repeated to install additional
+                                plugins.
 
 EOF
 
@@ -59,15 +62,36 @@
         '/events/test/docker/run_tests/start.sh'
 }
 
-cleanup() {
-    docker-compose "${COMPOSE_ARGS[@]}" down -v --rmi local 2>/dev/null
+fetch_artifact() { # source_location output_path
+    if [[ "$1" =~ ^file://|^http://|^https:// ]] ; then
+        curl --silent --fail --netrc "$1" --output "$2" --create-dirs || die "unable to fetch $1"
+    else
+        cp -f "$1" "$2" || die "unable to copy $1"
+    fi
 }
 
+fetch_artifacts() {
+    local plugin_name
+    [ -n "$GERRIT_WAR" ] && fetch_artifact "$GERRIT_WAR" "$ARTIFACTS/bin/gerrit.war"
+    for plugin_name in "${!PLUGIN_JAR_BY_NAME[@]}" ; do
+        if [ -n "${PLUGIN_JAR_BY_NAME["$plugin_name"]}" ] ; then
+            fetch_artifact "${PLUGIN_JAR_BY_NAME[$plugin_name]}" \
+                "$ARTIFACTS/plugins/$plugin_name.jar"
+        fi
+    done
+}
+
+cleanup() {
+    docker-compose "${COMPOSE_ARGS[@]}" down -v --rmi local 2>/dev/null
+    rm -rf "$ARTIFACTS"
+}
+
+declare -A PLUGIN_JAR_BY_NAME
 while (( "$#" )); do
     case "$1" in
         --help|-h)                    usage ;;
         --gerrit-war|-g)              shift ; GERRIT_WAR=$1 ;;
-        --events-plugin-jar|-e)       shift ; EVENTS_PLUGIN_JAR=$1 ;;
+        --plugin)                     shift ; PLUGIN_JAR_BY_NAME["$1"]=$2 ; shift ;;
         *)                            usage "invalid argument $1" ;;
     esac
     shift
@@ -77,14 +101,14 @@
 COMPOSE_YAML="$MYDIR/docker-compose.yaml"
 COMPOSE_ARGS=(--project-name "$PROJECT_NAME" -f "$COMPOSE_YAML")
 check_prerequisite
-mkdir -p -- "$ARTIFACTS"
-[ -n "$EVENTS_PLUGIN_JAR" ] && cp -f -- "$EVENTS_PLUGIN_JAR" "$ARTIFACTS/events.jar"
-if [ ! -e "$ARTIFACTS/events.jar" ] ; then
-    MISSING="Missing $ARTIFACTS/events.jar"
-    [ -n "$EVENTS_PLUGIN_JAR" ] && die "$MISSING, check for copy failure?"
-    usage "$MISSING, did you forget --events-plugin-jar?"
+mkdir -p -- "$ARTIFACTS/bin" "$ARTIFACTS/plugins"
+if [ -z "${PLUGIN_JAR_BY_NAME["$PLUGIN"]}" ] ; then
+    PLUGIN_JAR_BY_NAME["$PLUGIN"]=$BAZEL_BUILT_JAR
 fi
-[ -n "$GERRIT_WAR" ] && cp -f -- "$GERRIT_WAR" "$ARTIFACTS/gerrit.war"
+if [ ! -e "${PLUGIN_JAR_BY_NAME["$PLUGIN"]}" ] ; then
+    usage "Cannot find plugin jar, did you forget --plugin?"
+fi
+progress "Fetching artifacts" fetch_artifacts
 ( trap cleanup EXIT SIGTERM
     progress "Building docker images" build_images
     run_events_plugin_tests
diff --git a/test/test_events_plugin.sh b/test/test_events_plugin.sh
index 84e0a6d..f8ba2f9 100755
--- a/test/test_events_plugin.sh
+++ b/test/test_events_plugin.sh
@@ -7,9 +7,18 @@
 # plugin_name
 is_plugin_installed() { gssh gerrit plugin ls | awk '{print $1}' | grep -q "^$1$"; }
 
+set_filter_rules() { # [rule]...
+    git config -f "$GERRIT_CFG" --unset-all plugin.events.filter
+    local rule
+    for rule in "$@" ; do
+        git config -f "$GERRIT_CFG" plugin.events.filter "$rule"
+    done
+    gssh gerrit plugin reload events
+}
+
 cleanup() {
     wait_event
-    (kill_captures ; sleep 1 ; kill_captures -9 ) &
+    (kill_diff_captures ; sleep 1 ; kill_diff_captures -9 ) &
 }
 
 # > uuid
@@ -99,14 +108,14 @@
 
 # ------------------------- Event Capturing ---------------------------
 
-kill_captures() { # sig
+kill_diff_captures() { # sig
     local pid
     for pid in "${CAPTURE_PIDS[@]}" ; do
         q kill $1 $pid
     done
 }
 
-setup_captures() {
+setup_diff_captures() {
     ssh -p 29418 -x "$SERVER" "${CORE_CMD[@]}" > "$EVENTS_CORE" &
     CAPTURE_PIDS=("${CAPTURE_PIDS[@]}" $!)
     ssh -p 29418 -x "$SERVER" "${PLUGIN_CMD[@]}" > "$EVENTS_PLUGIN" &
@@ -140,14 +149,18 @@
    q wait $CAPTURE_PID_SSH
 }
 
-result_type() { # test type [expected_count]
+
+result_event() { # test type [expected_count]
     local test=$1 type=$2 expected_count=$3
     [ -n "$expected_count" ] || expected_count=1
     wait_event
     local actual_count=$(grep -c "\"type\":\"$type\"" "$EVENTS")
-    result_out "$test $type" "$expected_count $type event(s)" "$actual_count $type event(s)"
+    result_out "$test" "$expected_count $type event(s)" "$actual_count $type event(s)"
 }
 
+# test type [expected_count]
+result_type() { result_event "$1 $2" "$2" "$3" ; }
+
 # ------------------------- Usage ---------------------------
 
 usage() { # [error_message]
@@ -223,11 +236,10 @@
 EVENTS_PLUGIN=$TEST_DIR/events-plugin
 EVENT_FIFO=$TEST_DIR/event-fifo
 EVENTS=$TEST_DIR/events
+GERRIT_CFG="/gerrit.config/gerrit.config"
 
 trap cleanup EXIT
 
-setup_captures
-
 # We need to do an initial REST call, as the first REST call after a server is
 # brought up results in being anonymous despite providing proper authentication.
 get_open_changes
@@ -236,6 +248,9 @@
 
 # ------------------------- Individual Event Tests ---------------------------
 GROUP=visible-events
+set_filter_rules # No rules
+setup_diff_captures
+
 type=patchset-created
 capture_events 3
 ch1=$(create_change "$REF_BRANCH" "$FILE_A") || exit
@@ -286,9 +301,29 @@
 
 # reviewer-added needs to be tested via Rest-API
 
-# ------------------------- Compare them all to Core -------------------------
-
 out=$(diff -- "$EVENTS_CORE" "$EVENTS_PLUGIN")
-result "core/plugin diff" "$out"
+result "$GROUP core/plugin diff" "$out"
+
+kill_diff_captures
+
+# ------------------------- Filtering -------------------------
+
+GROUP=restored-filtered
+set_filter_rules 'DROP classname com.google.gerrit.server.events.ChangeRestoredEvent'
+
+ch1=$(create_change "$REF_BRANCH" "$FILE_A") || exit
+type=change-abandoned
+
+capture_events 2
+review "$ch1,1" --abandon
+result_type "$GROUP" "$type"
+
+type=change-restored
+capture_events 3
+review "$ch1,1" --restore
+# Instead of timing out waiting for the filtered change-restored event,
+# create follow-on events and capture them to trigger completion.
+review "$ch1,1" --message "'\"trigger filtered completion\"'"
+result_type "$GROUP" "$type" 0
 
 exit $RESULT