Removing deprecated DMAAP library

Issue-ID: POLICY-4402
Change-Id: I4ce5b303cb3a775010683d3c7102b0bd065280ea
Signed-off-by: adheli.tavares <adheli.tavares@est.tech>
diff --git a/feature-pooling-messages/pom.xml b/feature-pooling-messages/pom.xml
new file mode 100644
index 0000000..756abca
--- /dev/null
+++ b/feature-pooling-messages/pom.xml
@@ -0,0 +1,108 @@
+<!--
+  ============LICENSE_START=======================================================
+  ONAP Policy Engine - Drools PDP
+  ================================================================================
+  Copyright (C) 2018-2020 AT&T Intellectual Property. All rights reserved.
+  Modifications Copyright (C) 2024 Nordix Foundation.
+  ================================================================================
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+  ============LICENSE_END=========================================================
+  -->
+
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.onap.policy.drools-pdp</groupId>
+        <artifactId>drools-pdp</artifactId>
+        <version>2.1.1-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>feature-pooling-messages</artifactId>
+
+    <name>feature-pooling-messages</name>
+    <description>Endpoints</description>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-assembly-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>zipfile</id>
+                        <goals>
+                            <goal>single</goal>
+                        </goals>
+                        <phase>package</phase>
+                        <configuration>
+                            <attach>true</attach>
+                            <finalName>${project.artifactId}-${project.version}</finalName>
+                            <descriptors>
+                                <descriptor>src/assembly/assemble_zip.xml</descriptor>
+                            </descriptors>
+                            <appendAssemblyId>false</appendAssemblyId>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-dependency-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>copy-dependencies</id>
+                        <goals>
+                            <goal>copy-dependencies</goal>
+                        </goals>
+                        <phase>prepare-package</phase>
+                        <configuration>
+                            <outputDirectory>${project.build.directory}/assembly/lib</outputDirectory>
+                            <overWriteReleases>false</overWriteReleases>
+                            <overWriteSnapshots>true</overWriteSnapshots>
+                            <overWriteIfNewer>true</overWriteIfNewer>
+                            <useRepositoryLayout>false</useRepositoryLayout>
+                            <addParentPoms>false</addParentPoms>
+                            <copyPom>false</copyPom>
+                            <includeScope>runtime</includeScope>
+                            <excludeTransitive>true</excludeTransitive>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+
+    <dependencies>
+        <dependency>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-classic</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.onap.policy.drools-pdp</groupId>
+            <artifactId>policy-core</artifactId>
+            <version>${project.version}</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.onap.policy.drools-pdp</groupId>
+            <artifactId>policy-management</artifactId>
+            <version>${project.version}</version>
+            <scope>provided</scope>
+        </dependency>
+    </dependencies>
+
+</project>
diff --git a/feature-pooling-messages/src/assembly/assemble_zip.xml b/feature-pooling-messages/src/assembly/assemble_zip.xml
new file mode 100644
index 0000000..6742411
--- /dev/null
+++ b/feature-pooling-messages/src/assembly/assemble_zip.xml
@@ -0,0 +1,76 @@
+<!--
+  ============LICENSE_START=======================================================
+  feature-pooling-messages
+  ================================================================================
+  Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+  ================================================================================
+  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.
+  ============LICENSE_END=========================================================
+  -->
+
+<!-- Defines how we build the .zip file which is our distribution. -->
+
+<assembly
+	xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0"
+	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0 http://maven.apache.org/xsd/assembly-1.1.0.xsd">
+	<id>feature-pooling-messages</id>
+	<formats>
+		<format>zip</format>
+	</formats>
+
+	<!-- we want "system" and related files right at the root level as this 
+		file is supposed to be unzipped on top of a karaf distro. -->
+	<includeBaseDirectory>false</includeBaseDirectory>
+
+	<fileSets>
+		<fileSet>
+			<directory>target</directory>
+			<outputDirectory>lib/feature</outputDirectory>
+			<includes>
+				<include>feature-pooling-messages-${project.version}.jar</include>
+			</includes>
+		</fileSet>
+		<fileSet>
+			<directory>target/assembly/lib</directory>
+			<outputDirectory>lib/dependencies</outputDirectory>
+			<includes>
+				<include>*.jar</include>
+			</includes>
+		</fileSet>
+		<fileSet>
+			<directory>src/main/feature/config</directory>
+			<outputDirectory>config</outputDirectory>
+			<fileMode>0644</fileMode>
+			<excludes/>
+		</fileSet>
+		<fileSet>
+			<directory>src/main/feature/bin</directory>
+			<outputDirectory>bin</outputDirectory>
+			<fileMode>0744</fileMode>
+			<excludes/>
+		</fileSet>
+		<fileSet>
+			<directory>src/main/feature/db</directory>
+			<outputDirectory>db</outputDirectory>
+			<fileMode>0744</fileMode>
+			<excludes/>
+		</fileSet>
+		<fileSet>
+			<directory>src/main/feature/install</directory>
+			<outputDirectory>install</outputDirectory>
+			<fileMode>0744</fileMode>
+			<excludes/>
+		</fileSet>
+	</fileSets>
+</assembly>
diff --git a/feature-pooling-messages/src/main/feature/config/feature-pooling-messages.properties b/feature-pooling-messages/src/main/feature/config/feature-pooling-messages.properties
new file mode 100644
index 0000000..925d169
--- /dev/null
+++ b/feature-pooling-messages/src/main/feature/config/feature-pooling-messages.properties
@@ -0,0 +1,89 @@
+###
+# ============LICENSE_START=======================================================
+# feature-pooling-messages
+# ================================================================================
+# Copyright (C) 2018-2020 AT&T Intellectual Property. All rights reserved.
+# Modifications Copyright (C) 2024 Nordix Foundation.
+# ================================================================================
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ============LICENSE_END=========================================================
+###
+
+# In general, the feature-specific properties begin with "pooling",
+# and they may be made specific to a controller by prepending with
+# "pooling.<controller-name>", instead.
+#
+# The available properties and their default values are shown below.
+
+# Whether the feature is enabled.
+#pooling.enabled=false
+
+# The internal kafka topic used by a controller.  Note: the controller
+# name is required for this property.
+#pooling.<controller-name>.topic =
+
+# Maximum number of events to retain in the queue while a new host waits
+# to be assigned work.
+#pooling.offline.queue.limit=1000
+
+# Maximum age, in milliseconds, of events to be retained in the queue.
+# Events older than this are discarded.
+#pooling.offline.queue.age.milliseconds=60000
+
+# Time, in milliseconds, to wait for an "Offline" message to be published
+# to topic manager before the connection may be closed.
+#pooling.offline.publish.wait.milliseconds=3000
+
+# Time, in milliseconds, to wait for this host's initial heart beat.  This
+# is used to verify connectivity to the internal topic.
+#pooling.start.heartbeat.milliseconds=100000
+
+# Time, in milliseconds, to wait before attempting to reactivate this
+# host when it was not assigned any work.
+#pooling.reactivate.milliseconds=50000
+
+# Time, in milliseconds, to wait for other hosts to identify themselves
+# when this host is started.
+#pooling.identification.milliseconds=50000
+
+# Time, in milliseconds, to wait for heart beats from this host, or its
+# predecessor, during the active state.
+#pooling.active.heartbeat.milliseconds=50000
+
+# Time, in milliseconds, to wait between heart beat generations.
+#pooling.inter.heartbeat.milliseconds=15000
+
+# Topic used for inter-host communication for a particular controller
+# pooling.<controller-name>.topic=XXX
+
+# Each controller that is enabled should have its own topic and the
+# corresponding ${topicManager}.xxx properties (using kafka as default).
+# However, for now, just assume that the usecases features will not both
+# be enabled at the same time.
+
+pooling.usecases.enabled=true
+pooling.usecases.topic=${env:POOLING_TOPIC}
+
+# the list of sources and sinks should be identical
+kafka.source.topics=POOLING_TOPIC
+kafka.sink.topics=POOLING_TOPIC
+
+kafka.source.topics.POOLING_TOPIC.servers=${env:KAFKA_SERVERS}
+kafka.source.topics.POOLING_TOPIC.effectiveTopic=${env:POOLING_TOPIC}
+kafka.source.topics.POOLING_TOPIC.apiKey=
+kafka.source.topics.POOLING_TOPIC.apiSecret=
+
+kafka.sink.topics.POOLING_TOPIC.servers=${env:kafka_SERVERS}
+kafka.sink.topics.POOLING_TOPIC.effectiveTopic=${env:POOLING_TOPIC}
+kafka.sink.topics.POOLING_TOPIC.apiKey=
+kafka.sink.topics.POOLING_TOPIC.apiSecret=
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/CancellableScheduledTask.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/CancellableScheduledTask.java
new file mode 100644
index 0000000..4e9112e
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/CancellableScheduledTask.java
@@ -0,0 +1,34 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2019 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+/**
+ * A scheduled task that can be cancelled.
+ */
+@FunctionalInterface
+public interface CancellableScheduledTask {
+
+    /**
+     * Cancels the scheduled task.
+     */
+    void cancel();
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingFeature.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingFeature.java
new file mode 100644
index 0000000..6411dd8
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingFeature.java
@@ -0,0 +1,397 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import java.util.List;
+import java.util.Properties;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import lombok.AccessLevel;
+import lombok.Getter;
+import org.onap.policy.common.endpoints.event.comm.Topic.CommInfrastructure;
+import org.onap.policy.common.endpoints.event.comm.TopicEndpointManager;
+import org.onap.policy.common.endpoints.event.comm.TopicSink;
+import org.onap.policy.common.endpoints.event.comm.TopicSource;
+import org.onap.policy.common.utils.properties.SpecProperties;
+import org.onap.policy.common.utils.properties.exception.PropertyException;
+import org.onap.policy.drools.controller.DroolsController;
+import org.onap.policy.drools.features.DroolsControllerFeatureApi;
+import org.onap.policy.drools.features.PolicyControllerFeatureApi;
+import org.onap.policy.drools.features.PolicyEngineFeatureApi;
+import org.onap.policy.drools.persistence.SystemPersistenceConstants;
+import org.onap.policy.drools.system.PolicyController;
+import org.onap.policy.drools.system.PolicyControllerConstants;
+import org.onap.policy.drools.system.PolicyEngine;
+import org.onap.policy.drools.util.FeatureEnabledChecker;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Controller/session pooling. Multiple hosts may be launched, all servicing the same
+ * controllers/sessions. When this feature is enabled, the requests are divided across the different
+ * hosts, instead of all running on a single, active host.
+ *
+ * <p>With each controller, there is an
+ * associated DMaaP topic that is used for internal communication between the different hosts
+ * serving the controller.
+ */
+public class PoolingFeature implements PolicyEngineFeatureApi, PolicyControllerFeatureApi, DroolsControllerFeatureApi {
+
+    private static final Logger logger = LoggerFactory.getLogger(PoolingFeature.class);
+
+    /**
+     * ID of this host.
+     */
+    @Getter
+    private final String host;
+
+    /**
+     * Entire set of feature properties, including those specific to various controllers.
+     */
+    private Properties featProps = null;
+
+    /**
+     * Maps a controller name to its associated manager.
+     */
+    private final ConcurrentHashMap<String, PoolingManagerImpl> ctlr2pool = new ConcurrentHashMap<>(107);
+
+    /**
+     * Decremented each time a manager enters the Active state. Used by junit tests.
+     */
+    @Getter(AccessLevel.PROTECTED)
+    private final CountDownLatch activeLatch = new CountDownLatch(1);
+
+    /**
+     * Topic names passed to beforeOffer(), which are saved for when the beforeInsert() is
+     * called later. As multiple threads can be active within the methods at the same
+     * time, we must keep this in thread local storage.
+     */
+    private ThreadLocal<String> offerTopics = new ThreadLocal<>();
+
+    /**
+     * Constructor.
+     */
+    public PoolingFeature() {
+        super();
+
+        this.host = UUID.randomUUID().toString();
+    }
+
+    @Override
+    public int getSequenceNumber() {
+        return 0;
+    }
+
+    @Override
+    public boolean beforeStart(PolicyEngine engine) {
+        logger.info("initializing {}", PoolingProperties.FEATURE_NAME);
+        featProps = getProperties(PoolingProperties.FEATURE_NAME);
+
+        // remove any generic pooling topic - always use controller-specific property
+        featProps.remove(PoolingProperties.POOLING_TOPIC);
+
+        initTopicSources(featProps);
+        initTopicSinks(featProps);
+
+        return false;
+    }
+
+    @Override
+    public boolean beforeStart(PolicyController controller) {
+        return doManager(controller, mgr -> {
+            mgr.beforeStart();
+            return false;
+        });
+    }
+
+    /**
+     * Adds the controller and a new pooling manager to {@link #ctlr2pool}.
+     *
+     * @throws PoolingFeatureRtException if an error occurs
+     */
+    @Override
+    public boolean afterCreate(PolicyController controller) {
+
+        if (featProps == null) {
+            logger.error("pooling feature properties have not been loaded");
+            throw new PoolingFeatureRtException(new IllegalStateException("missing pooling feature properties"));
+        }
+
+        String name = controller.getName();
+
+        var specProps = new SpecProperties(PoolingProperties.PREFIX, name, featProps);
+
+        if (FeatureEnabledChecker.isFeatureEnabled(specProps, PoolingProperties.FEATURE_ENABLED)) {
+            try {
+                // get & validate the properties
+                var props = new PoolingProperties(name, featProps);
+
+                logger.info("pooling enabled for {}", name);
+                ctlr2pool.computeIfAbsent(name, xxx -> makeManager(host, controller, props, activeLatch));
+
+            } catch (PropertyException e) {
+                logger.error("pooling disabled due to exception for {}", name);
+                throw new PoolingFeatureRtException(e);
+            }
+
+        } else {
+            logger.info("pooling disabled for {}", name);
+        }
+
+
+        return false;
+    }
+
+    @Override
+    public boolean afterStart(PolicyController controller) {
+        return doManager(controller, mgr -> {
+            mgr.afterStart();
+            return false;
+        });
+    }
+
+    @Override
+    public boolean beforeStop(PolicyController controller) {
+        return doManager(controller, mgr -> {
+            mgr.beforeStop();
+            return false;
+        });
+    }
+
+    @Override
+    public boolean afterStop(PolicyController controller) {
+        return doManager(controller, mgr -> {
+            mgr.afterStop();
+            return false;
+        });
+    }
+
+    @Override
+    public boolean afterShutdown(PolicyController controller) {
+        return commonShutdown(controller);
+    }
+
+    @Override
+    public boolean afterHalt(PolicyController controller) {
+        return commonShutdown(controller);
+    }
+
+    private boolean commonShutdown(PolicyController controller) {
+        deleteManager(controller);
+        return false;
+    }
+
+    @Override
+    public boolean beforeLock(PolicyController controller) {
+        return doManager(controller, mgr -> {
+            mgr.beforeLock();
+            return false;
+        });
+    }
+
+    @Override
+    public boolean afterUnlock(PolicyController controller) {
+        return doManager(controller, mgr -> {
+            mgr.afterUnlock();
+            return false;
+        });
+    }
+
+    @Override
+    public boolean beforeOffer(PolicyController controller, CommInfrastructure protocol, String topic2, String event) {
+        /*
+         * As this is invoked a lot, we'll directly call the manager's method instead of using the
+         * functional interface via doManager().
+         */
+        PoolingManagerImpl mgr = ctlr2pool.get(controller.getName());
+        if (mgr == null) {
+            return false;
+        }
+
+        if (mgr.beforeOffer(topic2, event)) {
+            return true;
+        }
+
+        offerTopics.set(topic2);
+        return false;
+    }
+
+    @Override
+    public boolean beforeInsert(DroolsController droolsController, Object fact) {
+
+        String topic = offerTopics.get();
+        if (topic == null) {
+            logger.warn("missing arguments for feature-pooling-messages in beforeInsert");
+            return false;
+        }
+
+        PolicyController controller;
+        try {
+            controller = getController(droolsController);
+
+        } catch (IllegalArgumentException | IllegalStateException e) {
+            logger.warn("cannot get controller for {} {}", droolsController.getGroupId(),
+                    droolsController.getArtifactId(), e);
+            return false;
+        }
+
+
+        if (controller == null) {
+            logger.warn("cannot determine controller for {} {}", droolsController.getGroupId(),
+                    droolsController.getArtifactId());
+            return false;
+        }
+
+        /*
+         * As this is invoked a lot, we'll directly call the manager's method instead of using the
+         * functional interface via doManager().
+         */
+        PoolingManagerImpl mgr = ctlr2pool.get(controller.getName());
+        if (mgr == null) {
+            return false;
+        }
+
+        return mgr.beforeInsert(topic, fact);
+    }
+
+    @Override
+    public boolean afterOffer(PolicyController controller, CommInfrastructure protocol, String topic, String event,
+            boolean success) {
+
+        // clear any stored arguments
+        offerTopics.remove();
+
+        return false;
+    }
+
+    /**
+     * Executes a function using the manager associated with the controller. Catches any exceptions
+     * from the function and re-throws it as a runtime exception.
+     *
+     * @param controller controller
+     * @param func function to be executed
+     * @return {@code true} if the function handled the request, {@code false} otherwise
+     * @throws PoolingFeatureRtException if an error occurs
+     */
+    private boolean doManager(PolicyController controller, MgrFunc func) {
+        PoolingManagerImpl mgr = ctlr2pool.get(controller.getName());
+        if (mgr == null) {
+            return false;
+        }
+
+        try {
+            return func.apply(mgr);
+
+        } catch (PoolingFeatureException e) {
+            throw new PoolingFeatureRtException(e);
+        }
+    }
+
+    /**
+     * Deletes the manager associated with a controller.
+     *
+     * @param controller controller
+     * @throws PoolingFeatureRtException if an error occurs
+     */
+    private void deleteManager(PolicyController controller) {
+
+        String name = controller.getName();
+        logger.info("remove feature-pooling-messages manager for {}", name);
+
+        ctlr2pool.remove(name);
+    }
+
+    /**
+     * Function that operates on a manager.
+     */
+    @FunctionalInterface
+    private static interface MgrFunc {
+
+        /**
+         * Apply.
+         *
+         * @param mgr manager
+         * @return {@code true} if the request was handled by the manager, {@code false} otherwise
+         * @throws PoolingFeatureException feature exception
+         */
+        boolean apply(PoolingManagerImpl mgr) throws PoolingFeatureException;
+    }
+
+    /*
+     * The remaining methods may be overridden by junit tests.
+     */
+
+    /**
+     * Get properties.
+     *
+     * @param featName feature name
+     * @return the properties for the specified feature
+     */
+    protected Properties getProperties(String featName) {
+        return SystemPersistenceConstants.getManager().getProperties(featName);
+    }
+
+    /**
+     * Makes a pooling manager for a controller.
+     *
+     * @param host name/uuid of this host
+     * @param controller controller
+     * @param props properties to use to configure the manager
+     * @param activeLatch decremented when the manager goes Active
+     * @return a new pooling manager
+     */
+    protected PoolingManagerImpl makeManager(String host, PolicyController controller, PoolingProperties props,
+            CountDownLatch activeLatch) {
+        return new PoolingManagerImpl(host, controller, props, activeLatch);
+    }
+
+    /**
+     * Gets the policy controller associated with a drools controller.
+     *
+     * @param droolsController drools controller
+     * @return the policy controller associated with a drools controller
+     */
+    protected PolicyController getController(DroolsController droolsController) {
+        return PolicyControllerConstants.getFactory().get(droolsController);
+    }
+
+    /**
+     * Initializes the topic sources.
+     *
+     * @param props properties used to configure the topics
+     * @return the topic sources
+     */
+    protected List<TopicSource> initTopicSources(Properties props) {
+        return TopicEndpointManager.getManager().addTopicSources(props);
+    }
+
+    /**
+     * Initializes the topic sinks.
+     *
+     * @param props properties used to configure the topics
+     * @return the topic sinks
+     */
+    protected List<TopicSink> initTopicSinks(Properties props) {
+        return TopicEndpointManager.getManager().addTopicSinks(props);
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingFeatureException.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingFeatureException.java
new file mode 100644
index 0000000..5d7b9f7
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingFeatureException.java
@@ -0,0 +1,54 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import java.io.Serial;
+
+/**
+ * Exception thrown by the pooling feature.
+ */
+public class PoolingFeatureException extends Exception {
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    public PoolingFeatureException() {
+        super();
+    }
+
+    public PoolingFeatureException(String message) {
+        super(message);
+    }
+
+    public PoolingFeatureException(Throwable cause) {
+        super(cause);
+    }
+
+    public PoolingFeatureException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public PoolingFeatureException(String message, Throwable cause, boolean enableSuppression,
+                    boolean writableStackTrace) {
+        super(message, cause, enableSuppression, writableStackTrace);
+    }
+
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingFeatureRtException.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingFeatureRtException.java
new file mode 100644
index 0000000..5d0a275
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingFeatureRtException.java
@@ -0,0 +1,54 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import java.io.Serial;
+
+/**
+ * A runtime exception thrown by the pooling feature.
+ */
+public class PoolingFeatureRtException extends RuntimeException {
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    public PoolingFeatureRtException() {
+        super();
+    }
+
+    public PoolingFeatureRtException(String message) {
+        super(message);
+    }
+
+    public PoolingFeatureRtException(Throwable cause) {
+        super(cause);
+    }
+
+    public PoolingFeatureRtException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public PoolingFeatureRtException(String message, Throwable cause, boolean enableSuppression,
+                    boolean writableStackTrace) {
+        super(message, cause, enableSuppression, writableStackTrace);
+    }
+
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingManager.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingManager.java
new file mode 100644
index 0000000..5e358e6
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingManager.java
@@ -0,0 +1,133 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2020 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import org.onap.policy.drools.pooling.message.BucketAssignments;
+import org.onap.policy.drools.pooling.message.Message;
+import org.onap.policy.drools.pooling.state.State;
+import org.onap.policy.drools.pooling.state.StateTimerTask;
+
+/**
+ * Pooling manager for a single PolicyController.
+ */
+public interface PoolingManager {
+
+    /**
+     * Gets the properties used to configure the manager.
+     *
+     * @return pooling properties
+     */
+    PoolingProperties getProperties();
+
+    /**
+     * Gets the host id.
+     *
+     * @return the host id
+     */
+    String getHost();
+
+    /**
+     * Gets the name of the internal DMaaP topic used by this manager to communicate with
+     * its other hosts.
+     *
+     * @return the name of the internal DMaaP topic
+     */
+    String getTopic();
+
+    /**
+     * Starts distributing requests according to the given bucket assignments.
+     *
+     * @param assignments must <i>not</i> be {@code null}
+     */
+    void startDistributing(BucketAssignments assignments);
+
+    /**
+     * Gets the current bucket assignments.
+     *
+     * @return the current bucket assignments, or {@code null} if no assignments have been
+     *         made
+     */
+    BucketAssignments getAssignments();
+
+    /**
+     * Publishes a message to the internal topic on the administrative channel.
+     *
+     * @param msg message to be published
+     */
+    void publishAdmin(Message msg);
+
+    /**
+     * Publishes a message to the internal topic on a particular channel.
+     *
+     * @param channel channel on which the message should be published
+     * @param msg message to be published
+     */
+    void publish(String channel, Message msg);
+
+    /**
+     * Schedules a timer to fire after a delay.
+     *
+     * @param delayMs delay, in milliseconds
+     * @param task task
+     * @return a new scheduled task
+     */
+    CancellableScheduledTask schedule(long delayMs, StateTimerTask task);
+
+    /**
+     * Schedules a timer to fire repeatedly.
+     *
+     * @param initialDelayMs initial delay, in milliseconds
+     * @param delayMs delay, in milliseconds
+     * @param task task
+     * @return a new scheduled task
+     */
+    CancellableScheduledTask scheduleWithFixedDelay(long initialDelayMs, long delayMs, StateTimerTask task);
+
+    /**
+     * Transitions to the "start" state.
+     *
+     * @return the new state
+     */
+    State goStart();
+
+    /**
+     * Transitions to the "query" state.
+     *
+     * @return the new state
+     */
+    State goQuery();
+
+    /**
+     * Transitions to the "active" state.
+     *
+     * @return the new state
+     */
+    State goActive();
+
+    /**
+     * Transitions to the "inactive" state.
+     *
+     * @return the new state
+     */
+    State goInactive();
+
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingManagerImpl.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingManagerImpl.java
new file mode 100644
index 0000000..7c0436e
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingManagerImpl.java
@@ -0,0 +1,647 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import com.google.gson.JsonParseException;
+import java.lang.reflect.InvocationTargetException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import lombok.Getter;
+import org.onap.policy.common.endpoints.event.comm.Topic.CommInfrastructure;
+import org.onap.policy.common.endpoints.event.comm.TopicListener;
+import org.onap.policy.drools.controller.DroolsController;
+import org.onap.policy.drools.pooling.message.BucketAssignments;
+import org.onap.policy.drools.pooling.message.Leader;
+import org.onap.policy.drools.pooling.message.Message;
+import org.onap.policy.drools.pooling.message.Offline;
+import org.onap.policy.drools.pooling.state.ActiveState;
+import org.onap.policy.drools.pooling.state.IdleState;
+import org.onap.policy.drools.pooling.state.InactiveState;
+import org.onap.policy.drools.pooling.state.QueryState;
+import org.onap.policy.drools.pooling.state.StartState;
+import org.onap.policy.drools.pooling.state.State;
+import org.onap.policy.drools.pooling.state.StateTimerTask;
+import org.onap.policy.drools.protocol.coders.EventProtocolCoderConstants;
+import org.onap.policy.drools.system.PolicyController;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Implementation of a {@link PoolingManager}. Until bucket assignments have been made,
+ * events coming from external topics are saved in a queue for later processing. Once
+ * assignments are made, the saved events are processed. In addition, while the controller
+ * is locked, events are still forwarded to other hosts and bucket assignments are still
+ * updated, based on any {@link Leader} messages that it receives.
+ */
+public class PoolingManagerImpl implements PoolingManager, TopicListener {
+
+    private static final Logger logger = LoggerFactory.getLogger(PoolingManagerImpl.class);
+
+    /**
+     * Maximum number of times a message can be forwarded.
+     */
+    public static final int MAX_HOPS = 5;
+
+    /**
+     * ID of this host.
+     */
+    @Getter
+    private final String host;
+
+    /**
+     * Properties with which this was configured.
+     */
+    @Getter
+    private final PoolingProperties properties;
+
+    /**
+     * Associated controller.
+     */
+    private final PolicyController controller;
+
+    /**
+     * Decremented each time the manager enters the Active state. Used by junit tests.
+     */
+    private final CountDownLatch activeLatch;
+
+    /**
+     * Used to encode & decode request objects received from & sent to a rule engine.
+     */
+    private final Serializer serializer;
+
+    /**
+     * Internal DMaaP topic used by this controller.
+     */
+    @Getter
+    private final String topic;
+
+    /**
+     * Manager for the internal DMaaP topic.
+     */
+    private final TopicMessageManager topicMessageManager;
+
+    /**
+     * Lock used while updating {@link #current}. In general, public methods must use
+     * this, while private methods assume the lock is already held.
+     */
+    private final Object curLocker = new Object();
+
+    /**
+     * Current state.
+     *
+     * <p>This uses a finite state machine, wherein the state object contains all of the data
+     * relevant to that state. Each state object has a process() method, specific to each
+     * type of {@link Message} subclass. The method returns the next state object, or
+     * {@code null} if the state is to remain the same.
+     */
+    private State current;
+
+    /**
+     * Current bucket assignments or {@code null}.
+     */
+    @Getter
+    private BucketAssignments assignments = null;
+
+    /**
+     * Pool used to execute timers.
+     */
+    private ScheduledThreadPoolExecutor scheduler = null;
+
+    /**
+     * Constructs the manager, initializing all the data structures.
+     *
+     * @param host name/uuid of this host
+     * @param controller controller with which this is associated
+     * @param props feature properties specific to the controller
+     * @param activeLatch latch to be decremented each time the manager enters the Active
+     *        state
+     */
+    public PoolingManagerImpl(String host, PolicyController controller, PoolingProperties props,
+                    CountDownLatch activeLatch) {
+        this.host = host;
+        this.controller = controller;
+        this.properties = props;
+        this.activeLatch = activeLatch;
+
+        try {
+            this.serializer = new Serializer();
+            this.topic = props.getPoolingTopic();
+            this.topicMessageManager = makeTopicMessagesManager(props.getPoolingTopic());
+            this.current = new IdleState(this);
+
+            logger.info("allocating host {} to controller {} for topic {}", host, controller.getName(), topic);
+
+        } catch (ClassCastException e) {
+            logger.error("not a topic listener, controller {}", controller.getName());
+            throw new PoolingFeatureRtException(e);
+
+        } catch (PoolingFeatureException e) {
+            logger.error("failed to attach internal DMaaP topic to controller {}", controller.getName());
+            throw new PoolingFeatureRtException(e);
+        }
+    }
+
+    /**
+     * Should only be used by junit tests.
+     *
+     * @return the current state
+     */
+    protected State getCurrent() {
+        synchronized (curLocker) {
+            return current;
+        }
+    }
+
+    /**
+     * Indicates that the controller is about to start. Starts the publisher for the
+     * internal topic, and creates a thread pool for the timers.
+     */
+    public void beforeStart() {
+        synchronized (curLocker) {
+            if (scheduler == null) {
+                topicMessageManager.startPublisher();
+
+                logger.debug("make scheduler thread for topic {}", getTopic());
+                scheduler = makeScheduler();
+
+                /*
+                 * Only a handful of timers at any moment, thus we can afford to take the
+                 * time to remove them when they're cancelled.
+                 */
+                scheduler.setRemoveOnCancelPolicy(true);
+                scheduler.setMaximumPoolSize(1);
+                scheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
+                scheduler.setContinueExistingPeriodicTasksAfterShutdownPolicy(false);
+            }
+        }
+    }
+
+    /**
+     * Indicates that the controller has successfully started. Starts the consumer for the
+     * internal topic, enters the {@link StartState}, and sets the filter for the initial
+     * state.
+     */
+    public void afterStart() {
+        synchronized (curLocker) {
+            if (current instanceof IdleState) {
+                topicMessageManager.startConsumer(this);
+                changeState(new StartState(this));
+            }
+        }
+    }
+
+    /**
+     * Indicates that the controller is about to stop. Stops the consumer, the scheduler,
+     * and the current state.
+     */
+    public void beforeStop() {
+        ScheduledThreadPoolExecutor sched;
+
+        synchronized (curLocker) {
+            sched = scheduler;
+            scheduler = null;
+
+            if (!(current instanceof IdleState)) {
+                changeState(new IdleState(this));
+                topicMessageManager.stopConsumer(this);
+                publishAdmin(new Offline(getHost()));
+            }
+
+            assignments = null;
+        }
+
+        if (sched != null) {
+            logger.debug("stop scheduler for topic {}", getTopic());
+            sched.shutdownNow();
+        }
+    }
+
+    /**
+     * Indicates that the controller has stopped. Stops the publisher and logs a warning
+     * if any events are still in the queue.
+     */
+    public void afterStop() {
+        synchronized (curLocker) {
+            /*
+             * stop the publisher, but allow time for any Offline message to be
+             * transmitted
+             */
+            topicMessageManager.stopPublisher(properties.getOfflinePubWaitMs());
+        }
+    }
+
+    /**
+     * Indicates that the controller is about to be locked. Enters the idle state, as all
+     * it will be doing is forwarding messages.
+     */
+    public void beforeLock() {
+        logger.info("locking manager for topic {}", getTopic());
+
+        synchronized (curLocker) {
+            changeState(new IdleState(this));
+        }
+    }
+
+    /**
+     * Indicates that the controller has been unlocked. Enters the start state, if the
+     * controller is running.
+     */
+    public void afterUnlock() {
+        logger.info("unlocking manager for topic {}", getTopic());
+
+        synchronized (curLocker) {
+            if (controller.isAlive() && current instanceof IdleState && scheduler != null) {
+                changeState(new StartState(this));
+            }
+        }
+    }
+
+    /**
+     * Changes the finite state machine to a new state, provided the new state is not
+     * {@code null}.
+     *
+     * @param newState new state, or {@code null} if to remain unchanged
+     */
+    private void changeState(State newState) {
+        if (newState != null) {
+            current.cancelTimers();
+            current = newState;
+
+            newState.start();
+        }
+    }
+
+    @Override
+    public CancellableScheduledTask schedule(long delayMs, StateTimerTask task) {
+        // wrap the task in a TimerAction and schedule it
+        ScheduledFuture<?> fut = scheduler.schedule(new TimerAction(task), delayMs, TimeUnit.MILLISECONDS);
+
+        // wrap the future in a "CancellableScheduledTask"
+        return () -> fut.cancel(false);
+    }
+
+    @Override
+    public CancellableScheduledTask scheduleWithFixedDelay(long initialDelayMs, long delayMs, StateTimerTask task) {
+        // wrap the task in a TimerAction and schedule it
+        ScheduledFuture<?> fut = scheduler.scheduleWithFixedDelay(new TimerAction(task), initialDelayMs, delayMs,
+                        TimeUnit.MILLISECONDS);
+
+        // wrap the future in a "CancellableScheduledTask"
+        return () -> fut.cancel(false);
+    }
+
+    @Override
+    public void publishAdmin(Message msg) {
+        publish(Message.ADMIN, msg);
+    }
+
+    @Override
+    public void publish(String channel, Message msg) {
+        logger.info("publish {} to {} on topic {}", msg.getClass().getSimpleName(), channel, getTopic());
+
+        msg.setChannel(channel);
+
+        try {
+            // ensure it's valid before we send it
+            msg.checkValidity();
+
+            String txt = serializer.encodeMsg(msg);
+            topicMessageManager.publish(txt);
+
+        } catch (JsonParseException e) {
+            logger.error("failed to serialize message for topic {} channel {}", topic, channel, e);
+
+        } catch (PoolingFeatureException e) {
+            logger.error("failed to publish message for topic {} channel {}", topic, channel, e);
+        }
+    }
+
+    /**
+     * Handles an event from the internal topic.
+     *
+     * @param commType comm infrastructure
+     * @param topic2 topic
+     * @param event event
+     */
+    @Override
+    public void onTopicEvent(CommInfrastructure commType, String topic2, String event) {
+
+        if (event == null) {
+            logger.error("null event on topic {}", topic);
+            return;
+        }
+
+        synchronized (curLocker) {
+            // it's on the internal topic
+            handleInternal(event);
+        }
+    }
+
+    /**
+     * Called by the PolicyController before it offers the event to the DroolsController.
+     * If the controller is locked, then it isn't processing events. However, they still
+     * need to be forwarded, thus in that case, they are decoded and forwarded.
+     *
+     * <p>On the other hand, if the controller is not locked, then we just return immediately
+     * and let {@link #beforeInsert(String, Object)  beforeInsert()} handle
+     * it instead, as it already has the decoded message.
+     *
+     * @param topic2 topic
+     * @param event event
+     * @return {@code true} if the event was handled by the manager, {@code false} if it
+     *         must still be handled by the invoker
+     */
+    public boolean beforeOffer(String topic2, String event) {
+
+        if (!controller.isLocked()) {
+            // we should NOT intercept this message - let the invoker handle it
+            return false;
+        }
+
+        return handleExternal(topic2, decodeEvent(topic2, event));
+    }
+
+    /**
+     * Called by the DroolsController before it inserts the event into the rule engine.
+     *
+     * @param topic2 topic
+     * @param event event, as an object
+     * @return {@code true} if the event was handled by the manager, {@code false} if it
+     *         must still be handled by the invoker
+     */
+    public boolean beforeInsert(String topic2, Object event) {
+        return handleExternal(topic2, event);
+    }
+
+    /**
+     * Handles an event from an external topic.
+     *
+     * @param topic2 topic
+     * @param event event, as an object, or {@code null} if it cannot be decoded
+     * @return {@code true} if the event was handled by the manager, {@code false} if it
+     *         must still be handled by the invoker
+     */
+    private boolean handleExternal(String topic2, Object event) {
+        if (event == null) {
+            // no event - let the invoker handle it
+            return false;
+        }
+
+        synchronized (curLocker) {
+            return handleExternal(topic2, event, event.hashCode());
+        }
+    }
+
+    /**
+     * Handles an event from an external topic.
+     *
+     * @param topic2 topic
+     * @param event event, as an object
+     * @param eventHashCode event's hash code
+     * @return {@code true} if the event was handled, {@code false} if the invoker should
+     *         handle it
+     */
+    private boolean handleExternal(String topic2, Object event, int eventHashCode) {
+        if (assignments == null) {
+            // no bucket assignments yet - handle locally
+            logger.info("handle event locally for request {}", event);
+
+            // we did NOT consume the event
+            return false;
+
+        } else {
+            return handleEvent(topic2, event, eventHashCode);
+        }
+    }
+
+    /**
+     * Handles a {@link Forward} event, possibly forwarding it again.
+     *
+     * @param topic2 topic
+     * @param event event, as an object
+     * @param eventHashCode event's hash code
+     * @return {@code true} if the event was handled, {@code false} if the invoker should
+     *         handle it
+     */
+    private boolean handleEvent(String topic2, Object event, int eventHashCode) {
+        String target = assignments.getAssignedHost(eventHashCode);
+
+        if (target == null) {
+            /*
+             * This bucket has no assignment - just discard the event
+             */
+            logger.warn("discarded event for unassigned bucket from topic {}", topic2);
+            return true;
+        }
+
+        if (target.equals(host)) {
+            /*
+             * Message belongs to this host - allow the controller to handle it.
+             */
+            logger.info("handle local event for request {} from topic {}", event, topic2);
+            return false;
+        }
+
+        // not our message, consume the event
+        logger.warn("discarded event for host {} from topic {}", target, topic2);
+        return true;
+    }
+
+    /**
+     * Decodes an event from a String into an event Object.
+     *
+     * @param topic2 topic
+     * @param event event
+     * @return the decoded event object, or {@code null} if it can't be decoded
+     */
+    private Object decodeEvent(String topic2, String event) {
+        DroolsController drools = controller.getDrools();
+
+        // check if this topic has a decoder
+
+        if (!canDecodeEvent(drools, topic2)) {
+
+            logger.warn("{}: DECODING-UNSUPPORTED {}:{}:{}", drools, topic2, drools.getGroupId(),
+                            drools.getArtifactId());
+            return null;
+        }
+
+        // decode
+
+        try {
+            return decodeEventWrapper(drools, topic2, event);
+
+        } catch (UnsupportedOperationException | IllegalStateException | IllegalArgumentException e) {
+            logger.debug("{}: DECODE FAILED: {} <- {} because of {}", drools, topic2, event, e.getMessage(), e);
+            return null;
+        }
+    }
+
+    /**
+     * Handles an event from the internal topic. This uses reflection to identify the
+     * appropriate process() method to invoke, based on the type of Message that was
+     * decoded.
+     *
+     * @param event the serialized {@link Message} read from the internal topic
+     */
+    private void handleInternal(String event) {
+        Class<?> clazz = null;
+
+        try {
+            Message msg = serializer.decodeMsg(event);
+
+            // get the class BEFORE checking the validity
+            clazz = msg.getClass();
+
+            msg.checkValidity();
+
+            var meth = current.getClass().getMethod("process", msg.getClass());
+            changeState((State) meth.invoke(current, msg));
+
+        } catch (JsonParseException e) {
+            logger.warn("failed to decode message for topic {}", topic, e);
+
+        } catch (NoSuchMethodException | SecurityException e) {
+            logger.error("no processor for message {} for topic {}", clazz, topic, e);
+
+        } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException
+                        | PoolingFeatureException e) {
+            logger.error("failed to process message {} for topic {}", clazz, topic, e);
+        }
+    }
+
+    @Override
+    public void startDistributing(BucketAssignments asgn) {
+        synchronized (curLocker) {
+            int sz = (asgn == null ? 0 : asgn.getAllHosts().size());
+            logger.info("new assignments for {} hosts on topic {}", sz, getTopic());
+            assignments = asgn;
+        }
+    }
+
+    @Override
+    public State goStart() {
+        return new StartState(this);
+    }
+
+    @Override
+    public State goQuery() {
+        return new QueryState(this);
+    }
+
+    @Override
+    public State goActive() {
+        activeLatch.countDown();
+        return new ActiveState(this);
+    }
+
+    @Override
+    public State goInactive() {
+        return new InactiveState(this);
+    }
+
+    /**
+     * Action to run a timer task. Only runs the task if the machine is still in the state
+     * that it was in when the timer was created.
+     */
+    private class TimerAction implements Runnable {
+
+        /**
+         * State of the machine when the timer was created.
+         */
+        private State origState;
+
+        /**
+         * Task to be executed.
+         */
+        private StateTimerTask task;
+
+        /**
+         * Constructor.
+         *
+         * @param task task to execute when this timer runs
+         */
+        public TimerAction(StateTimerTask task) {
+            this.origState = current;
+            this.task = task;
+        }
+
+        @Override
+        public void run() {
+            synchronized (curLocker) {
+                if (current == origState) {
+                    changeState(task.fire());
+                }
+            }
+        }
+    }
+
+    /**
+     * Creates a DMaaP manager.
+     *
+     * @param topic name of the internal DMaaP topic
+     * @return a new topic messages manager
+     * @throws PoolingFeatureException if an error occurs
+     */
+    protected TopicMessageManager makeTopicMessagesManager(String topic) throws PoolingFeatureException {
+        return new TopicMessageManager(topic);
+    }
+
+    /**
+     * Creates a scheduled thread pool.
+     *
+     * @return a new scheduled thread pool
+     */
+    protected ScheduledThreadPoolExecutor makeScheduler() {
+        return new ScheduledThreadPoolExecutor(1);
+    }
+
+    /**
+     * Determines if the event can be decoded.
+     *
+     * @param drools drools controller
+     * @param topic topic on which the event was received
+     * @return {@code true} if the event can be decoded, {@code false} otherwise
+     */
+    protected boolean canDecodeEvent(DroolsController drools, String topic) {
+        return EventProtocolCoderConstants.getManager().isDecodingSupported(drools.getGroupId(), drools.getArtifactId(),
+                        topic);
+    }
+
+    /**
+     * Decodes the event.
+     *
+     * @param drools drools controller
+     * @param topic topic on which the event was received
+     * @param event event text to be decoded
+     * @return the decoded event
+     * @throws IllegalArgumentException illegal argument
+     * @throws UnsupportedOperationException unsupported operation
+     * @throws IllegalStateException illegal state
+     */
+    protected Object decodeEventWrapper(DroolsController drools, String topic, String event) {
+        return EventProtocolCoderConstants.getManager().decode(drools.getGroupId(), drools.getArtifactId(), topic,
+                        event);
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingProperties.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingProperties.java
new file mode 100644
index 0000000..fd1c3d3
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/PoolingProperties.java
@@ -0,0 +1,150 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import java.util.Properties;
+import lombok.Getter;
+import lombok.Setter;
+import org.onap.policy.common.utils.properties.BeanConfigurator;
+import org.onap.policy.common.utils.properties.Property;
+import org.onap.policy.common.utils.properties.SpecProperties;
+import org.onap.policy.common.utils.properties.exception.PropertyException;
+
+/**
+ * Properties used by the pooling feature, specific to a controller.
+ */
+@Getter
+@Setter
+public class PoolingProperties {
+
+    /**
+     * The feature name, used to retrieve properties.
+     */
+    public static final String FEATURE_NAME = "feature-pooling-messages";
+
+    /**
+     * Feature properties all begin with this prefix.
+     */
+    public static final String PREFIX = "pooling.";
+
+    public static final String FEATURE_ENABLED = PREFIX + "enabled";
+    public static final String POOLING_TOPIC = PREFIX + "topic";
+    public static final String OFFLINE_LIMIT = PREFIX + "offline.queue.limit";
+    public static final String OFFLINE_AGE_MS = PREFIX + "offline.queue.age.milliseconds";
+    public static final String OFFLINE_PUB_WAIT_MS = PREFIX + "offline.publish.wait.milliseconds";
+    public static final String START_HEARTBEAT_MS = PREFIX + "start.heartbeat.milliseconds";
+    public static final String REACTIVATE_MS = PREFIX + "reactivate.milliseconds";
+    public static final String IDENTIFICATION_MS = PREFIX + "identification.milliseconds";
+    public static final String ACTIVE_HEARTBEAT_MS = PREFIX + "active.heartbeat.milliseconds";
+    public static final String INTER_HEARTBEAT_MS = PREFIX + "inter.heartbeat.milliseconds";
+
+    /**
+     * Type of item that the extractors will be extracting.
+     */
+    public static final String EXTRACTOR_TYPE = "requestId";
+
+    /**
+     * Prefix for extractor properties.
+     */
+    public static final String PROP_EXTRACTOR_PREFIX = "extractor." + EXTRACTOR_TYPE;
+
+    /**
+     * Properties from which this was constructed.
+     */
+    private Properties source;
+
+    /**
+     * Topic used for inter-host communication.
+     */
+    @Property(name = POOLING_TOPIC)
+    private String poolingTopic;
+
+    /**
+     * Maximum number of events to retain in the queue while waiting for
+     * buckets to be assigned.
+     */
+    @Property(name = OFFLINE_LIMIT, defaultValue = "1000")
+    private int offlineLimit;
+
+    /**
+     * Maximum age, in milliseconds, of events to be retained in the queue.
+     * Events older than this are discarded.
+     */
+    @Property(name = OFFLINE_AGE_MS, defaultValue = "60000")
+    private long offlineAgeMs;
+
+    /**
+     * Time, in milliseconds, to wait for an "Offline" message to be published
+     * to topic.
+     */
+    @Property(name = OFFLINE_PUB_WAIT_MS, defaultValue = "3000")
+    private long offlinePubWaitMs;
+
+    /**
+     * Time, in milliseconds, to wait for this host's heart beat during the
+     * start-up state.
+     */
+    @Property(name = START_HEARTBEAT_MS, defaultValue = "100000")
+    private long startHeartbeatMs;
+
+    /**
+     * Time, in milliseconds, to wait before attempting to reactivate this
+     * host when it has no bucket assignments.
+     */
+    @Property(name = REACTIVATE_MS, defaultValue = "50000")
+    private long reactivateMs;
+
+    /**
+     * Time, in milliseconds, to wait for all Identification messages to
+     * arrive during the query state.
+     */
+    @Property(name = IDENTIFICATION_MS, defaultValue = "50000")
+    private long identificationMs;
+
+    /**
+     * Time, in milliseconds, to wait for heart beats from this host, or its
+     * predecessor, during the active state.
+     */
+    @Property(name = ACTIVE_HEARTBEAT_MS, defaultValue = "50000")
+    private long activeHeartbeatMs;
+
+    /**
+     * Time, in milliseconds, to wait between heart beat generations during
+     * the active and start-up states.
+     */
+    @Property(name = INTER_HEARTBEAT_MS, defaultValue = "15000")
+    private long interHeartbeatMs;
+
+    /**
+     * Constructor.
+     *
+     * @param controllerName the name of the controller
+     * @param props set of properties used to configure this
+     * @throws PropertyException if an error occurs
+     *
+     */
+    public PoolingProperties(String controllerName, Properties props) throws PropertyException {
+        source = props;
+
+        new BeanConfigurator().configureFromProperties(this, new SpecProperties(PREFIX, controllerName, props));
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/Serializer.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/Serializer.java
new file mode 100644
index 0000000..2894b1d
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/Serializer.java
@@ -0,0 +1,124 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+import java.util.HashMap;
+import java.util.Map;
+import org.onap.policy.drools.pooling.message.Heartbeat;
+import org.onap.policy.drools.pooling.message.Identification;
+import org.onap.policy.drools.pooling.message.Leader;
+import org.onap.policy.drools.pooling.message.Message;
+import org.onap.policy.drools.pooling.message.Offline;
+import org.onap.policy.drools.pooling.message.Query;
+
+/**
+ * Serialization helper functions.
+ */
+public class Serializer {
+
+    /**
+     * The message type is stored in fields of this name within the JSON.
+     */
+    private static final String TYPE_FIELD = "type";
+
+    /**
+     * Used to encode & decode JSON messages sent & received, respectively, on the
+     * internal topic.
+     */
+    private final Gson gson = new Gson();
+
+    /**
+     * Maps a message subclass to its type.
+     */
+    private static final Map<Class<? extends Message>, String> class2type = new HashMap<>();
+
+    /**
+     * Maps a message type to the appropriate subclass.
+     */
+    private static final Map<String, Class<? extends Message>> type2class = new HashMap<>();
+
+    static {
+        class2type.put(Heartbeat.class, "heartbeat");
+        class2type.put(Identification.class, "identification");
+        class2type.put(Leader.class, "leader");
+        class2type.put(Offline.class, "offline");
+        class2type.put(Query.class, "query");
+
+        class2type.forEach((clazz, type) -> type2class.put(type, clazz));
+    }
+
+    /**
+     * Encodes a filter.
+     *
+     * @param filter filter to be encoded
+     * @return the filter, serialized as a JSON string
+     */
+    public String encodeFilter(Map<String, Object> filter) {
+        return gson.toJson(filter);
+    }
+
+    /**
+     * Encodes a message.
+     *
+     * @param msg message to be encoded
+     * @return the message, serialized as a JSON string
+     */
+    public String encodeMsg(Message msg) {
+        JsonElement jsonEl = gson.toJsonTree(msg);
+
+        String type = class2type.get(msg.getClass());
+        if (type == null) {
+            throw new JsonParseException("cannot serialize " + msg.getClass());
+        }
+
+        jsonEl.getAsJsonObject().addProperty(TYPE_FIELD, type);
+
+        return gson.toJson(jsonEl);
+    }
+
+    /**
+     * Decodes a JSON string into a Message.
+     *
+     * @param msg JSON string representing the message
+     * @return the message
+     */
+    public Message decodeMsg(String msg) {
+        JsonElement jsonEl = gson.fromJson(msg, JsonElement.class);
+
+        JsonElement typeEl = jsonEl.getAsJsonObject().get(TYPE_FIELD);
+        if (typeEl == null) {
+            throw new JsonParseException("cannot deserialize " + Message.class
+                            + " because it does not contain a field named " + TYPE_FIELD);
+
+        }
+
+        Class<? extends Message> clazz = type2class.get(typeEl.getAsString());
+        if (clazz == null) {
+            throw new JsonParseException("cannot deserialize " + typeEl);
+        }
+
+        return gson.fromJson(jsonEl, clazz);
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/TopicMessageManager.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/TopicMessageManager.java
new file mode 100644
index 0000000..4c9b3f3
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/TopicMessageManager.java
@@ -0,0 +1,233 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import java.util.List;
+import lombok.Getter;
+import org.onap.policy.common.endpoints.event.comm.TopicEndpoint;
+import org.onap.policy.common.endpoints.event.comm.TopicEndpointManager;
+import org.onap.policy.common.endpoints.event.comm.TopicListener;
+import org.onap.policy.common.endpoints.event.comm.TopicSink;
+import org.onap.policy.common.endpoints.event.comm.TopicSource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Manages the internal topic. Assumes all topics are managed by
+ * {@link TopicEndpoint}.
+ */
+public class TopicMessageManager {
+
+    private static final Logger logger = LoggerFactory.getLogger(TopicMessageManager.class);
+
+    /**
+     * Name of the topic.
+     */
+    @Getter
+    private final String topic;
+
+    /**
+     * Topic source whose filter is to be manipulated.
+     */
+    private final TopicSource topicSource;
+
+    /**
+     * Where to publish messages.
+     */
+    private final TopicSink topicSink;
+
+    /**
+     * {@code True} if the consumer is running, {@code false} otherwise.
+     */
+    private boolean consuming = false;
+
+    /**
+     * {@code True} if the publisher is running, {@code false} otherwise.
+     */
+    private boolean publishing = false;
+
+    /**
+     * Constructs the manager, but does not start the source or sink.
+     *
+     * @param topic name of the internal topic
+     * @throws PoolingFeatureException if an error occurs
+     */
+    public TopicMessageManager(String topic) throws PoolingFeatureException {
+
+        logger.info("initializing bus for topic {}", topic);
+
+        try {
+            this.topic = topic;
+
+            this.topicSource = findTopicSource();
+            this.topicSink = findTopicSink();
+
+        } catch (IllegalArgumentException e) {
+            logger.error("failed to attach to topic {}", topic);
+            throw new PoolingFeatureException(e);
+        }
+    }
+
+    /**
+     * Finds the topic source associated with the internal topic.
+     *
+     * @return the topic source
+     * @throws PoolingFeatureException if the source doesn't exist or is not filterable
+     */
+    private TopicSource findTopicSource() throws PoolingFeatureException {
+        for (TopicSource src : getTopicSources()) {
+            if (topic.equals(src.getTopic())) {
+                return src;
+            }
+        }
+
+        throw new PoolingFeatureException("missing topic source " + topic);
+    }
+
+    /**
+     * Finds the topic sink associated with the internal topic.
+     *
+     * @return the topic sink
+     * @throws PoolingFeatureException if the sink doesn't exist
+     */
+    private TopicSink findTopicSink() throws PoolingFeatureException {
+        for (TopicSink sink : getTopicSinks()) {
+            if (topic.equals(sink.getTopic())) {
+                return sink;
+            }
+        }
+
+        throw new PoolingFeatureException("missing topic sink " + topic);
+    }
+
+    /**
+     * Starts the publisher, if it isn't already running.
+     */
+    public void startPublisher() {
+        if (publishing) {
+            return;
+        }
+
+        logger.info("start publishing to topic {}", topic);
+        publishing = true;
+    }
+
+    /**
+     * Stops the publisher.
+     *
+     * @param waitMs time, in milliseconds, to wait for the sink to transmit any queued messages and
+     *        close
+     */
+    public void stopPublisher(long waitMs) {
+        if (!publishing) {
+            return;
+        }
+
+        /*
+         * Give the sink a chance to transmit messages in the queue. It would be better if "waitMs"
+         * could be passed to sink.stop(), but that isn't an option at this time.
+         */
+        try {
+            Thread.sleep(waitMs);
+
+        } catch (InterruptedException e) {
+            logger.warn("message transmission stopped due to {}", e.getMessage());
+            Thread.currentThread().interrupt();
+        }
+
+        logger.info("stop publishing to topic {}", topic);
+        publishing = false;
+    }
+
+    /**
+     * Starts the consumer, if it isn't already running.
+     *
+     * @param listener listener to register with the source
+     */
+    public void startConsumer(TopicListener listener) {
+        if (consuming) {
+            return;
+        }
+
+        logger.info("start consuming from topic {}", topic);
+        topicSource.register(listener);
+        consuming = true;
+    }
+
+    /**
+     * Stops the consumer.
+     *
+     * @param listener listener to unregister with the source
+     */
+    public void stopConsumer(TopicListener listener) {
+        if (!consuming) {
+            return;
+        }
+
+        logger.info("stop consuming from topic {}", topic);
+        consuming = false;
+        topicSource.unregister(listener);
+    }
+
+    /**
+     * Publishes a message to the sink.
+     *
+     * @param msg message to be published
+     * @throws PoolingFeatureException if an error occurs or the publisher isn't running
+     */
+    public void publish(String msg) throws PoolingFeatureException {
+        if (!publishing) {
+            throw new PoolingFeatureException(new IllegalStateException("no topic sink " + topic));
+        }
+
+        try {
+            if (!topicSink.send(msg)) {
+                throw new PoolingFeatureException("failed to send to topic sink " + topic);
+            }
+
+        } catch (IllegalStateException e) {
+            throw new PoolingFeatureException("cannot send to topic sink " + topic, e);
+        }
+    }
+
+    /*
+     * The remaining methods may be overridden by junit tests.
+     */
+
+    /**
+     * Get topic source.
+     *
+     * @return the topic sources
+     */
+    protected List<TopicSource> getTopicSources() {
+        return TopicEndpointManager.getManager().getTopicSources();
+    }
+
+    /**
+     * Get topic sinks.
+     *
+     * @return the topic sinks
+     */
+    protected List<TopicSink> getTopicSinks() {
+        return TopicEndpointManager.getManager().getTopicSinks();
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/BucketAssignments.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/BucketAssignments.java
new file mode 100644
index 0000000..485a9d2
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/BucketAssignments.java
@@ -0,0 +1,205 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.onap.policy.drools.pooling.PoolingFeatureException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Bucket assignments, which is simply an array of host names.
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+public class BucketAssignments {
+
+    private static final Logger logger = LoggerFactory.getLogger(BucketAssignments.class);
+
+    /**
+     * The number of bits in the maximum number of buckets.
+     */
+    private static final int MAX_BUCKET_BITS = 10;
+
+    /**
+     * Maximum number of buckets. Must be a power of two.
+     */
+    public static final int MAX_BUCKETS = 1 << MAX_BUCKET_BITS;
+
+    /**
+     * Used to ensure that a hash code is not negative.
+     */
+    private static final int MAX_BUCKETS_MASK = MAX_BUCKETS - 1;
+
+    /**
+     * Identifies the host serving a particular bucket.
+     */
+    private String[] hostArray = null;
+
+
+    /**
+     * Constructor.
+     *
+     * @param hostArray maps a bucket number (i.e., array index) to a host. All values
+     *        must be non-null
+     */
+    public BucketAssignments(String[] hostArray) {
+        this.hostArray = hostArray;
+    }
+
+    /**
+     * Gets the leader, which is the host with the minimum UUID.
+     *
+     * @return the assignment leader
+     */
+    public String getLeader() {
+        if (hostArray == null) {
+            return null;
+        }
+
+        String leader = null;
+
+        for (String host : hostArray) {
+            if (host != null && (leader == null || host.compareTo(leader) < 0)) {
+                leader = host;
+            }
+        }
+
+        return leader;
+
+    }
+
+    /**
+     * Determines if a host has an assignment.
+     *
+     * @param host host to be checked
+     * @return {@code true} if the host has an assignment, {@code false} otherwise
+     */
+    public boolean hasAssignment(String host) {
+        if (hostArray == null) {
+            return false;
+        }
+
+        for (String host2 : hostArray) {
+            if (host.equals(host2)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Gets all the hosts that have an assignment.
+     *
+     * @return all the hosts that have an assignment
+     */
+    public Set<String> getAllHosts() {
+        Set<String> set = new HashSet<>();
+        if (hostArray == null) {
+            return set;
+        }
+
+        for (String host : hostArray) {
+            if (host != null) {
+                set.add(host);
+            }
+        }
+
+        return set;
+    }
+
+    /**
+     * Gets the host assigned to a given bucket.
+     *
+     * @param hashCode hash code of the item whose assignment is desired
+     * @return the assigned host, or {@code null} if the item has no assigned host
+     */
+    public String getAssignedHost(int hashCode) {
+        if (hostArray == null || hostArray.length == 0) {
+            logger.error("no buckets have been assigned");
+            return null;
+        }
+
+        return hostArray[(Math.abs(hashCode) & MAX_BUCKETS_MASK) % hostArray.length];
+    }
+
+    /**
+     * Gets the number of buckets.
+     *
+     * @return the number of buckets
+     */
+    public int size() {
+        return (hostArray != null ? hostArray.length : 0);
+    }
+
+    /**
+     * Checks the validity of the assignments, verifying that all buckets have been
+     * assigned to a host.
+     *
+     * @throws PoolingFeatureException if the assignments are invalid
+     */
+    public void checkValidity() throws PoolingFeatureException {
+        if (hostArray == null || hostArray.length == 0) {
+            throw new PoolingFeatureException("missing hosts in message bucket assignments");
+        }
+
+        if (hostArray.length > MAX_BUCKETS) {
+            throw new PoolingFeatureException("too many hosts in message bucket assignments");
+        }
+
+        for (var x = 0; x < hostArray.length; ++x) {
+            if (hostArray[x] == null) {
+                throw new PoolingFeatureException("bucket " + x + " has no assignment");
+            }
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        final var prime = 31;
+        var result = 1;
+        result = prime * result + Arrays.hashCode(hostArray);
+        return result;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        BucketAssignments other = (BucketAssignments) obj;
+        return Arrays.equals(hostArray, other.hostArray);
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Heartbeat.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Heartbeat.java
new file mode 100644
index 0000000..4a8bdc3
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Heartbeat.java
@@ -0,0 +1,52 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * Heart beat message sent to self, or to the succeeding host.
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+public class Heartbeat extends Message {
+
+    /**
+     * Time, in milliseconds, when this was created.
+     */
+    private long timestampMs;
+
+    /**
+     * Constructor.
+     *
+     * @param source host on which the message originated
+     * @param timestampMs time, in milliseconds, associated with the message
+     */
+    public Heartbeat(String source, long timestampMs) {
+        super(source);
+
+        this.timestampMs = timestampMs;
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Identification.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Identification.java
new file mode 100644
index 0000000..b8fd841
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Identification.java
@@ -0,0 +1,41 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+import lombok.NoArgsConstructor;
+
+/**
+ * Identifies the source host and the bucket assignments which it knows about.
+ */
+@NoArgsConstructor
+public class Identification extends MessageWithAssignments {
+
+    /**
+     * Constructor.
+     *
+     * @param source host on which the message originated
+     * @param assignments assignments
+     */
+    public Identification(String source, BucketAssignments assignments) {
+        super(source, assignments);
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Leader.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Leader.java
new file mode 100644
index 0000000..10c3338
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Leader.java
@@ -0,0 +1,70 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2019, 2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+import lombok.NoArgsConstructor;
+import org.onap.policy.drools.pooling.PoolingFeatureException;
+
+/**
+ * Indicates that the "source" of this message is now the "lead" host.
+ */
+@NoArgsConstructor
+public class Leader extends MessageWithAssignments {
+
+    /**
+     * Constructor.
+     *
+     * @param source host on which the message originated
+     * @param assignments assignments
+     */
+    public Leader(String source, BucketAssignments assignments) {
+        super(source, assignments);
+    }
+
+    /**
+     * Also verifies that buckets have been assigned and that the source is
+     * indeed the leader.
+     */
+    @Override
+    public void checkValidity() throws PoolingFeatureException {
+
+        super.checkValidity();
+
+        BucketAssignments assignments = getAssignments();
+        if (assignments == null) {
+            throw new PoolingFeatureException("missing message bucket assignments");
+        }
+
+        String leader = getSource();
+
+        if (!assignments.hasAssignment(leader)) {
+            throw new PoolingFeatureException("leader " + leader + " has no bucket assignments");
+        }
+
+        for (String host : assignments.getHostArray()) {
+            if (host.compareTo(leader) < 0) {
+                throw new PoolingFeatureException("invalid leader " + leader + ", should be " + host);
+            }
+        }
+    }
+
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Message.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Message.java
new file mode 100644
index 0000000..bb97314
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Message.java
@@ -0,0 +1,79 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2019, 2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.onap.policy.drools.pooling.PoolingFeatureException;
+
+/**
+ * Messages sent on the internal topic.
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+public class Message {
+
+    /**
+     * Name of the administrative channel.
+     */
+    public static final String ADMIN = "_admin";
+
+    /**
+     * Host that originated the message.
+     */
+    private String source;
+
+    /**
+     * Channel on which the message is routed, which is either the target host
+     * or {@link #ADMIN}.
+     */
+    private String channel;
+
+
+    /**
+     * Constructor.
+     *
+     * @param source host on which the message originated
+     */
+    public Message(String source) {
+        this.source = source;
+    }
+
+    /**
+     * Checks the validity of the message, including verifying that required
+     * fields are not missing.
+     *
+     * @throws PoolingFeatureException if the message is invalid
+     */
+    public void checkValidity() throws PoolingFeatureException {
+        if (source == null || source.isEmpty()) {
+            throw new PoolingFeatureException("missing message source");
+        }
+
+        if (channel == null || channel.isEmpty()) {
+            throw new PoolingFeatureException("missing message channel");
+        }
+    }
+
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/MessageWithAssignments.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/MessageWithAssignments.java
new file mode 100644
index 0000000..b8521dd
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/MessageWithAssignments.java
@@ -0,0 +1,68 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2019, 2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.onap.policy.drools.pooling.PoolingFeatureException;
+
+/**
+ * A Message that includes bucket assignments.
+ */
+@Setter
+@Getter
+@NoArgsConstructor
+public class MessageWithAssignments extends Message {
+
+    /**
+     * Bucket assignments, as known by the source host.
+     */
+    private BucketAssignments assignments;
+
+
+    /**
+     * Constructor.
+     *
+     * @param source host on which the message originated
+     * @param assignments assignments
+     */
+    public MessageWithAssignments(String source, BucketAssignments assignments) {
+        super(source);
+
+        this.assignments = assignments;
+    }
+
+    /**
+     * If there are any assignments, it verifies there validity.
+     */
+    @Override
+    public void checkValidity() throws PoolingFeatureException {
+
+        super.checkValidity();
+
+        if (assignments != null) {
+            assignments.checkValidity();
+        }
+    }
+
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Offline.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Offline.java
new file mode 100644
index 0000000..3ff7bf1
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Offline.java
@@ -0,0 +1,41 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+import lombok.NoArgsConstructor;
+
+/**
+ * Indicates that the source host is going offline and will be unable to process
+ * any further requests.
+ */
+@NoArgsConstructor
+public class Offline extends Message {
+
+    /**
+     * Constructor.
+     *
+     * @param source host on which the message originated
+     */
+    public Offline(String source) {
+        super(source);
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Query.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Query.java
new file mode 100644
index 0000000..4c856bb
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/message/Query.java
@@ -0,0 +1,40 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+import lombok.NoArgsConstructor;
+
+/**
+ * Query the other hosts for their identification.
+ */
+@NoArgsConstructor
+public class Query extends Message {
+
+    /**
+     * Constructor.
+     *
+     * @param source host on which the message originated
+     */
+    public Query(String source) {
+        super(source);
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/ActiveState.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/ActiveState.java
new file mode 100644
index 0000000..cafcb45
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/ActiveState.java
@@ -0,0 +1,270 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2020-2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+import java.util.Arrays;
+import java.util.TreeSet;
+import lombok.AccessLevel;
+import lombok.Getter;
+import org.onap.policy.drools.pooling.PoolingManager;
+import org.onap.policy.drools.pooling.message.Heartbeat;
+import org.onap.policy.drools.pooling.message.Leader;
+import org.onap.policy.drools.pooling.message.Offline;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The active state. In this state, this host has one more bucket assignments and
+ * processes any events associated with one of its buckets. Other events are forwarded to
+ * appropriate target hosts.
+ */
+@Getter(AccessLevel.PROTECTED)
+public class ActiveState extends ProcessingState {
+
+    private static final Logger logger = LoggerFactory.getLogger(ActiveState.class);
+
+    /**
+     * Set of hosts that have been assigned a bucket.
+     */
+    @Getter(AccessLevel.NONE)
+    private final TreeSet<String> assigned = new TreeSet<>();
+
+    /**
+     * Host that comes after this host, or {@code null} if it has no successor.
+     */
+    private String succHost = null;
+
+    /**
+     * Host that comes before this host, or "" if it has no predecessor.
+     */
+    private String predHost = "";
+
+    /**
+     * {@code True} if we saw this host's heart beat since the last check, {@code false}
+     * otherwise.
+     */
+    private boolean myHeartbeatSeen = false;
+
+    /**
+     * {@code True} if we saw the predecessor's heart beat since the last check,
+     * {@code false} otherwise.
+     */
+    private boolean predHeartbeatSeen = false;
+
+
+    /**
+     * Constructor.
+     *
+     * @param mgr pooling manager
+     */
+    public ActiveState(PoolingManager mgr) {
+        super(mgr, mgr.getAssignments().getLeader());
+
+        assigned.addAll(Arrays.asList(mgr.getAssignments().getHostArray()));
+
+        detmNeighbors();
+    }
+
+    /**
+     * Determine this host's neighbors based on the order of the host UUIDs. Updates
+     * {@link #succHost} and {@link #predHost}.
+     */
+    private void detmNeighbors() {
+        if (assigned.size() < 2) {
+            logger.info("this host has no neighbors on topic {}", getTopic());
+            /*
+             * this host is the only one with any assignments - it has no neighbors
+             */
+            succHost = null;
+            predHost = "";
+            return;
+        }
+
+        if ((succHost = assigned.higher(getHost())) == null) {
+            // wrapped around - successor is the first host in the set
+            succHost = assigned.first();
+        }
+        logger.info("this host's successor is {} on topic {}", succHost, getTopic());
+
+        if ((predHost = assigned.lower(getHost())) == null) {
+            // wrapped around - predecessor is the last host in the set
+            predHost = assigned.last();
+        }
+        logger.info("this host's predecessor is {} on topic {}", predHost, getTopic());
+    }
+
+    @Override
+    public void start() {
+        super.start();
+        addTimers();
+        genHeartbeat();
+    }
+
+    /**
+     * Adds the timers.
+     */
+    private void addTimers() {
+        logger.info("add timers");
+
+        /*
+         * heart beat generator
+         */
+        long genMs = getProperties().getInterHeartbeatMs();
+
+        scheduleWithFixedDelay(genMs, genMs, () -> {
+            genHeartbeat();
+            return null;
+        });
+
+        /*
+         * my heart beat checker
+         */
+        long waitMs = getProperties().getActiveHeartbeatMs();
+
+        scheduleWithFixedDelay(waitMs, waitMs, () -> {
+            if (myHeartbeatSeen) {
+                myHeartbeatSeen = false;
+                return null;
+            }
+
+            // missed my heart beat
+            logger.error("missed my heartbeat on topic {}", getTopic());
+
+            return missedHeartbeat();
+        });
+
+        /*
+         * predecessor heart beat checker
+         */
+        if (!predHost.isEmpty()) {
+
+            scheduleWithFixedDelay(waitMs, waitMs, () -> {
+                if (predHeartbeatSeen) {
+                    predHeartbeatSeen = false;
+                    return null;
+                }
+
+                // missed the predecessor's heart beat
+                logger.warn("missed predecessor's heartbeat on topic {}", getTopic());
+
+                publish(makeQuery());
+
+                return goQuery();
+            });
+        }
+    }
+
+    /**
+     * Generates a heart beat for this host and its successor.
+     */
+    private void genHeartbeat() {
+        var msg = makeHeartbeat(System.currentTimeMillis());
+        publish(getHost(), msg);
+
+        if (succHost != null) {
+            publish(succHost, msg);
+        }
+    }
+
+    @Override
+    public State process(Heartbeat msg) {
+        String src = msg.getSource();
+
+        if (src == null) {
+            logger.warn("Heartbeat message has no source on topic {}", getTopic());
+
+        } else if (src.equals(getHost())) {
+            logger.info("saw my heartbeat on topic {}", getTopic());
+            myHeartbeatSeen = true;
+
+        } else if (src.equals(predHost)) {
+            logger.info("saw heartbeat from {} on topic {}", src, getTopic());
+            predHeartbeatSeen = true;
+
+        } else {
+            logger.info("ignored heartbeat message from {} on topic {}", src, getTopic());
+        }
+
+        return null;
+    }
+
+    @Override
+    public State process(Leader msg) {
+        if (!isValid(msg)) {
+            return null;
+        }
+
+        String src = msg.getSource();
+
+        if (getHost().compareTo(src) < 0) {
+            // our host would be a better leader - find out what's up
+            logger.warn("unexpected Leader message from {} on topic {}", src, getTopic());
+            return goQuery();
+        }
+
+        logger.info("have a new leader {} on topic {}", src, getTopic());
+
+        return goActive(msg.getAssignments());
+    }
+
+    @Override
+    public State process(Offline msg) {
+        String src = msg.getSource();
+
+        if (src == null) {
+            logger.warn("Offline message has no source on topic {}", getTopic());
+            return null;
+
+        } else if (!assigned.contains(src)) {
+            /*
+             * the offline host wasn't assigned any buckets, so just ignore the message
+             */
+            logger.info("ignore Offline message from unassigned source {} on topic {}", src, getTopic());
+            return null;
+
+        } else if (isLeader() || (predHost.equals(src) && predHost.equals(assigned.first()))) {
+            /*
+             * Case 1: We are the leader.
+             *
+             * Case 2: Our predecessor was the leader, and it has gone offline - we should
+             * become the leader.
+             *
+             * In either case, we are now the leader, and we must re-balance the buckets
+             * since one of the hosts has gone offline.
+             */
+
+            logger.info("Offline message from source {} on topic {}", src, getTopic());
+
+            assigned.remove(src);
+
+            return becomeLeader(assigned);
+
+        } else {
+            /*
+             * Otherwise, we don't care right now - we'll wait for the leader to tell us
+             * it's been removed.
+             */
+            logger.info("ignore Offline message from source {} on topic {}", src, getTopic());
+            return null;
+        }
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/IdleState.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/IdleState.java
new file mode 100644
index 0000000..0641828
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/IdleState.java
@@ -0,0 +1,34 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+import org.onap.policy.drools.pooling.PoolingManager;
+
+/**
+ * Idle state, used when offline.
+ */
+public class IdleState extends State {
+
+    public IdleState(PoolingManager mgr) {
+        super(mgr);
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/InactiveState.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/InactiveState.java
new file mode 100644
index 0000000..7fc220a
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/InactiveState.java
@@ -0,0 +1,81 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+import org.onap.policy.drools.pooling.PoolingManager;
+import org.onap.policy.drools.pooling.message.Leader;
+import org.onap.policy.drools.pooling.message.Query;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The inactive state. In this state, we just wait a bit and then try to re-activate. In
+ * the meantime, all messages are ignored.
+ */
+public class InactiveState extends State {
+
+    private static final Logger logger = LoggerFactory.getLogger(InactiveState.class);
+
+    /**
+     * Constructor.
+     * 
+     * @param mgr pooling manager
+     */
+    public InactiveState(PoolingManager mgr) {
+        super(mgr);
+    }
+
+    @Override
+    public void start() {
+        super.start();
+        schedule(getProperties().getReactivateMs(), this::goStart);
+    }
+
+    @Override
+    public State process(Leader msg) {
+        if (isValid(msg)) {
+            logger.info("received Leader message from {} on topic {}", msg.getSource(), getTopic());
+            return goActive(msg.getAssignments());
+        }
+
+        return null;
+    }
+
+    /**
+     * Generates an Identification message and goes to the query state.
+     */
+    @Override
+    public State process(Query msg) {
+        logger.info("received Query message on topic {}", getTopic());
+        publish(makeIdentification());
+        return goQuery();
+    }
+
+    /**
+     * Remains in this state, without resetting any timers.
+     */
+    @Override
+    protected State goInactive() {
+        return null;
+    }
+
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/ProcessingState.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/ProcessingState.java
new file mode 100644
index 0000000..76914b7
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/ProcessingState.java
@@ -0,0 +1,398 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.Map;
+import java.util.Queue;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import lombok.Getter;
+import lombok.NonNull;
+import lombok.Setter;
+import org.onap.policy.drools.pooling.PoolingManager;
+import org.onap.policy.drools.pooling.message.BucketAssignments;
+import org.onap.policy.drools.pooling.message.Leader;
+import org.onap.policy.drools.pooling.message.Query;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Any state in which events are being processed locally and forwarded, as appropriate.
+ */
+@Setter
+@Getter
+public class ProcessingState extends State {
+
+    private static final Logger logger = LoggerFactory.getLogger(ProcessingState.class);
+
+    /**
+     * Current known leader, never {@code null}.
+     */
+    @NonNull
+    private String leader;
+
+    /**
+     * Constructor.
+     *
+     * @param mgr pooling manager
+     * @param leader current known leader, which need not be the same as the assignment
+     *        leader. Never {@code null}
+     * @throws IllegalArgumentException if an argument is invalid
+     */
+    public ProcessingState(PoolingManager mgr, @NonNull String leader) {
+        super(mgr);
+
+        BucketAssignments assignments = mgr.getAssignments();
+
+        if (assignments != null) {
+            String[] arr = assignments.getHostArray();
+            if (arr != null && arr.length == 0) {
+                throw new IllegalArgumentException("zero-length bucket assignments");
+            }
+        }
+
+        this.leader = leader;
+    }
+
+    /**
+     * Generates an Identification message and goes to the query state.
+     */
+    @Override
+    public State process(Query msg) {
+        logger.info("received Query message on topic {}", getTopic());
+        publish(makeIdentification());
+        return goQuery();
+    }
+
+    /**
+     * Sets the assignments.
+     *
+     * @param assignments new assignments, or {@code null}
+     */
+    protected final void setAssignments(BucketAssignments assignments) {
+        if (assignments != null) {
+            startDistributing(assignments);
+        }
+    }
+
+    /**
+     * Determines if this host is the leader, based on the current assignments.
+     *
+     * @return {@code true} if this host is the leader, {@code false} otherwise
+     */
+    public boolean isLeader() {
+        return getHost().equals(leader);
+    }
+
+    /**
+     * Becomes the leader. Publishes a Leader message and enters the {@link ActiveState}.
+     *
+     * @param alive hosts that are known to be alive
+     *
+     * @return the new state
+     */
+    protected State becomeLeader(SortedSet<String> alive) {
+        String newLeader = getHost();
+
+        if (!newLeader.equals(alive.first())) {
+            throw new IllegalArgumentException(newLeader + " cannot replace " + alive.first());
+        }
+
+        var msg = makeLeader(alive);
+        logger.info("{}/{} hosts have an assignment", msg.getAssignments().getAllHosts().size(), alive.size());
+
+        publish(msg);
+
+        return goActive(msg.getAssignments());
+    }
+
+    /**
+     * Makes a leader message. Assumes "this" host is the leader, and thus appears as the
+     * first host in the set of hosts that are still alive.
+     *
+     * @param alive hosts that are known to be alive
+     *
+     * @return a new message
+     */
+    private Leader makeLeader(Set<String> alive) {
+        return new Leader(getHost(), makeAssignments(alive));
+    }
+
+    /**
+     * Makes a set of bucket assignments. Assumes "this" host is the leader.
+     *
+     * @param alive hosts that are known to be alive
+     *
+     * @return a new set of bucket assignments
+     */
+    private BucketAssignments makeAssignments(Set<String> alive) {
+
+        // make a working array from the CURRENT assignments
+        String[] bucket2host = makeBucketArray();
+
+        TreeSet<String> avail = new TreeSet<>(alive);
+
+        // if we have more hosts than buckets, then remove the extra hosts
+        removeExcessHosts(bucket2host.length, avail);
+
+        // create a host bucket for each available host
+        Map<String, HostBucket> host2hb = new HashMap<>();
+        avail.forEach(host -> host2hb.put(host, new HostBucket(host)));
+
+        // add bucket indices to the appropriate host bucket
+        addIndicesToHostBuckets(bucket2host, host2hb);
+
+        // convert the collection back to an array
+        fillArray(host2hb.values(), bucket2host);
+
+        // update bucket2host with new assignments
+        rebalanceBuckets(host2hb.values(), bucket2host);
+
+        return new BucketAssignments(bucket2host);
+    }
+
+    /**
+     * Makes a bucket array, copying the current assignments, if available.
+     *
+     * @return a new bucket array
+     */
+    private String[] makeBucketArray() {
+        BucketAssignments asgn = getAssignments();
+        if (asgn == null) {
+            return new String[BucketAssignments.MAX_BUCKETS];
+        }
+
+        String[] oldArray = asgn.getHostArray();
+        if (oldArray.length == 0) {
+            return new String[BucketAssignments.MAX_BUCKETS];
+        }
+
+        var newArray = new String[oldArray.length];
+        System.arraycopy(oldArray, 0, newArray, 0, oldArray.length);
+
+        return newArray;
+    }
+
+    /**
+     * Removes excess hosts from the set of available hosts. Assumes "this" host is the
+     * leader, and thus appears as the first host in the set.
+     *
+     * @param maxHosts maximum number of hosts to be retained
+     * @param avail available hosts
+     */
+    private void removeExcessHosts(int maxHosts, SortedSet<String> avail) {
+        while (avail.size() > maxHosts) {
+            /*
+             * Don't remove this host, as it's the leader. Since the leader is always at
+             * the front of the sorted set, we'll just pick off hosts from the back of the
+             * set.
+             */
+            String host = avail.last();
+            avail.remove(host);
+
+            logger.warn("not using extra host {} for topic {}", host, getTopic());
+        }
+    }
+
+    /**
+     * Adds bucket indices to {@link HostBucket} objects. Buckets that are unassigned or
+     * assigned to a host that does not appear within the map are re-assigned to a host
+     * that appears within the map.
+     *
+     * @param bucket2host bucket assignments
+     * @param host2data maps a host name to its {@link HostBucket}
+     */
+    private void addIndicesToHostBuckets(String[] bucket2host, Map<String, HostBucket> host2data) {
+        LinkedList<Integer> nullBuckets = new LinkedList<>();
+
+        for (var x = 0; x < bucket2host.length; ++x) {
+            String host = bucket2host[x];
+            if (host == null) {
+                nullBuckets.add(x);
+
+            } else {
+                HostBucket hb = host2data.get(host);
+                if (hb == null) {
+                    nullBuckets.add(x);
+
+                } else {
+                    hb.add(x);
+                }
+            }
+        }
+
+        // assign the null buckets to other hosts
+        assignNullBuckets(nullBuckets, host2data.values());
+    }
+
+    /**
+     * Assigns null buckets (i.e., those having no assignment) to available hosts.
+     *
+     * @param buckets buckets that still need to be assigned to hosts
+     * @param coll collection of current host-bucket assignments
+     */
+    private void assignNullBuckets(Queue<Integer> buckets, Collection<HostBucket> coll) {
+        // assign null buckets to the hosts with the fewest buckets
+        TreeSet<HostBucket> assignments = new TreeSet<>(coll);
+
+        for (Integer index : buckets) {
+            // add it to the host with the shortest bucket list
+            HostBucket newhb = assignments.pollFirst();
+            assert newhb != null;
+            newhb.add(index);
+
+            // put the item back into the queue, with its new count
+            assignments.add(newhb);
+        }
+    }
+
+    /**
+     * Re-balances the buckets, taking from those that have a larger count and giving to
+     * those that have a smaller count. Populates an output array with the new
+     * assignments.
+     *
+     * @param coll current bucket assignment
+     * @param bucket2host array to be populated with the new assignments
+     */
+    private void rebalanceBuckets(Collection<HostBucket> coll, String[] bucket2host) {
+        if (coll.size() <= 1) {
+            // only one hosts - nothing to rebalance
+            return;
+        }
+
+        TreeSet<HostBucket> assignments = new TreeSet<>(coll);
+
+        for (;;) {
+            HostBucket smaller = assignments.pollFirst();
+            HostBucket larger = assignments.pollLast();
+
+            assert larger != null && smaller != null;
+            if (larger.size() - smaller.size() <= 1) {
+                // it's as balanced as it will get
+                break;
+            }
+
+            // move the bucket from the larger to the smaller
+            Integer bucket = larger.remove();
+            smaller.add(bucket);
+
+            bucket2host[bucket] = smaller.host;
+
+            // put the items back, with their new counts
+            assignments.add(larger);
+            assignments.add(smaller);
+        }
+
+    }
+
+    /**
+     * Fills the array with the host assignments.
+     *
+     * @param coll the host assignments
+     * @param bucket2host array to be filled
+     */
+    private void fillArray(Collection<HostBucket> coll, String[] bucket2host) {
+        for (HostBucket hb : coll) {
+            for (Integer index : hb.buckets) {
+                bucket2host[index] = hb.host;
+            }
+        }
+    }
+
+    /**
+     * Tracks buckets that have been assigned to a host.
+     */
+    protected static class HostBucket implements Comparable<HostBucket> {
+        /**
+         * Host to which the buckets have been assigned.
+         */
+        private String host;
+
+        /**
+         * Buckets that have been assigned to this host.
+         */
+        private Queue<Integer> buckets = new LinkedList<>();
+
+        /**
+         * Constructor.
+         *
+         * @param host host
+         */
+        public HostBucket(String host) {
+            this.host = host;
+        }
+
+        /**
+         * Removes the next bucket from the list.
+         *
+         * @return the next bucket
+         */
+        public final Integer remove() {
+            return buckets.remove();
+        }
+
+        /**
+         * Adds a bucket to the list.
+         *
+         * @param index index of the bucket to add
+         */
+        public final void add(Integer index) {
+            buckets.add(index);
+        }
+
+        /**
+         * Size.
+         *
+         * @return the number of buckets assigned to this host
+         */
+        public final int size() {
+            return buckets.size();
+        }
+
+        /**
+         * Compares host buckets, first by the number of buckets, and then by the host
+         * name.
+         */
+        @Override
+        public final int compareTo(HostBucket other) {
+            int diff = buckets.size() - other.buckets.size();
+            if (diff == 0) {
+                diff = host.compareTo(other.host);
+            }
+            return diff;
+        }
+
+        @Override
+        public final int hashCode() {
+            throw new UnsupportedOperationException("HostBucket cannot be hashed");
+        }
+
+        @Override
+        public final boolean equals(Object obj) {
+            throw new UnsupportedOperationException("cannot compare HostBuckets");
+        }
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/QueryState.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/QueryState.java
new file mode 100644
index 0000000..ef401dc
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/QueryState.java
@@ -0,0 +1,204 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+import java.util.TreeSet;
+import org.onap.policy.drools.pooling.PoolingManager;
+import org.onap.policy.drools.pooling.message.BucketAssignments;
+import org.onap.policy.drools.pooling.message.Identification;
+import org.onap.policy.drools.pooling.message.Leader;
+import org.onap.policy.drools.pooling.message.Offline;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The Query state. In this state, the host waits for the other hosts to identify
+ * themselves. Eventually, a leader should come forth. If not, it will transition to the
+ * active or inactive state, depending on whether it has an assignment in the
+ * current bucket assignments. The other possibility is that it may <i>become</i> the
+ * leader, in which case it will also transition to the active state.
+ */
+public class QueryState extends ProcessingState {
+
+    private static final Logger logger = LoggerFactory.getLogger(QueryState.class);
+
+    /**
+     * Hosts that have sent an "Identification" message. Always includes this host.
+     */
+    private final TreeSet<String> alive = new TreeSet<>();
+
+    /**
+     * {@code True} if we saw our own Identification method, {@code false} otherwise.
+     */
+    private boolean sawSelfIdent = false;
+
+    /**
+     * Constructor.
+     * 
+     * @param mgr manager
+     */
+    public QueryState(PoolingManager mgr) {
+        // this host is the leader, until a better candidate identifies itself
+        super(mgr, mgr.getHost());
+
+        alive.add(getHost());
+    }
+
+    @Override
+    public void start() {
+        super.start();
+
+        // start identification timer
+        awaitIdentification();
+    }
+
+    /**
+     * Starts a timer to wait for all Identification messages to arrive.
+     */
+    private void awaitIdentification() {
+
+        /*
+         * Once we've waited long enough for all Identification messages to arrive, become
+         * the leader, assuming we should.
+         */
+
+        schedule(getProperties().getIdentificationMs(), () -> {
+
+            if (!sawSelfIdent) {
+                // didn't see our identification
+                logger.error("missed our own Ident message on topic {}", getTopic());
+                return missedHeartbeat();
+
+            } else if (isLeader()) {
+                // "this" host is the new leader
+                logger.info("this host is the new leader for topic {}", getTopic());
+                return becomeLeader(alive);
+
+            } else {
+                // not the leader - return to previous state
+                logger.info("no new leader on topic {}", getTopic());
+                return goActive(getAssignments());
+            }
+        });
+    }
+
+    @Override
+    public State goQuery() {
+        return null;
+    }
+
+    @Override
+    public State process(Identification msg) {
+
+        if (getHost().equals(msg.getSource())) {
+            logger.info("saw our own Ident message on topic {}", getTopic());
+            sawSelfIdent = true;
+
+        } else {
+            logger.info("received Ident message from {} on topic {}", msg.getSource(), getTopic());
+            recordInfo(msg.getSource(), msg.getAssignments());
+        }
+
+        return null;
+    }
+
+    /**
+     * If the message leader is better than the leader we have, then go active with it.
+     * Otherwise, simply treat it like an {@link Identification} message.
+     */
+    @Override
+    public State process(Leader msg) {
+        if (!isValid(msg)) {
+            return null;
+        }
+
+        String source = msg.getSource();
+        BucketAssignments asgn = msg.getAssignments();
+
+        // go active, if this has a leader that's the same or better than the one we have
+        if (source.compareTo(getLeader()) <= 0) {
+            logger.warn("leader with {} on topic {}", source, getTopic());
+            return goActive(asgn);
+        }
+
+        /*
+         * The message does not have an acceptable leader, but we'll still record its
+         * info.
+         */
+        logger.info("record leader info from {} on topic {}", source, getTopic());
+        recordInfo(source, asgn);
+
+        return null;
+    }
+
+    @Override
+    public State process(Offline msg) {
+        String host = msg.getSource();
+
+        if (host != null && !host.equals(getHost())) {
+            logger.warn("host {} offline on topic {}", host, getTopic());
+            alive.remove(host);
+            setLeader(alive.first());
+
+        } else {
+            logger.info("ignored offline message from {} on topic {}", msg.getSource(), getTopic());
+        }
+
+        return null;
+    }
+
+    /**
+     * Records info from a message, adding the source host name to {@link #alive}, and
+     * updating the bucket assignments.
+     * 
+     * @param source the message's source host
+     * @param assignments assignments, or {@code null}
+     */
+    private void recordInfo(String source, BucketAssignments assignments) {
+        // add this message's source host to "alive"
+        if (source != null) {
+            alive.add(source);
+            setLeader(alive.first());
+        }
+
+        if (assignments == null || assignments.getLeader() == null) {
+            return;
+        }
+
+        // record assignments, if we don't have any yet
+        BucketAssignments current = getAssignments();
+        if (current == null) {
+            logger.info("received initial assignments on topic {}", getTopic());
+            setAssignments(assignments);
+            return;
+        }
+
+        /*
+         * Record assignments, if the new assignments have a better (i.e., lesser) leader.
+         */
+        String curldr = current.getLeader();
+        if (curldr == null || assignments.getLeader().compareTo(curldr) < 0) {
+            logger.info("use new assignments from {} on topic {}", source, getTopic());
+            setAssignments(assignments);
+        }
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/StartState.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/StartState.java
new file mode 100644
index 0000000..73717d7
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/StartState.java
@@ -0,0 +1,99 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2020-2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+import lombok.Getter;
+import org.onap.policy.drools.pooling.PoolingManager;
+import org.onap.policy.drools.pooling.message.Heartbeat;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The start state. Upon entry, a heart beat is generated and the event filter is changed
+ * to look for just that particular message. Once the message is seen, it goes into the
+ * {@link QueryState}.
+ */
+@Getter
+public class StartState extends State {
+
+    private static final Logger logger = LoggerFactory.getLogger(StartState.class);
+
+    /**
+     * Time stamp inserted into the heart beat message.
+     */
+    private long hbTimestampMs = System.currentTimeMillis();
+
+    /**
+     * Constructor.
+     *
+     * @param mgr pooling manager
+     */
+    public StartState(PoolingManager mgr) {
+        super(mgr);
+    }
+
+    @Override
+    public void start() {
+
+        super.start();
+
+        var hb = makeHeartbeat(hbTimestampMs);
+        publish(getHost(), hb);
+
+        /*
+         * heart beat generator
+         */
+        long genMs = getProperties().getInterHeartbeatMs();
+
+        scheduleWithFixedDelay(genMs, genMs, () -> {
+            publish(getHost(), hb);
+            return null;
+        });
+
+        /*
+         * my heart beat checker
+         */
+        schedule(getProperties().getStartHeartbeatMs(), () -> {
+            logger.error("missed heartbeat on topic {}", getTopic());
+            return internalTopicFailed();
+        });
+    }
+
+    /**
+     * Transitions to the query state if the heart beat originated from this host and its
+     * time stamp matches.
+     */
+    @Override
+    public State process(Heartbeat msg) {
+        if (msg.getTimestampMs() == hbTimestampMs && getHost().equals(msg.getSource())) {
+            // saw our own heart beat - transition to query state
+            logger.info("saw our own heartbeat on topic {}", getTopic());
+            publish(makeQuery());
+            return goQuery();
+
+        } else {
+            logger.info("ignored old heartbeat message from {} on topic {}", msg.getSource(), getTopic());
+        }
+
+        return null;
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/State.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/State.java
new file mode 100644
index 0000000..e2cf958
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/State.java
@@ -0,0 +1,373 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2020-2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+import java.util.LinkedList;
+import java.util.List;
+import org.onap.policy.drools.pooling.CancellableScheduledTask;
+import org.onap.policy.drools.pooling.PoolingManager;
+import org.onap.policy.drools.pooling.PoolingProperties;
+import org.onap.policy.drools.pooling.message.BucketAssignments;
+import org.onap.policy.drools.pooling.message.Heartbeat;
+import org.onap.policy.drools.pooling.message.Identification;
+import org.onap.policy.drools.pooling.message.Leader;
+import org.onap.policy.drools.pooling.message.Offline;
+import org.onap.policy.drools.pooling.message.Query;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A state in the finite state machine.
+ *
+ * <p>A state may have several timers associated with it, which must be cancelled whenever
+ * the state is changed. Assumes that timers are not continuously added to the same state.
+ */
+public abstract class State {
+
+    private static final Logger logger = LoggerFactory.getLogger(State.class);
+
+    /**
+     * Host pool manager.
+     */
+    private final PoolingManager mgr;
+
+    /**
+     * Timers added by this state.
+     */
+    private final List<CancellableScheduledTask> timers = new LinkedList<>();
+
+    /**
+     * Constructor.
+     *
+     * @param mgr pooling manager
+     */
+    protected State(PoolingManager mgr) {
+        this.mgr = mgr;
+    }
+
+    /**
+     * Cancels the timers added by this state.
+     */
+    public final void cancelTimers() {
+        timers.forEach(CancellableScheduledTask::cancel);
+    }
+
+    /**
+     * Starts the state. The default method simply logs a message and returns.
+     */
+    public void start() {
+        logger.info("entered {} for topic {}", getClass().getSimpleName(), getTopic());
+    }
+
+    /**
+     * Transitions to the "start" state.
+     *
+     * @return the new state
+     */
+    public final State goStart() {
+        return mgr.goStart();
+    }
+
+    /**
+     * Transitions to the "query" state.
+     *
+     * @return the new state
+     */
+    public State goQuery() {
+        return mgr.goQuery();
+    }
+
+    /**
+     * Goes active with a new set of assignments.
+     *
+     * @param asgn new assignments
+     * @return the new state, either Active or Inactive, depending on whether or not this
+     *         host has an assignment
+     */
+    protected State goActive(BucketAssignments asgn) {
+        startDistributing(asgn);
+
+        if (asgn != null && asgn.hasAssignment(getHost())) {
+            return mgr.goActive();
+
+        } else {
+            return goInactive();
+        }
+    }
+
+    /**
+     * Transitions to the "inactive" state.
+     *
+     * @return the new state
+     */
+    protected State goInactive() {
+        return mgr.goInactive();
+    }
+
+    /**
+     * Processes a message. The default method just returns {@code null}.
+     *
+     * @param msg message to be processed
+     * @return the new state, or {@code null} if the state is unchanged
+     */
+    public State process(Heartbeat msg) {
+        logger.info("ignored heartbeat message from {} on topic {}", msg.getSource(), getTopic());
+        return null;
+    }
+
+    /**
+     * Processes a message. The default method just returns {@code null}.
+     *
+     * @param msg message to be processed
+     * @return the new state, or {@code null} if the state is unchanged
+     */
+    public State process(Identification msg) {
+        logger.info("ignored ident message from {} on topic {}", msg.getSource(), getTopic());
+        return null;
+    }
+
+    /**
+     * Processes a message. The default method copies the assignments and then returns
+     * {@code null}.
+     *
+     * @param msg message to be processed
+     * @return the new state, or {@code null} if the state is unchanged
+     */
+    public State process(Leader msg) {
+        if (isValid(msg)) {
+            logger.info("extract assignments from Leader message from {} on topic {}", msg.getSource(), getTopic());
+            startDistributing(msg.getAssignments());
+        }
+
+        return null;
+    }
+
+    /**
+     * Processes a message. The default method just returns {@code null}.
+     *
+     * @param msg message to be processed
+     * @return the new state, or {@code null} if the state is unchanged
+     */
+    public State process(Offline msg) {
+        logger.info("ignored offline message from {} on topic {}", msg.getSource(), getTopic());
+        return null;
+    }
+
+    /**
+     * Processes a message. The default method just returns {@code null}.
+     *
+     * @param msg message to be processed
+     * @return the new state, or {@code null} if the state is unchanged
+     */
+    public State process(Query msg) {
+        logger.info("ignored Query message from {} on topic {}", msg.getSource(), getTopic());
+        return null;
+    }
+
+    /**
+     * Determines if a message is valid and did not originate from this host.
+     *
+     * @param msg message to be validated
+     * @return {@code true} if the message is valid, {@code false} otherwise
+     */
+    protected boolean isValid(Leader msg) {
+        BucketAssignments asgn = msg.getAssignments();
+        if (asgn == null) {
+            logger.warn("Leader message from {} has no assignments for topic {}", msg.getSource(), getTopic());
+            return false;
+        }
+
+        // ignore Leader messages from ourself
+        String source = msg.getSource();
+        if (source == null || source.equals(getHost())) {
+            logger.debug("ignore Leader message from {} for topic {}", msg.getSource(), getTopic());
+            return false;
+        }
+
+        // the new leader must equal the source
+        boolean result = source.equals(asgn.getLeader());
+
+        if (!result) {
+            logger.warn("Leader message from {} has an invalid assignment for topic {}", msg.getSource(), getTopic());
+        }
+
+        return result;
+    }
+
+    /**
+     * Publishes a message.
+     *
+     * @param msg message to be published
+     */
+    protected final void publish(Identification msg) {
+        mgr.publishAdmin(msg);
+    }
+
+    /**
+     * Publishes a message.
+     *
+     * @param msg message to be published
+     */
+    protected final void publish(Leader msg) {
+        mgr.publishAdmin(msg);
+    }
+
+    /**
+     * Publishes a message.
+     *
+     * @param msg message to be published
+     */
+    protected final void publish(Offline msg) {
+        mgr.publishAdmin(msg);
+    }
+
+    /**
+     * Publishes a message.
+     *
+     * @param msg message to be published
+     */
+    protected final void publish(Query msg) {
+        mgr.publishAdmin(msg);
+    }
+
+    /**
+     * Publishes a message on the specified channel.
+     *
+     * @param channel channel
+     * @param msg message to be published
+     */
+    protected final void publish(String channel, Heartbeat msg) {
+        mgr.publish(channel, msg);
+    }
+
+    /**
+     * Starts distributing messages using the specified bucket assignments.
+     *
+     * @param assignments assignments
+     */
+    protected final void startDistributing(BucketAssignments assignments) {
+        if (assignments != null) {
+            mgr.startDistributing(assignments);
+        }
+    }
+
+    /**
+     * Schedules a timer to fire after a delay.
+     *
+     * @param delayMs delay in ms
+     * @param task task
+     */
+    protected final void schedule(long delayMs, StateTimerTask task) {
+        timers.add(mgr.schedule(delayMs, task));
+    }
+
+    /**
+     * Schedules a timer to fire repeatedly.
+     *
+     * @param initialDelayMs initial delay ms
+     * @param delayMs delay ms
+     * @param task task
+     */
+    protected final void scheduleWithFixedDelay(long initialDelayMs, long delayMs, StateTimerTask task) {
+        timers.add(mgr.scheduleWithFixedDelay(initialDelayMs, delayMs, task));
+    }
+
+    /**
+     * Indicates that we failed to see our own heartbeat; must be a problem with the
+     * internal topic. Assumes the problem is temporary and continues to use the current
+     * bucket assignments.
+     *
+     * @return a new {@link StartState}
+     */
+    protected final State missedHeartbeat() {
+        publish(makeOffline());
+
+        return mgr.goStart();
+    }
+
+    /**
+     * Indicates that the internal topic failed; this should only be invoked from the
+     * StartState. Discards bucket assignments and begins processing everything locally.
+     *
+     * @return a new {@link InactiveState}
+     */
+    protected final State internalTopicFailed() {
+        publish(makeOffline());
+        mgr.startDistributing(null);
+
+        return mgr.goInactive();
+    }
+
+    /**
+     * Makes a heart beat message.
+     *
+     * @param timestampMs time, in milliseconds, associated with the message
+     *
+     * @return a new message
+     */
+    protected final Heartbeat makeHeartbeat(long timestampMs) {
+        return new Heartbeat(getHost(), timestampMs);
+    }
+
+    /**
+     * Makes an Identification message.
+     *
+     * @return a new message
+     */
+    protected Identification makeIdentification() {
+        return new Identification(getHost(), getAssignments());
+    }
+
+    /**
+     * Makes an "offline" message.
+     *
+     * @return a new message
+     */
+    protected final Offline makeOffline() {
+        return new Offline(getHost());
+    }
+
+    /**
+     * Makes a query message.
+     *
+     * @return a new message
+     */
+    protected final Query makeQuery() {
+        return new Query(getHost());
+    }
+
+    public final BucketAssignments getAssignments() {
+        return mgr.getAssignments();
+    }
+
+    public final String getHost() {
+        return mgr.getHost();
+    }
+
+    public final String getTopic() {
+        return mgr.getTopic();
+    }
+
+    public final PoolingProperties getProperties() {
+        return mgr.getProperties();
+    }
+}
diff --git a/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/StateTimerTask.java b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/StateTimerTask.java
new file mode 100644
index 0000000..892a176
--- /dev/null
+++ b/feature-pooling-messages/src/main/java/org/onap/policy/drools/pooling/state/StateTimerTask.java
@@ -0,0 +1,37 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2019 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+/**
+ * Task to be executed when a timer fires within a {@link State}.
+ */
+@FunctionalInterface
+public interface StateTimerTask {
+
+    /**
+     * Fires the timer.
+     *
+     * @return the new state, or {@code null} if the state is unchanged
+     */
+    State fire();
+
+}
diff --git a/feature-pooling-messages/src/main/resources/META-INF/services/org.onap.policy.drools.features.DroolsControllerFeatureApi b/feature-pooling-messages/src/main/resources/META-INF/services/org.onap.policy.drools.features.DroolsControllerFeatureApi
new file mode 100644
index 0000000..cd59e46
--- /dev/null
+++ b/feature-pooling-messages/src/main/resources/META-INF/services/org.onap.policy.drools.features.DroolsControllerFeatureApi
@@ -0,0 +1 @@
+org.onap.policy.drools.pooling.PoolingFeature
diff --git a/feature-pooling-messages/src/main/resources/META-INF/services/org.onap.policy.drools.features.PolicyControllerFeatureApi b/feature-pooling-messages/src/main/resources/META-INF/services/org.onap.policy.drools.features.PolicyControllerFeatureApi
new file mode 100644
index 0000000..cd59e46
--- /dev/null
+++ b/feature-pooling-messages/src/main/resources/META-INF/services/org.onap.policy.drools.features.PolicyControllerFeatureApi
@@ -0,0 +1 @@
+org.onap.policy.drools.pooling.PoolingFeature
diff --git a/feature-pooling-messages/src/main/resources/META-INF/services/org.onap.policy.drools.features.PolicyEngineFeatureApi b/feature-pooling-messages/src/main/resources/META-INF/services/org.onap.policy.drools.features.PolicyEngineFeatureApi
new file mode 100644
index 0000000..cd59e46
--- /dev/null
+++ b/feature-pooling-messages/src/main/resources/META-INF/services/org.onap.policy.drools.features.PolicyEngineFeatureApi
@@ -0,0 +1 @@
+org.onap.policy.drools.pooling.PoolingFeature
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/EndToEndFeatureTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/EndToEndFeatureTest.java
new file mode 100644
index 0000000..31ad207
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/EndToEndFeatureTest.java
@@ -0,0 +1,810 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.onap.policy.drools.pooling.PoolingProperties.PREFIX;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+import java.util.Arrays;
+import java.util.Deque;
+import java.util.IdentityHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Properties;
+import java.util.TreeMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+import org.onap.policy.common.endpoints.event.comm.Topic.CommInfrastructure;
+import org.onap.policy.common.endpoints.event.comm.TopicEndpointManager;
+import org.onap.policy.common.endpoints.event.comm.TopicListener;
+import org.onap.policy.common.endpoints.event.comm.TopicSink;
+import org.onap.policy.common.endpoints.event.comm.TopicSource;
+import org.onap.policy.common.endpoints.properties.PolicyEndPointProperties;
+import org.onap.policy.drools.controller.DroolsController;
+import org.onap.policy.drools.system.PolicyController;
+import org.onap.policy.drools.system.PolicyEngine;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * End-to-end tests of the pooling feature. Launches one or more "hosts", each one having its own
+ * feature object. Uses real feature objects, as well as real DMaaP sources and sinks. However, the
+ * following are not: <dl> <dt>PolicyEngine, PolicyController, DroolsController</dt> <dd>mocked</dd>
+ * </dl>
+ *
+ * <p>The following fields must be set before executing this: <ul> <li>UEB_SERVERS</li>
+ * <li>INTERNAL_TOPIC</li> <li>EXTERNAL_TOPIC</li> </ul>
+ */
+public class EndToEndFeatureTest {
+
+    private static final Logger logger = LoggerFactory.getLogger(EndToEndFeatureTest.class);
+
+    /**
+     * UEB servers for both internal & external topics.
+     */
+    private static final String UEB_SERVERS = "ueb-server";
+
+    /**
+     * Name of the topic used for inter-host communication.
+     */
+    private static final String INTERNAL_TOPIC = "internal-topic";
+
+    /**
+     * Name of the topic from which "external" events "arrive".
+     */
+    private static final String EXTERNAL_TOPIC = "external-topic";
+
+    /**
+     * Consumer group to use when polling the external topic.
+     */
+    private static final String EXTERNAL_GROUP = EndToEndFeatureTest.class.getName();
+
+    /**
+     * Name of the controller.
+     */
+    private static final String CONTROLLER1 = "controller.one";
+
+    /**
+     * Maximum number of items to fetch from DMaaP in a single poll.
+     */
+    private static final String FETCH_LIMIT = "5";
+
+    private static final long STD_REACTIVATE_WAIT_MS = 10000;
+    private static final long STD_IDENTIFICATION_MS = 10000;
+    private static final long STD_START_HEARTBEAT_MS = 15000;
+    private static final long STD_ACTIVE_HEARTBEAT_MS = 12000;
+    private static final long STD_INTER_HEARTBEAT_MS = 5000;
+    private static final long STD_OFFLINE_PUB_WAIT_MS = 2;
+    private static final long EVENT_WAIT_SEC = 15;
+
+    /**
+     * Used to decode events from the external topic.
+     */
+    private static final Gson mapper = new Gson();
+
+    /**
+     * Used to identify the current host.
+     */
+    private static final ThreadLocal<Host> currentHost = new ThreadLocal<Host>();
+
+    /**
+     * Sink for external DMaaP topic.
+     */
+    private static TopicSink externalSink;
+
+    /**
+     * Sink for internal DMaaP topic.
+     */
+    private static TopicSink internalSink;
+
+    /**
+     * Context for the current test case.
+     */
+    private Context ctx;
+
+    /**
+     * Setup before class.
+     *
+     */
+    @BeforeAll
+    public static void setUpBeforeClass() {
+        externalSink = TopicEndpointManager.getManager().addTopicSinks(makeSinkProperties(EXTERNAL_TOPIC)).get(0);
+        externalSink.start();
+
+        internalSink = TopicEndpointManager.getManager().addTopicSinks(makeSinkProperties(INTERNAL_TOPIC)).get(0);
+        internalSink.start();
+    }
+
+    /**
+     * Tear down after class.
+     *
+     */
+    @AfterAll
+    public static void tearDownAfterClass() {
+        externalSink.stop();
+        internalSink.stop();
+    }
+
+    /**
+     * Setup.
+     */
+    @BeforeEach
+    public void setUp() {
+        ctx = null;
+    }
+
+    /**
+     * Tear down.
+     */
+    @AfterEach
+    public void tearDown() {
+        if (ctx != null) {
+            ctx.destroy();
+        }
+    }
+
+    /*
+     * This test should only be run manually, after configuring all the fields,
+     * thus it is ignored.
+     */
+    @Disabled
+    @Test
+    public void test_SingleHost() throws Exception {    // NOSONAR
+        run(70, 1);
+    }
+
+    /*
+     * This test should only be run manually, after configuring all the fields,
+     * thus it is ignored.
+     */
+    @Disabled
+    @Test
+    public void test_TwoHosts() throws Exception {      // NOSONAR
+        run(200, 2);
+    }
+
+    /*
+     * This test should only be run manually, after configuring all the fields,
+     * thus it is ignored.
+     */
+    @Disabled
+    @Test
+    public void test_ThreeHosts() throws Exception {    // NOSONAR
+        run(200, 3);
+    }
+
+    private void run(int nmessages, int nhosts) throws Exception {
+        ctx = new Context(nmessages);
+
+        for (int x = 0; x < nhosts; ++x) {
+            ctx.addHost();
+        }
+
+        ctx.startHosts();
+        ctx.awaitAllActive(STD_IDENTIFICATION_MS * 2);
+
+        for (int x = 0; x < nmessages; ++x) {
+            ctx.offerExternal(makeMessage(x));
+        }
+
+        ctx.awaitEvents(EVENT_WAIT_SEC, TimeUnit.SECONDS);
+
+        assertEquals(0, ctx.getDecodeErrors());
+        assertEquals(0, ctx.getRemainingEvents());
+        ctx.checkAllSawAMsg();
+    }
+
+    private String makeMessage(int reqnum) {
+        return "{\"reqid\":\"req" + reqnum + "\", \"data\":\"hello " + reqnum + "\"}";
+    }
+
+    private static Properties makeSinkProperties(String topic) {
+        Properties props = new Properties();
+
+        props.setProperty(PolicyEndPointProperties.PROPERTY_UEB_SINK_TOPICS, topic);
+
+        props.setProperty(PolicyEndPointProperties.PROPERTY_UEB_SINK_TOPICS + "." + topic
+                + PolicyEndPointProperties.PROPERTY_TOPIC_SERVERS_SUFFIX, UEB_SERVERS);
+        props.setProperty(PolicyEndPointProperties.PROPERTY_UEB_SINK_TOPICS + "." + topic
+                + PolicyEndPointProperties.PROPERTY_TOPIC_SINK_PARTITION_KEY_SUFFIX, "0");
+        props.setProperty(PolicyEndPointProperties.PROPERTY_UEB_SINK_TOPICS + "." + topic
+                + PolicyEndPointProperties.PROPERTY_MANAGED_SUFFIX, "false");
+
+        return props;
+    }
+
+    private static Properties makeSourceProperties(String topic) {
+        Properties props = new Properties();
+
+        props.setProperty(PolicyEndPointProperties.PROPERTY_UEB_SOURCE_TOPICS, topic);
+
+        props.setProperty(PolicyEndPointProperties.PROPERTY_UEB_SOURCE_TOPICS + "." + topic
+                + PolicyEndPointProperties.PROPERTY_TOPIC_SERVERS_SUFFIX, UEB_SERVERS);
+        props.setProperty(PolicyEndPointProperties.PROPERTY_UEB_SOURCE_TOPICS + "." + topic
+                + PolicyEndPointProperties.PROPERTY_TOPIC_SOURCE_FETCH_LIMIT_SUFFIX, FETCH_LIMIT);
+        props.setProperty(PolicyEndPointProperties.PROPERTY_UEB_SOURCE_TOPICS + "." + topic
+                + PolicyEndPointProperties.PROPERTY_MANAGED_SUFFIX, "false");
+
+        if (EXTERNAL_TOPIC.equals(topic)) {
+            // consumer group is a constant
+            props.setProperty(PolicyEndPointProperties.PROPERTY_UEB_SOURCE_TOPICS + "." + topic
+                    + PolicyEndPointProperties.PROPERTY_TOPIC_SOURCE_CONSUMER_GROUP_SUFFIX, EXTERNAL_GROUP);
+
+            // consumer instance is generated by the BusConsumer code
+        }
+
+        // else internal topic: feature populates info for internal topic
+
+        return props;
+    }
+
+    /**
+     * Decodes an event.
+     *
+     * @param event event
+     * @return the decoded event, or {@code null} if it cannot be decoded
+     */
+    private static Object decodeEvent(String event) {
+        try {
+            return mapper.fromJson(event, TreeMap.class);
+
+        } catch (JsonParseException e) {
+            logger.warn("cannot decode external event", e);
+            return null;
+        }
+    }
+
+    /**
+     * Context used for a single test case.
+     */
+    private static class Context {
+
+        /**
+         * Hosts that have been added to this context.
+         */
+        private final Deque<Host> hosts = new LinkedList<>();
+
+        /**
+         * Maps a drools controller to its policy controller.
+         */
+        private final IdentityHashMap<DroolsController, PolicyController> drools2policy = new IdentityHashMap<>();
+
+        /**
+         * Counts the number of decode errors.
+         */
+        private final AtomicInteger decodeErrors = new AtomicInteger(0);
+
+        /**
+         * Number of events we're still waiting to receive.
+         */
+        private final CountDownLatch eventCounter;
+
+        /**
+         * Constructor.
+         *
+         * @param events number of events to be processed
+         */
+        public Context(int events) {
+            eventCounter = new CountDownLatch(events);
+        }
+
+        /**
+         * Destroys the context, stopping any hosts that remain.
+         */
+        public void destroy() {
+            stopHosts();
+            hosts.clear();
+        }
+
+        /**
+         * Creates and adds a new host to the context.
+         *
+         * @return the new Host
+         */
+        public Host addHost() {
+            Host host = new Host(this);
+            hosts.add(host);
+
+            return host;
+        }
+
+        /**
+         * Starts the hosts.
+         */
+        public void startHosts() {
+            hosts.forEach(host -> host.start());
+        }
+
+        /**
+         * Stops the hosts.
+         */
+        public void stopHosts() {
+            hosts.forEach(host -> host.stop());
+        }
+
+        /**
+         * Verifies that all hosts processed at least one message.
+         */
+        public void checkAllSawAMsg() {
+            int msgs = 0;
+            for (Host host : hosts) {
+                assertTrue(host.messageSeen(), "msgs=" + msgs);
+                ++msgs;
+            }
+        }
+
+        /**
+         * Offers an event to the external topic.
+         *
+         * @param event event
+         */
+        public void offerExternal(String event) {
+            externalSink.send(event);
+        }
+
+        /**
+         * Associates a controller with its drools controller.
+         *
+         * @param controller controller
+         * @param droolsController drools controller
+         */
+        public void addController(PolicyController controller, DroolsController droolsController) {
+            drools2policy.put(droolsController, controller);
+        }
+
+        /**
+         * Get controller.
+         *
+         * @param droolsController drools controller
+         * @return the controller associated with a drools controller, or {@code null} if it has no
+         *         associated controller
+         */
+        public PolicyController getController(DroolsController droolsController) {
+            return drools2policy.get(droolsController);
+        }
+
+        /**
+         * Get decode errors.
+         *
+         * @return the number of decode errors so far
+         */
+        public int getDecodeErrors() {
+            return decodeErrors.get();
+        }
+
+        /**
+         * Increments the count of decode errors.
+         */
+        public void bumpDecodeErrors() {
+            decodeErrors.incrementAndGet();
+        }
+
+        /**
+         * Get remaining events.
+         *
+         * @return the number of events that haven't been processed
+         */
+        public long getRemainingEvents() {
+            return eventCounter.getCount();
+        }
+
+        /**
+         * Adds an event to the counter.
+         */
+        public void addEvent() {
+            eventCounter.countDown();
+        }
+
+        /**
+         * Waits, for a period of time, for all events to be processed.
+         *
+         * @param time time
+         * @param units units
+         * @return {@code true} if all events have been processed, {@code false} otherwise
+         * @throws InterruptedException throws interrupted exception
+         */
+        public boolean awaitEvents(long time, TimeUnit units) throws InterruptedException {
+            return eventCounter.await(time, units);
+        }
+
+        /**
+         * Waits, for a period of time, for all hosts to enter the Active state.
+         *
+         * @param timeMs maximum time to wait, in milliseconds
+         * @throws InterruptedException throws interrupted exception
+         */
+        public void awaitAllActive(long timeMs) throws InterruptedException {
+            long tend = timeMs + System.currentTimeMillis();
+
+            for (Host host : hosts) {
+                long tremain = Math.max(0, tend - System.currentTimeMillis());
+                assertTrue(host.awaitActive(tremain));
+            }
+        }
+    }
+
+    /**
+     * Simulates a single "host".
+     */
+    private static class Host {
+
+        private final PoolingFeature feature;
+
+        /**
+         * {@code True} if this host has processed a message, {@code false} otherwise.
+         */
+        private final AtomicBoolean sawMsg = new AtomicBoolean(false);
+
+        private final TopicSource externalSource;
+        private final TopicSource internalSource;
+
+        // mock objects
+        private final PolicyEngine engine = mock(PolicyEngine.class);
+        private final ListenerController controller = mock(ListenerController.class);
+        private final DroolsController drools = mock(DroolsController.class);
+
+        /**
+         * Constructor.
+         *
+         * @param context context
+         */
+        public Host(Context context) {
+
+            when(controller.getName()).thenReturn(CONTROLLER1);
+            when(controller.getDrools()).thenReturn(drools);
+
+            externalSource = TopicEndpointManager.getManager().addTopicSources(makeSourceProperties(EXTERNAL_TOPIC))
+                            .get(0);
+            internalSource = TopicEndpointManager.getManager().addTopicSources(makeSourceProperties(INTERNAL_TOPIC))
+                            .get(0);
+
+            // stop consuming events if the controller stops
+            when(controller.stop()).thenAnswer(args -> {
+                externalSource.unregister(controller);
+                return true;
+            });
+
+            doAnswer(new MyExternalTopicListener(context, this)).when(controller).onTopicEvent(any(), any(), any());
+
+            context.addController(controller, drools);
+
+            feature = new PoolingFeatureImpl(context, this);
+        }
+
+        /**
+         * Waits, for a period of time, for the host to enter the Active state.
+         *
+         * @param timeMs time to wait, in milliseconds
+         * @return {@code true} if the host entered the Active state within the given amount of
+         *         time, {@code false} otherwise
+         * @throws InterruptedException throws interrupted exception
+         */
+        public boolean awaitActive(long timeMs) throws InterruptedException {
+            return feature.getActiveLatch().await(timeMs, TimeUnit.MILLISECONDS);
+        }
+
+        /**
+         * Starts threads for the host so that it begins consuming from both the external "DMaaP"
+         * topic and its own internal "DMaaP" topic.
+         */
+        public void start() {
+            feature.beforeStart(engine);
+            feature.afterCreate(controller);
+
+            feature.beforeStart(controller);
+
+            // start consuming events from the external topic
+            externalSource.register(controller);
+
+            feature.afterStart(controller);
+        }
+
+        /**
+         * Stops the host's threads.
+         */
+        public void stop() {
+            feature.beforeStop(controller);
+            externalSource.unregister(controller);
+            feature.afterStop(controller);
+        }
+
+        /**
+         * Offers an event to the feature, before the policy controller handles it.
+         *
+         * @param protocol protocol
+         * @param topic2 topic
+         * @param event event
+         * @return {@code true} if the event was handled, {@code false} otherwise
+         */
+        public boolean beforeOffer(CommInfrastructure protocol, String topic2, String event) {
+            return feature.beforeOffer(controller, protocol, topic2, event);
+        }
+
+        /**
+         * Offers an event to the feature, after the policy controller handles it.
+         *
+         * @param protocol protocol
+         * @param topic topic
+         * @param event event
+         * @param success success
+         * @return {@code true} if the event was handled, {@code false} otherwise
+         */
+        public boolean afterOffer(CommInfrastructure protocol, String topic, String event, boolean success) {
+
+            return feature.afterOffer(controller, protocol, topic, event, success);
+        }
+
+        /**
+         * Offers an event to the feature, before the drools controller handles it.
+         *
+         * @param fact fact
+         * @return {@code true} if the event was handled, {@code false} otherwise
+         */
+        public boolean beforeInsert(Object fact) {
+            return feature.beforeInsert(drools, fact);
+        }
+
+        /**
+         * Offers an event to the feature, after the drools controller handles it.
+         *
+         * @param fact fact
+         * @param successInsert {@code true} if it was successfully inserted by the drools
+         *        controller, {@code false} otherwise
+         * @return {@code true} if the event was handled, {@code false} otherwise
+         */
+        public boolean afterInsert(Object fact, boolean successInsert) {
+            return feature.afterInsert(drools, fact, successInsert);
+        }
+
+        /**
+         * Indicates that a message was seen for this host.
+         */
+        public void sawMessage() {
+            sawMsg.set(true);
+        }
+
+        /**
+         * Message seen.
+         *
+         * @return {@code true} if a message was seen for this host, {@code false} otherwise
+         */
+        public boolean messageSeen() {
+            return sawMsg.get();
+        }
+    }
+
+    /**
+     * Listener for the external topic. Simulates the actions taken by
+     * <i>AggregatedPolicyController.onTopicEvent</i>.
+     */
+    private static class MyExternalTopicListener implements Answer<Void> {
+
+        private final Context context;
+        private final Host host;
+
+        public MyExternalTopicListener(Context context, Host host) {
+            this.context = context;
+            this.host = host;
+        }
+
+        @Override
+        public Void answer(InvocationOnMock args) throws Throwable {
+            int index = 0;
+            CommInfrastructure commType = args.getArgument(index++);
+            String topic = args.getArgument(index++);
+            String event = args.getArgument(index++);
+
+            if (host.beforeOffer(commType, topic, event)) {
+                return null;
+            }
+
+            boolean result;
+            Object fact = decodeEvent(event);
+
+            if (fact == null) {
+                result = false;
+                context.bumpDecodeErrors();
+
+            } else {
+                result = true;
+
+                if (!host.beforeInsert(fact)) {
+                    // feature did not handle it so we handle it here
+                    host.afterInsert(fact, result);
+
+                    host.sawMessage();
+                    context.addEvent();
+                }
+            }
+
+            host.afterOffer(commType, topic, event, result);
+            return null;
+        }
+    }
+
+    /**
+     * Feature with overrides.
+     */
+    private static class PoolingFeatureImpl extends PoolingFeature {
+
+        private final Context context;
+        private final Host host;
+
+        /**
+         * Constructor.
+         *
+         * @param context context
+         */
+        public PoolingFeatureImpl(Context context, Host host) {
+            this.context = context;
+            this.host = host;
+
+            /*
+             * Note: do NOT extract anything from "context" at this point, because it hasn't been
+             * fully initialized yet
+             */
+        }
+
+        @Override
+        public Properties getProperties(String featName) {
+            Properties props = new Properties();
+
+            props.setProperty(PoolingProperties.PROP_EXTRACTOR_PREFIX + ".java.util.Map", "${reqid}");
+
+            props.setProperty(specialize(PoolingProperties.FEATURE_ENABLED, CONTROLLER1), "true");
+            props.setProperty(specialize(PoolingProperties.POOLING_TOPIC, CONTROLLER1), INTERNAL_TOPIC);
+            props.setProperty(specialize(PoolingProperties.OFFLINE_LIMIT, CONTROLLER1), "10000");
+            props.setProperty(specialize(PoolingProperties.OFFLINE_AGE_MS, CONTROLLER1), "1000000");
+            props.setProperty(specialize(PoolingProperties.OFFLINE_PUB_WAIT_MS, CONTROLLER1),
+                    "" + STD_OFFLINE_PUB_WAIT_MS);
+            props.setProperty(specialize(PoolingProperties.START_HEARTBEAT_MS, CONTROLLER1),
+                    "" + STD_START_HEARTBEAT_MS);
+            props.setProperty(specialize(PoolingProperties.REACTIVATE_MS, CONTROLLER1), "" + STD_REACTIVATE_WAIT_MS);
+            props.setProperty(specialize(PoolingProperties.IDENTIFICATION_MS, CONTROLLER1), "" + STD_IDENTIFICATION_MS);
+            props.setProperty(specialize(PoolingProperties.ACTIVE_HEARTBEAT_MS, CONTROLLER1),
+                    "" + STD_ACTIVE_HEARTBEAT_MS);
+            props.setProperty(specialize(PoolingProperties.INTER_HEARTBEAT_MS, CONTROLLER1),
+                    "" + STD_INTER_HEARTBEAT_MS);
+
+            props.putAll(makeSinkProperties(INTERNAL_TOPIC));
+            props.putAll(makeSourceProperties(INTERNAL_TOPIC));
+
+            return props;
+        }
+
+        @Override
+        public PolicyController getController(DroolsController droolsController) {
+            return context.getController(droolsController);
+        }
+
+        /**
+         * Embeds a specializer within a property name, after the prefix.
+         *
+         * @param propnm property name into which it should be embedded
+         * @param spec specializer to be embedded
+         * @return the property name, with the specializer embedded within it
+         */
+        private String specialize(String propnm, String spec) {
+            String suffix = propnm.substring(PREFIX.length());
+            return PREFIX + spec + "." + suffix;
+        }
+
+        @Override
+        protected PoolingManagerImpl makeManager(String hostName, PolicyController controller, PoolingProperties props,
+                        CountDownLatch activeLatch) {
+
+            /*
+             * Set this before creating the test, because the test's superclass
+             * constructor uses it before the test object has a chance to store it.
+             */
+            currentHost.set(host);
+
+            return new PoolingManagerTest(hostName, controller, props, activeLatch);
+        }
+    }
+
+    /**
+     * Pooling Manager with overrides.
+     */
+    private static class PoolingManagerTest extends PoolingManagerImpl {
+
+        /**
+         * Constructor.
+         *
+         * @param hostName the host
+         * @param controller the controller
+         * @param props the properties
+         * @param activeLatch the latch
+         */
+        public PoolingManagerTest(String hostName, PolicyController controller,
+                        PoolingProperties props, CountDownLatch activeLatch) {
+
+            super(hostName, controller, props, activeLatch);
+        }
+
+        @Override
+        protected TopicMessageManager makeTopicMessagesManager(String topic) throws PoolingFeatureException {
+            return new TopicMessageManagerImpl(topic);
+        }
+
+        @Override
+        protected boolean canDecodeEvent(DroolsController drools, String topic) {
+            return true;
+        }
+
+        @Override
+        protected Object decodeEventWrapper(DroolsController drools, String topic, String event) {
+            return decodeEvent(event);
+        }
+    }
+
+    /**
+     * DMaaP Manager with overrides.
+     */
+    private static class TopicMessageManagerImpl extends TopicMessageManager {
+
+        /**
+         * Constructor.
+         *
+         * @param topic the topic
+         * @throws PoolingFeatureException if an error occurs
+         */
+        public TopicMessageManagerImpl(String topic) throws PoolingFeatureException {
+            super(topic);
+        }
+
+        @Override
+        protected List<TopicSource> getTopicSources() {
+            Host host = currentHost.get();
+            return Arrays.asList(host.internalSource, host.externalSource);
+        }
+
+        @Override
+        protected List<TopicSink> getTopicSinks() {
+            return Arrays.asList(internalSink, externalSink);
+        }
+    }
+
+    /**
+     * Controller that also implements the {@link TopicListener} interface.
+     */
+    private static interface ListenerController extends PolicyController, TopicListener {
+
+    }
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/FeatureTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/FeatureTest.java
new file mode 100644
index 0000000..e9bd3cb
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/FeatureTest.java
@@ -0,0 +1,1033 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2020, 2024 Nordix Foundation
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.onap.policy.drools.pooling.PoolingProperties.PREFIX;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+import java.util.Deque;
+import java.util.IdentityHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Properties;
+import java.util.TreeMap;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import lombok.Getter;
+import org.apache.commons.lang3.tuple.Pair;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+import org.onap.policy.common.endpoints.event.comm.Topic;
+import org.onap.policy.common.endpoints.event.comm.Topic.CommInfrastructure;
+import org.onap.policy.common.endpoints.event.comm.TopicListener;
+import org.onap.policy.common.endpoints.event.comm.TopicSink;
+import org.onap.policy.common.endpoints.event.comm.TopicSource;
+import org.onap.policy.drools.controller.DroolsController;
+import org.onap.policy.drools.system.PolicyController;
+import org.onap.policy.drools.system.PolicyEngine;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * End-to-end tests of the pooling feature. Launches one or more "hosts", each one having
+ * its own feature object. Uses real feature objects. However, the following are not:
+ * <dl>
+ * <dt>DMaaP sources and sinks</dt>
+ * <dd>simulated using queues. There is one queue for the external topic, and one queue
+ * for each host's internal topic. Messages published to the "admin" channel are simply
+ * sent to all of the hosts' internal topic queues</dd>
+ * <dt>PolicyEngine, PolicyController, DroolsController</dt>
+ * <dd>mocked</dd>
+ * </dl>
+ *
+ * <p>Invoke {@link #runSlow()}, before the test, to slow things down.
+ */
+
+class FeatureTest {
+    private static final Logger logger = LoggerFactory.getLogger(FeatureTest.class);
+    /**
+     * Name of the topic used for inter-host communication.
+     */
+    private static final String INTERNAL_TOPIC = "my.internal.topic";
+    /**
+     * Name of the topic from which "external" events "arrive".
+     */
+    private static final String EXTERNAL_TOPIC = "my.external.topic";
+    /**
+     * Name of the controller.
+     */
+    private static final String CONTROLLER1 = "controller.one";
+    private static long stdReactivateWaitMs = 200;
+    private static long stdIdentificationMs = 60;
+    private static long stdStartHeartbeatMs = 60;
+    private static long stdActiveHeartbeatMs = 50;
+    private static long stdInterHeartbeatMs = 5;
+    private static long stdOfflinePubWaitMs = 2;
+    private static long stdPollMs = 2;
+    private static long stdInterPollMs = 2;
+    private static long stdEventWaitSec = 10;
+    /**
+     * Used to decode events from the external topic.
+     */
+    private static final Gson mapper = new Gson();
+    /**
+     * Used to identify the current context.
+     */
+    private static final ThreadLocal<Context> currentContext = new ThreadLocal<Context>();
+    /**
+     * Context for the current test case.
+     */
+    private Context ctx;
+
+    /**
+     * Setup.
+     */
+
+    @BeforeEach
+    public void setUp() {
+        ctx = null;
+    }
+
+    /**
+     * Tear down.
+     */
+
+    @AfterEach
+    public void tearDown() {
+        if (ctx != null) {
+            ctx.destroy();
+        }
+    }
+
+    @Test
+    void test_SingleHost() throws Exception {
+        run(70, 1);
+    }
+
+    @Test
+    void test_TwoHosts() throws Exception {
+        run(200, 2);
+    }
+
+    @Test
+    void test_ThreeHosts() throws Exception {
+        run(200, 3);
+    }
+
+    private void run(int nmessages, int nhosts) throws Exception {
+        ctx = new Context(nmessages);
+        for (int x = 0; x < nhosts; ++x) {
+            ctx.addHost();
+        }
+        ctx.startHosts();
+        for (int x = 0; x < nmessages; ++x) {
+            ctx.offerExternal(makeMessage(x));
+        }
+        ctx.awaitEvents(stdEventWaitSec, TimeUnit.SECONDS);
+        assertEquals(0, ctx.getDecodeErrors());
+        assertEquals(0, ctx.getRemainingEvents());
+        ctx.checkAllSawAMsg();
+    }
+
+    private String makeMessage(int reqnum) {
+        return "{\"reqid\":\"req" + reqnum + "\", \"data\":\"hello " + reqnum + "\"}";
+    }
+
+    /**
+     * Invoke this to slow the timers down.
+     */
+
+    protected static void runSlow() {
+        stdReactivateWaitMs = 10000;
+        stdIdentificationMs = 10000;
+        stdStartHeartbeatMs = 15000;
+        stdActiveHeartbeatMs = 12000;
+        stdInterHeartbeatMs = 5000;
+        stdOfflinePubWaitMs = 2;
+        stdPollMs = 2;
+        stdInterPollMs = 2000;
+        stdEventWaitSec = 1000;
+    }
+
+    /**
+     * Decodes an event.
+     *
+     * @param event event
+     * @return the decoded event, or {@code null} if it cannot be decoded
+     */
+
+    private static Object decodeEvent(String event) {
+        try {
+            return mapper.fromJson(event, TreeMap.class);
+        } catch (JsonParseException e) {
+            logger.warn("cannot decode external event", e);
+            return null;
+        }
+    }
+
+    /**
+     * Context used for a single test case.
+     */
+
+    private static class Context {
+        /**
+         * Hosts that have been added to this context.
+         */
+        private final Deque<Host> hosts = new LinkedList<>();
+        /**
+         * Maps a drools controller to its policy controller.
+         */
+        private final IdentityHashMap<DroolsController, PolicyController> drools2policy = new IdentityHashMap<>();
+        /**
+         * Maps a channel to its queue. Does <i>not</i> include the "admin" channel.
+         */
+        private final ConcurrentMap<String, BlockingQueue<String>> channel2queue = new ConcurrentHashMap<>(7);
+        /**
+         * Counts the number of decode errors.
+         */
+        private final AtomicInteger numDecodeErrors = new AtomicInteger(0);
+        /**
+         * Number of events we're still waiting to receive.
+         */
+        private final CountDownLatch eventCounter;
+
+        /**
+         * The current host. Set by {@link #withHost(Host, VoidFunction)} and used by
+         * {@link #getCurrentHost()}.
+         */
+        @Getter
+        private Host currentHost = null;
+
+        /**
+         * Constructor.
+         *
+         * @param events number of events to be processed
+         */
+
+        public Context(int events) {
+            eventCounter = new CountDownLatch(events);
+        }
+
+        /**
+         * Destroys the context, stopping any hosts that remain.
+         */
+
+        public void destroy() {
+            stopHosts();
+            hosts.clear();
+        }
+
+        /**
+         * Creates and adds a new host to the context.
+         *
+         * @return the new Host
+         */
+
+        public Host addHost() {
+            Host host = new Host(this);
+            hosts.add(host);
+            return host;
+        }
+
+        /**
+         * Starts the hosts.
+         */
+
+        public void startHosts() {
+            hosts.forEach(host -> host.start());
+        }
+
+        /**
+         * Stops the hosts.
+         */
+
+        public void stopHosts() {
+            hosts.forEach(host -> host.stop());
+        }
+
+        /**
+         * Verifies that all hosts processed at least one message.
+         */
+
+        public void checkAllSawAMsg() {
+            int msgs = 0;
+            for (Host host : hosts) {
+                assertTrue(host.messageSeen(), "msgs=" + msgs);
+                ++msgs;
+            }
+        }
+
+        /**
+         * Sets {@link #currentHost} to the specified host, and then invokes the given
+         * function. Resets {@link #currentHost} to {@code null} before returning.
+         *
+         * @param host host
+         * @param func function to invoke
+         */
+
+        public void withHost(Host host, VoidFunction func) {
+            currentHost = host;
+            func.apply();
+            currentHost = null;
+        }
+
+        /**
+         * Offers an event to the external topic. As each host needs a copy, it is posted
+         * to each Host's queue.
+         *
+         * @param event event
+         */
+
+        public void offerExternal(String event) {
+            for (Host host : hosts) {
+                host.getExternalTopic().offer(event);
+            }
+        }
+
+        /**
+         * Adds an internal channel to the set of channels.
+         *
+         * @param channel channel
+         * @param queue   the channel's queue
+         */
+
+        public void addInternal(String channel, BlockingQueue<String> queue) {
+            channel2queue.put(channel, queue);
+        }
+
+        /**
+         * Offers a message to all internal channels.
+         *
+         * @param message message
+         */
+
+        public void offerInternal(String message) {
+            channel2queue.values().forEach(queue -> queue.offer(message));
+        }
+
+        /**
+         * Associates a controller with its drools controller.
+         *
+         * @param controller       controller
+         * @param droolsController drools controller
+         */
+
+        public void addController(PolicyController controller, DroolsController droolsController) {
+            drools2policy.put(droolsController, controller);
+        }
+
+        /**
+         * Get controller.
+         *
+         * @param droolsController drools controller
+         * @return the controller associated with a drools controller, or {@code null} if
+         *         it has no associated controller
+         */
+
+        public PolicyController getController(DroolsController droolsController) {
+            return drools2policy.get(droolsController);
+        }
+
+        /**
+         * Get decode errors.
+         *
+         * @return the number of decode errors so far
+         */
+
+        public int getDecodeErrors() {
+            return numDecodeErrors.get();
+        }
+
+        /**
+         * Increments the count of decode errors.
+         */
+
+        public void bumpDecodeErrors() {
+            numDecodeErrors.incrementAndGet();
+        }
+
+        /**
+         * Get remaining events.
+         *
+         * @return the number of events that haven't been processed
+         */
+
+        public long getRemainingEvents() {
+            return eventCounter.getCount();
+        }
+
+        /**
+         * Adds an event to the counter.
+         */
+
+        public void addEvent() {
+            eventCounter.countDown();
+        }
+
+        /**
+         * Waits, for a period of time, for all events to be processed.
+         *
+         * @param time  time
+         * @param units units
+         * @return {@code true} if all events have been processed, {@code false} otherwise
+         * @throws InterruptedException throws interrupted
+         */
+
+        public boolean awaitEvents(long time, TimeUnit units) throws InterruptedException {
+            return eventCounter.await(time, units);
+        }
+
+    }
+
+    /**
+     * Simulates a single "host".
+     */
+
+    private static class Host {
+        private final Context context;
+        private final PoolingFeature feature;
+
+        /**
+         * {@code True} if this host has processed a message, {@code false} otherwise.
+         */
+
+        private final AtomicBoolean sawMsg = new AtomicBoolean(false);
+
+        /**
+         * This host's internal "DMaaP" topic.
+         */
+
+        private final BlockingQueue<String> msgQueue = new LinkedBlockingQueue<>();
+
+        /**
+         * Queue for the external "DMaaP" topic.
+         */
+        @Getter
+        private final BlockingQueue<String> externalTopic = new LinkedBlockingQueue<String>();
+
+        /**
+         * Source that reads from the external topic and posts to the listener.
+         */
+
+        private TopicSource externalSource;
+
+        // mock objects
+        private final PolicyEngine engine = mock(PolicyEngine.class);
+        private final ListenerController controller = mock(ListenerController.class);
+        private final DroolsController drools = mock(DroolsController.class);
+
+        /**
+         * Constructor.
+         *
+         * @param context context
+         */
+
+        public Host(Context context) {
+            this.context = context;
+            when(controller.getName()).thenReturn(CONTROLLER1);
+            when(controller.getDrools()).thenReturn(drools);
+            // stop consuming events if the controller stops
+            when(controller.stop()).thenAnswer(args -> {
+                externalSource.unregister(controller);
+                return true;
+            });
+            doAnswer(new MyExternalTopicListener(context, this)).when(controller).onTopicEvent(any(), any(), any());
+            context.addController(controller, drools);
+            // arrange to read from the external topic
+            externalSource = new TopicSourceImpl(EXTERNAL_TOPIC, externalTopic);
+            feature = new PoolingFeatureImpl(context);
+        }
+
+        /**
+         * Get name.
+         *
+         * @return the host name
+         */
+
+        public String getName() {
+            return feature.getHost();
+        }
+
+        /**
+         * Starts threads for the host so that it begins consuming from both the external
+         * "DMaaP" topic and its own internal "DMaaP" topic.
+         */
+
+        public void start() {
+            context.withHost(this, () -> {
+                feature.beforeStart(engine);
+                feature.afterCreate(controller);
+                // assign the queue for this host's internal topic
+                context.addInternal(getName(), msgQueue);
+                feature.beforeStart(controller);
+                // start consuming events from the external topic
+                externalSource.register(controller);
+                feature.afterStart(controller);
+            });
+        }
+
+        /**
+         * Stops the host's threads.
+         */
+
+        public void stop() {
+            feature.beforeStop(controller);
+            externalSource.unregister(controller);
+            feature.afterStop(controller);
+        }
+
+        /**
+         * Offers an event to the feature, before the policy controller handles it.
+         *
+         * @param protocol protocol
+         * @param topic2   topic
+         * @param event    event
+         * @return {@code true} if the event was handled, {@code false} otherwise
+         */
+
+        public boolean beforeOffer(CommInfrastructure protocol, String topic2, String event) {
+            return feature.beforeOffer(controller, protocol, topic2, event);
+        }
+
+        /**
+         * Offers an event to the feature, after the policy controller handles it.
+         *
+         * @param protocol protocol
+         * @param topic    topic
+         * @param event    event
+         * @param success  success
+         * @return {@code true} if the event was handled, {@code false} otherwise
+         */
+
+        public boolean afterOffer(CommInfrastructure protocol, String topic, String event, boolean success) {
+            return feature.afterOffer(controller, protocol, topic, event, success);
+        }
+
+        /**
+         * Offers an event to the feature, before the drools controller handles it.
+         *
+         * @param fact fact
+         * @return {@code true} if the event was handled, {@code false} otherwise
+         */
+
+        public boolean beforeInsert(Object fact) {
+            return feature.beforeInsert(drools, fact);
+        }
+
+        /**
+         * Offers an event to the feature, after the drools controller handles it.
+         *
+         * @param fact          fact
+         * @param successInsert {@code true} if it was successfully inserted by the drools
+         *                      controller, {@code false} otherwise
+         * @return {@code true} if the event was handled, {@code false} otherwise
+         */
+
+        public boolean afterInsert(Object fact, boolean successInsert) {
+            return feature.afterInsert(drools, fact, successInsert);
+        }
+
+        /**
+         * Indicates that a message was seen for this host.
+         */
+
+        public void sawMessage() {
+            sawMsg.set(true);
+        }
+
+        /**
+         * Message seen.
+         *
+         * @return {@code true} if a message was seen for this host, {@code false} otherwise
+         */
+
+        public boolean messageSeen() {
+            return sawMsg.get();
+        }
+
+        /**
+         * Get internal queue.
+         *
+         * @return the queue associated with this host's internal topic
+         */
+
+        public BlockingQueue<String> getInternalQueue() {
+            return msgQueue;
+        }
+    }
+
+    /**
+     * Listener for the external topic. Simulates the actions taken by
+     * <i>AggregatedPolicyController.onTopicEvent</i>.
+     */
+
+    private static class MyExternalTopicListener implements Answer<Void> {
+        private final Context context;
+        private final Host host;
+
+        public MyExternalTopicListener(Context context, Host host) {
+            this.context = context;
+            this.host = host;
+        }
+
+        @Override
+        public Void answer(InvocationOnMock args) throws Throwable {
+            int index = 0;
+            CommInfrastructure commType = args.getArgument(index++);
+            String topic = args.getArgument(index++);
+            String event = args.getArgument(index++);
+            if (host.beforeOffer(commType, topic, event)) {
+                return null;
+            }
+            boolean result;
+            Object fact = decodeEvent(event);
+            if (fact == null) {
+                result = false;
+                context.bumpDecodeErrors();
+            } else {
+                result = true;
+                if (!host.beforeInsert(fact)) {
+                    // feature did not handle it so we handle it here
+                    host.afterInsert(fact, result);
+                    host.sawMessage();
+                    context.addEvent();
+                }
+            }
+            host.afterOffer(commType, topic, event, result);
+            return null;
+        }
+    }
+
+    /**
+     * Sink implementation that puts a message on the queue specified by the
+     * <i>channel</i> embedded within the message. If it's the "admin" channel, then the
+     * message is placed on all queues.
+     */
+
+    private static class TopicSinkImpl extends TopicImpl implements TopicSink {
+        private final Context context;
+
+        /**
+         * Constructor.
+         *
+         * @param context context
+         */
+
+        public TopicSinkImpl(Context context) {
+            this.context = context;
+        }
+
+        @Override
+        public synchronized boolean send(String message) {
+            if (!isAlive()) {
+                return false;
+            }
+            try {
+                context.offerInternal(message);
+                return true;
+            } catch (JsonParseException e) {
+                logger.warn("could not decode message: {}", message);
+                context.bumpDecodeErrors();
+                return false;
+            }
+        }
+    }
+
+    /**
+     * Source implementation that reads from a queue associated with a topic.
+     */
+
+    private static class TopicSourceImpl extends TopicImpl implements TopicSource {
+
+        private final String topic;
+        /**
+         * Queue from which to retrieve messages.
+         */
+        private final BlockingQueue<String> queue;
+        /**
+         * Manages the current consumer thread. The "first" item is used as a trigger to
+         * tell the thread to stop processing, while the "second" item is triggered <i>by
+         * the thread</i> when it completes.
+         */
+        private AtomicReference<Pair<CountDownLatch, CountDownLatch>> pair = new AtomicReference<>(null);
+
+        /**
+         * Constructor.
+         *
+         * @param type  topic type
+         * @param queue topic from which to read
+         */
+
+        public TopicSourceImpl(String type, BlockingQueue<String> queue) {
+            this.topic = type;
+            this.queue = queue;
+        }
+
+        @Override
+        public String getTopic() {
+            return topic;
+        }
+
+        @Override
+        public boolean offer(String event) {
+            throw new UnsupportedOperationException("offer topic source");
+        }
+
+        /**
+         * Starts a thread that takes messages from the queue and gives them to the
+         * listener. Stops the thread of any previously registered listener.
+         */
+
+        @Override
+        public void register(TopicListener listener) {
+            Pair<CountDownLatch, CountDownLatch> newPair = Pair.of(new CountDownLatch(1), new CountDownLatch(1));
+            reregister(newPair);
+            Thread thread = new Thread(() -> {
+                try {
+                    do {
+                        processMessages(newPair.getLeft(), listener);
+                    } while (!newPair.getLeft().await(stdInterPollMs, TimeUnit.MILLISECONDS));
+                    logger.info("topic source thread completed");
+                } catch (InterruptedException e) {
+                    logger.warn("topic source thread aborted", e);
+                    Thread.currentThread().interrupt();
+                } catch (RuntimeException e) {
+                    logger.warn("topic source thread aborted", e);
+                }
+                newPair.getRight().countDown();
+            });
+            thread.setDaemon(true);
+            thread.start();
+        }
+
+        /**
+         * Stops the thread of <i>any</i> currently registered listener.
+         */
+
+        @Override
+        public void unregister(TopicListener listener) {
+            reregister(null);
+        }
+
+        /**
+         * Registers a new "pair" with this source, stopping the consumer associated with
+         * any previous registration.
+         *
+         * @param newPair the new "pair", or {@code null} to unregister
+         */
+
+        private void reregister(Pair<CountDownLatch, CountDownLatch> newPair) {
+            try {
+                Pair<CountDownLatch, CountDownLatch> oldPair = pair.getAndSet(newPair);
+                if (oldPair == null) {
+                    if (newPair == null) {
+                        // unregister was invoked twice in a row
+                        logger.warn("re-unregister for topic source");
+                    }
+                    // no previous thread to stop
+                    return;
+                }
+                // need to stop the previous thread
+                // tell it to stop
+                oldPair.getLeft().countDown();
+                // wait for it to stop
+                if (!oldPair.getRight().await(2, TimeUnit.SECONDS)) {
+                    logger.warn("old topic registration is still running");
+                }
+            } catch (InterruptedException e) {
+                logger.warn("old topic registration may still be running", e);
+                Thread.currentThread().interrupt();
+            }
+            if (newPair != null) {
+                // register was invoked twice in a row
+                logger.warn("re-register for topic source");
+            }
+        }
+
+        /**
+         * Polls for messages from the topic and offers them to the listener.
+         *
+         * @param stopped  triggered if processing should stop
+         * @param listener listener
+         * @throws InterruptedException throws interrupted exception
+         */
+
+        private void processMessages(CountDownLatch stopped, TopicListener listener) throws InterruptedException {
+            for (int x = 0; x < 5 && stopped.getCount() > 0; ++x) {
+                String msg = queue.poll(stdPollMs, TimeUnit.MILLISECONDS);
+                if (msg == null) {
+                    return;
+                }
+                listener.onTopicEvent(CommInfrastructure.UEB, topic, msg);
+            }
+        }
+    }
+
+    /**
+     * Topic implementation. Most methods just throw
+     * {@link UnsupportedOperationException}.
+     */
+
+    private static class TopicImpl implements Topic {
+
+        /**
+         * Constructor.
+         */
+
+        public TopicImpl() {
+            super();
+        }
+
+        @Override
+        public String getTopic() {
+            return INTERNAL_TOPIC;
+        }
+
+        @Override
+        public String getEffectiveTopic() {
+            return INTERNAL_TOPIC;
+        }
+
+        @Override
+        public CommInfrastructure getTopicCommInfrastructure() {
+            throw new UnsupportedOperationException("topic protocol");
+        }
+
+        @Override
+        public List<String> getServers() {
+            throw new UnsupportedOperationException("topic servers");
+        }
+
+        @Override
+        public String[] getRecentEvents() {
+            throw new UnsupportedOperationException("topic events");
+        }
+
+        @Override
+        public void register(TopicListener topicListener) {
+            throw new UnsupportedOperationException("register topic");
+        }
+
+        @Override
+        public void unregister(TopicListener topicListener) {
+            throw new UnsupportedOperationException("unregister topic");
+        }
+
+        @Override
+        public synchronized boolean start() {
+            return true;
+        }
+
+        @Override
+        public synchronized boolean stop() {
+            return true;
+        }
+
+        @Override
+        public synchronized void shutdown() {
+            // do nothing
+        }
+
+        @Override
+        public synchronized boolean isAlive() {
+            return true;
+        }
+
+        @Override
+        public boolean lock() {
+            throw new UnsupportedOperationException("lock topicink");
+        }
+
+        @Override
+        public boolean unlock() {
+            throw new UnsupportedOperationException("unlock topic");
+        }
+
+        @Override
+        public boolean isLocked() {
+            throw new UnsupportedOperationException("topic isLocked");
+        }
+    }
+
+    /**
+     * Feature with overrides.
+     */
+
+    private static class PoolingFeatureImpl extends PoolingFeature {
+        private final Context context;
+
+        /**
+         * Constructor.
+         *
+         * @param context context
+         */
+
+        public PoolingFeatureImpl(Context context) {
+            this.context = context;
+            /*
+             * Note: do NOT extract anything from "context" at this point, because it
+             * hasn't been fully initialized yet
+             */
+        }
+
+        @Override
+        public Properties getProperties(String featName) {
+            Properties props = new Properties();
+            props.setProperty(PoolingProperties.PROP_EXTRACTOR_PREFIX + ".java.util.Map", "${reqid}");
+            props.setProperty(specialize(PoolingProperties.FEATURE_ENABLED, CONTROLLER1), "true");
+            props.setProperty(specialize(PoolingProperties.POOLING_TOPIC, CONTROLLER1), INTERNAL_TOPIC);
+            props.setProperty(specialize(PoolingProperties.OFFLINE_LIMIT, CONTROLLER1), "10000");
+            props.setProperty(specialize(PoolingProperties.OFFLINE_AGE_MS, CONTROLLER1), "1000000");
+            props.setProperty(specialize(PoolingProperties.OFFLINE_PUB_WAIT_MS, CONTROLLER1), "" + stdOfflinePubWaitMs);
+            props.setProperty(specialize(PoolingProperties.START_HEARTBEAT_MS, CONTROLLER1), "" + stdStartHeartbeatMs);
+            props.setProperty(specialize(PoolingProperties.REACTIVATE_MS, CONTROLLER1), "" + stdReactivateWaitMs);
+            props.setProperty(specialize(PoolingProperties.IDENTIFICATION_MS, CONTROLLER1), "" + stdIdentificationMs);
+            props.setProperty(specialize(PoolingProperties.ACTIVE_HEARTBEAT_MS, CONTROLLER1),
+                "" + stdActiveHeartbeatMs);
+            props.setProperty(specialize(PoolingProperties.INTER_HEARTBEAT_MS, CONTROLLER1), "" + stdInterHeartbeatMs);
+            return props;
+        }
+
+        @Override
+        public PolicyController getController(DroolsController droolsController) {
+            return context.getController(droolsController);
+        }
+
+        /**
+         * Embeds a specializer within a property name, after the prefix.
+         *
+         * @param propnm property name into which it should be embedded
+         * @param spec   specializer to be embedded
+         * @return the property name, with the specializer embedded within it
+         */
+
+        private String specialize(String propnm, String spec) {
+            String suffix = propnm.substring(PREFIX.length());
+            return PREFIX + spec + "." + suffix;
+        }
+
+        @Override
+        protected PoolingManagerImpl makeManager(String host, PolicyController controller, PoolingProperties props,
+                                                 CountDownLatch activeLatch) {
+            currentContext.set(context);
+            return new PoolingManagerTest(host, controller, props, activeLatch);
+        }
+    }
+
+    /**
+     * Pooling Manager with overrides.
+     */
+
+    private static class PoolingManagerTest extends PoolingManagerImpl {
+
+        /**
+         * Constructor.
+         *
+         * @param host        the host
+         * @param controller  the controller
+         * @param props       the properties
+         * @param activeLatch the latch
+         */
+
+        public PoolingManagerTest(String host, PolicyController controller, PoolingProperties props,
+                                  CountDownLatch activeLatch) {
+            super(host, controller, props, activeLatch);
+        }
+
+        @Override
+        protected TopicMessageManager makeTopicMessagesManager(String topic) throws PoolingFeatureException {
+            return new TopicMessageManagerImpl(topic);
+        }
+
+        @Override
+        protected boolean canDecodeEvent(DroolsController drools, String topic) {
+            return true;
+        }
+
+        @Override
+        protected Object decodeEventWrapper(DroolsController drools, String topic, String event) {
+            return decodeEvent(event);
+        }
+    }
+
+    /**
+     * DMaaP Manager with overrides.
+     */
+
+    private static class TopicMessageManagerImpl extends TopicMessageManager {
+
+        /**
+         * Constructor.
+         *
+         * @param topic the topic
+         * @throws PoolingFeatureException if an error occurs
+         */
+
+        public TopicMessageManagerImpl(String topic) throws PoolingFeatureException {
+            super(topic);
+        }
+
+        @Override
+        protected List<TopicSource> getTopicSources() {
+            return List.of(
+                new TopicSourceImpl(INTERNAL_TOPIC, currentContext.get().getCurrentHost().getInternalQueue()));
+        }
+
+        @Override
+        protected List<TopicSink> getTopicSinks() {
+            return List.of(new TopicSinkImpl(currentContext.get()));
+        }
+    }
+
+    /**
+     * Controller that also implements the {@link TopicListener} interface.
+     */
+
+    private static interface ListenerController extends PolicyController, TopicListener {
+    }
+
+    /**
+     * Simple function that takes no arguments and returns nothing.
+     */
+
+    @FunctionalInterface
+    private static interface VoidFunction {
+        void apply();
+    }
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/PoolingFeatureExceptionTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/PoolingFeatureExceptionTest.java
new file mode 100644
index 0000000..1bfba19
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/PoolingFeatureExceptionTest.java
@@ -0,0 +1,36 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import org.onap.policy.common.utils.test.ExceptionsTester;
+
+class PoolingFeatureExceptionTest extends ExceptionsTester {
+
+    @Test
+    void test() {
+        assertEquals(5, test(PoolingFeatureException.class));
+    }
+
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/PoolingFeatureRtExceptionTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/PoolingFeatureRtExceptionTest.java
new file mode 100644
index 0000000..a4305a7
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/PoolingFeatureRtExceptionTest.java
@@ -0,0 +1,36 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import org.onap.policy.common.utils.test.ExceptionsTester;
+
+class PoolingFeatureRtExceptionTest extends ExceptionsTester {
+
+    @Test
+    void test() {
+        assertEquals(5, test(PoolingFeatureRtException.class));
+    }
+
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/PoolingFeatureTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/PoolingFeatureTest.java
new file mode 100644
index 0000000..1b05e02
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/PoolingFeatureTest.java
@@ -0,0 +1,548 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2020 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2020, 2024 Nordix Foundation
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CountDownLatch;
+import org.apache.commons.lang3.tuple.Pair;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.onap.policy.common.endpoints.event.comm.Topic.CommInfrastructure;
+import org.onap.policy.common.endpoints.event.comm.TopicSink;
+import org.onap.policy.common.endpoints.event.comm.TopicSource;
+import org.onap.policy.drools.controller.DroolsController;
+import org.onap.policy.drools.system.PolicyController;
+import org.onap.policy.drools.system.PolicyEngine;
+
+class PoolingFeatureTest {
+
+    private static final String CONTROLLER1 = "controllerA";
+    private static final String CONTROLLER2 = "controllerB";
+    private static final String CONTROLLER_DISABLED = "controllerDisabled";
+    private static final String CONTROLLER_EX = "controllerException";
+    private static final String CONTROLLER_UNKNOWN = "controllerUnknown";
+
+    private static final String TOPIC1 = "topic.one";
+    private static final String TOPIC2 = "topic.two";
+
+    private static final String EVENT1 = "event.one";
+    private static final String EVENT2 = "event.two";
+
+    private static final Object OBJECT1 = new Object();
+    private static final Object OBJECT2 = new Object();
+
+    private Properties props;
+    private PolicyEngine engine;
+    private PolicyController controller1;
+    private PolicyController controller2;
+    private PolicyController controllerDisabled;
+    private PolicyController controllerException;
+    private PolicyController controllerUnknown;
+    private DroolsController drools1;
+    private DroolsController drools2;
+    private DroolsController droolsDisabled;
+    private List<Pair<PoolingManagerImpl, PoolingProperties>> managers;
+    private PoolingManagerImpl mgr1;
+    private PoolingManagerImpl mgr2;
+
+    private PoolingFeature pool;
+
+    /**
+     * Setup.
+     *
+     * @throws Exception exception
+     */
+    @BeforeEach
+    public void setUp() throws Exception {
+        props = initProperties();
+        engine = mock(PolicyEngine.class);
+        controller1 = mock(PolicyController.class);
+        controller2 = mock(PolicyController.class);
+        controllerDisabled = mock(PolicyController.class);
+        controllerException = mock(PolicyController.class);
+        controllerUnknown = mock(PolicyController.class);
+        drools1 = mock(DroolsController.class);
+        drools2 = mock(DroolsController.class);
+        droolsDisabled = mock(DroolsController.class);
+        managers = new LinkedList<>();
+
+        when(controller1.getName()).thenReturn(CONTROLLER1);
+        when(controller2.getName()).thenReturn(CONTROLLER2);
+        when(controllerDisabled.getName()).thenReturn(CONTROLLER_DISABLED);
+        when(controllerException.getName()).thenReturn(CONTROLLER_EX);
+        when(controllerUnknown.getName()).thenReturn(CONTROLLER_UNKNOWN);
+
+        pool = new PoolingFeatureImpl();
+
+        pool.beforeStart(engine);
+
+        pool.afterCreate(controller1);
+        pool.afterCreate(controller2);
+
+        mgr1 = managers.get(0).getLeft();
+        mgr2 = managers.get(1).getLeft();
+    }
+
+    @Test
+    void test() {
+        assertEquals(2, managers.size());
+    }
+
+    @Test
+    void testGetHost() {
+        String host = pool.getHost();
+        assertNotNull(host);
+
+        // create another and ensure it generates another host name
+        pool = new PoolingFeatureImpl();
+        String host2 = pool.getHost();
+        assertNotNull(host2);
+
+        assertNotEquals(host, host2);
+    }
+
+    @Test
+    void testGetSequenceNumber() {
+        assertEquals(0, pool.getSequenceNumber());
+    }
+
+    @Test
+    void testBeforeStartEngine() {
+        pool = new PoolingFeatureImpl();
+
+        assertFalse(pool.beforeStart(engine));
+    }
+
+    @Test
+    void testAfterCreate() {
+        managers.clear();
+        pool = new PoolingFeatureImpl();
+        pool.beforeStart(engine);
+
+        assertFalse(pool.afterCreate(controller1));
+        assertEquals(1, managers.size());
+
+        // duplicate
+        assertFalse(pool.afterCreate(controller1));
+        assertEquals(1, managers.size());
+
+        // second controller
+        assertFalse(pool.afterCreate(controller2));
+        assertEquals(2, managers.size());
+    }
+
+    @Test
+    void testAfterCreate_NotEnabled() {
+        managers.clear();
+        pool = new PoolingFeatureImpl();
+        pool.beforeStart(engine);
+
+        assertFalse(pool.afterCreate(controllerDisabled));
+        assertTrue(managers.isEmpty());
+    }
+
+    @Test
+    void testAfterCreate_PropertyEx() {
+        managers.clear();
+        pool = new PoolingFeatureImpl();
+        pool.beforeStart(engine);
+
+        assertThrows(PoolingFeatureRtException.class, () -> pool.afterCreate(controllerException));
+    }
+
+    @Test
+    void testAfterCreate_NoProps() {
+        pool = new PoolingFeatureImpl();
+
+        // did not perform globalInit, which is an error
+
+        assertThrows(PoolingFeatureRtException.class, () -> pool.afterCreate(controller1));
+    }
+
+    @Test
+    void testAfterCreate_NoFeatProps() {
+        managers.clear();
+        pool = new PoolingFeatureImpl();
+        pool.beforeStart(engine);
+
+        assertFalse(pool.afterCreate(controllerUnknown));
+        assertTrue(managers.isEmpty());
+    }
+
+    @Test
+    void testBeforeStart() throws Exception {
+        assertFalse(pool.beforeStart(controller1));
+        verify(mgr1).beforeStart();
+
+        // ensure it's still in the map by re-invoking
+        assertFalse(pool.beforeStart(controller1));
+        verify(mgr1, times(2)).beforeStart();
+
+        assertFalse(pool.beforeStart(controllerDisabled));
+    }
+
+    @Test
+    void testAfterStart() {
+        assertFalse(pool.afterStart(controller1));
+        verify(mgr1).afterStart();
+
+        // ensure it's still in the map by re-invoking
+        assertFalse(pool.afterStart(controller1));
+        verify(mgr1, times(2)).afterStart();
+
+        assertFalse(pool.afterStart(controllerDisabled));
+    }
+
+    @Test
+    void testBeforeStop() {
+        assertFalse(pool.beforeStop(controller1));
+        verify(mgr1).beforeStop();
+
+        // ensure it's still in the map by re-invoking
+        assertFalse(pool.beforeStop(controller1));
+        verify(mgr1, times(2)).beforeStop();
+
+        assertFalse(pool.beforeStop(controllerDisabled));
+    }
+
+    @Test
+    void testAfterStop() {
+        assertFalse(pool.afterStop(controller1));
+        verify(mgr1).afterStop();
+
+        assertFalse(pool.afterStop(controllerDisabled));
+
+        // count should be unchanged
+        verify(mgr1).afterStop();
+    }
+
+    @Test
+    void testAfterHalt() {
+        assertFalse(pool.afterHalt(controller1));
+        assertFalse(pool.afterHalt(controller1));
+
+        verify(mgr1, never()).afterStop();
+
+        assertFalse(pool.afterStop(controllerDisabled));
+    }
+
+    @Test
+    void testAfterShutdown() {
+        assertFalse(pool.afterShutdown(controller1));
+        assertFalse(pool.afterShutdown(controller1));
+
+        verify(mgr1, never()).afterStop();
+
+        assertFalse(pool.afterStop(controllerDisabled));
+    }
+
+    @Test
+    void testBeforeLock() {
+        assertFalse(pool.beforeLock(controller1));
+        verify(mgr1).beforeLock();
+
+        // ensure it's still in the map by re-invoking
+        assertFalse(pool.beforeLock(controller1));
+        verify(mgr1, times(2)).beforeLock();
+
+        assertFalse(pool.beforeLock(controllerDisabled));
+    }
+
+    @Test
+    void testAfterUnlock() {
+        assertFalse(pool.afterUnlock(controller1));
+        verify(mgr1).afterUnlock();
+
+        // ensure it's still in the map by re-invoking
+        assertFalse(pool.afterUnlock(controller1));
+        verify(mgr1, times(2)).afterUnlock();
+
+        assertFalse(pool.afterUnlock(controllerDisabled));
+    }
+
+    @Test
+    void testBeforeOffer() {
+        assertFalse(pool.beforeOffer(controller1, CommInfrastructure.UEB, TOPIC1, EVENT1));
+        verify(mgr1).beforeOffer(TOPIC1, EVENT1);
+
+        // ensure that the args were captured
+        pool.beforeInsert(drools1, OBJECT1);
+        verify(mgr1).beforeInsert(TOPIC1, OBJECT1);
+
+
+        // ensure it's still in the map by re-invoking
+        assertFalse(pool.beforeOffer(controller1, CommInfrastructure.UEB, TOPIC2, EVENT2));
+        verify(mgr1).beforeOffer(TOPIC2, EVENT2);
+
+        // ensure that the new args were captured
+        pool.beforeInsert(drools1, OBJECT2);
+        verify(mgr1).beforeInsert(TOPIC2, OBJECT2);
+
+
+        assertFalse(pool.beforeOffer(controllerDisabled, CommInfrastructure.UEB, TOPIC1, EVENT1));
+    }
+
+    @Test
+    void testBeforeOffer_NotFound() {
+        assertFalse(pool.beforeOffer(controllerDisabled, CommInfrastructure.UEB, TOPIC1, EVENT1));
+    }
+
+    @Test
+    void testBeforeOffer_MgrTrue() {
+
+        // manager will return true
+        when(mgr1.beforeOffer(any(), any())).thenReturn(true);
+
+        assertTrue(pool.beforeOffer(controller1, CommInfrastructure.UEB, TOPIC1, EVENT1));
+        verify(mgr1).beforeOffer(TOPIC1, EVENT1);
+
+        // ensure it's still in the map by re-invoking
+        assertTrue(pool.beforeOffer(controller1, CommInfrastructure.UEB, TOPIC2, EVENT2));
+        verify(mgr1).beforeOffer(TOPIC2, EVENT2);
+
+        assertFalse(pool.beforeOffer(controllerDisabled, CommInfrastructure.UEB, TOPIC1, EVENT1));
+    }
+
+    @Test
+    void testBeforeInsert() {
+        pool.beforeOffer(controller1, CommInfrastructure.UEB, TOPIC1, EVENT1);
+        assertFalse(pool.beforeInsert(drools1, OBJECT1));
+        verify(mgr1).beforeInsert(TOPIC1, OBJECT1);
+
+        // ensure it's still in the map by re-invoking
+        pool.beforeOffer(controller1, CommInfrastructure.UEB, TOPIC2, EVENT2);
+        assertFalse(pool.beforeInsert(drools1, OBJECT2));
+        verify(mgr1).beforeInsert(TOPIC2, OBJECT2);
+
+        pool.beforeOffer(controllerDisabled, CommInfrastructure.UEB, TOPIC2, EVENT2);
+        assertFalse(pool.beforeInsert(droolsDisabled, OBJECT1));
+    }
+
+    @Test
+    void testBeforeInsert_NoArgs() {
+
+        // call beforeInsert without beforeOffer
+        assertFalse(pool.beforeInsert(drools1, OBJECT1));
+        verify(mgr1, never()).beforeInsert(any(), any());
+
+        assertFalse(pool.beforeInsert(droolsDisabled, OBJECT1));
+        verify(mgr1, never()).beforeInsert(any(), any());
+    }
+
+    @Test
+    void testBeforeInsert_ArgEx() {
+        // generate exception
+        pool = new PoolingFeatureImpl() {
+            @Override
+            protected PolicyController getController(DroolsController droolsController) {
+                throw new IllegalArgumentException();
+            }
+        };
+
+        pool.beforeOffer(controller1, CommInfrastructure.UEB, TOPIC1, EVENT1);
+        assertFalse(pool.beforeInsert(drools1, OBJECT1));
+        verify(mgr1, never()).beforeInsert(any(), any());
+    }
+
+    @Test
+    void testBeforeInsert_StateEx() {
+        // generate exception
+        pool = new PoolingFeatureImpl() {
+            @Override
+            protected PolicyController getController(DroolsController droolsController) {
+                throw new IllegalStateException();
+            }
+        };
+
+        pool.beforeOffer(controller1, CommInfrastructure.UEB, TOPIC1, EVENT1);
+        assertFalse(pool.beforeInsert(drools1, OBJECT1));
+        verify(mgr1, never()).beforeInsert(any(), any());
+    }
+
+    @Test
+    void testBeforeInsert_NullController() {
+
+        // return null controller
+        pool = new PoolingFeatureImpl() {
+            @Override
+            protected PolicyController getController(DroolsController droolsController) {
+                return null;
+            }
+        };
+
+        pool.beforeOffer(controller1, CommInfrastructure.UEB, TOPIC1, EVENT1);
+        assertFalse(pool.beforeInsert(drools1, OBJECT1));
+        verify(mgr1, never()).beforeInsert(any(), any());
+    }
+
+    @Test
+    void testBeforeInsert_NotFound() {
+
+        pool.beforeOffer(controllerDisabled, CommInfrastructure.UEB, TOPIC2, EVENT2);
+        assertFalse(pool.beforeInsert(droolsDisabled, OBJECT1));
+    }
+
+    @Test
+    void testAfterOffer() {
+        // this will create OfferArgs
+        pool.beforeOffer(controller1, CommInfrastructure.UEB, TOPIC1, EVENT1);
+
+        // this should clear them
+        assertFalse(pool.afterOffer(controller1, CommInfrastructure.UEB, TOPIC2, EVENT2, true));
+
+        assertFalse(pool.beforeInsert(drools1, OBJECT1));
+        verify(mgr1, never()).beforeInsert(any(), any());
+
+
+        assertFalse(pool.beforeInsert(droolsDisabled, OBJECT1));
+    }
+
+    @Test
+    void testDoManager() {
+        assertFalse(pool.beforeStart(controller1));
+        verify(mgr1).beforeStart();
+
+        // ensure it's still in the map by re-invoking
+        assertFalse(pool.beforeStart(controller1));
+        verify(mgr1, times(2)).beforeStart();
+
+
+        // different controller
+        assertFalse(pool.beforeStart(controller2));
+        verify(mgr2).beforeStart();
+
+        // ensure it's still in the map by re-invoking
+        assertFalse(pool.beforeStart(controller2));
+        verify(mgr2, times(2)).beforeStart();
+
+
+        assertFalse(pool.beforeStart(controllerDisabled));
+    }
+
+    @Test
+    void testDoManager_NotFound() {
+        assertFalse(pool.beforeStart(controllerDisabled));
+    }
+
+    @Test
+    void testDoManager_Ex() {
+
+        // generate exception
+        doThrow(new RuntimeException()).when(mgr1).beforeStart();
+
+        assertThrows(RuntimeException.class, () -> pool.beforeStart(controller1));
+    }
+
+    private Properties initProperties() {
+        Properties props = new Properties();
+
+        initProperties(props, "A", 0);
+        initProperties(props, "B", 1);
+        initProperties(props, "Exception", 2);
+
+        props.setProperty("pooling.controllerDisabled.enabled", "false");
+
+        props.setProperty("pooling.controllerException.offline.queue.limit", "INVALID NUMBER");
+
+        return props;
+    }
+
+    private void initProperties(Properties props, String suffix, int offset) {
+        props.setProperty("pooling.controller" + suffix + ".topic", "topic." + suffix);
+        props.setProperty("pooling.controller" + suffix + ".enabled", "true");
+        props.setProperty("pooling.controller" + suffix + ".offline.queue.limit", String.valueOf(5 + offset));
+        props.setProperty("pooling.controller" + suffix + ".offline.queue.age.milliseconds",
+                        String.valueOf(100 + offset));
+        props.setProperty("pooling.controller" + suffix + ".start.heartbeat.milliseconds", String.valueOf(10 + offset));
+        props.setProperty("pooling.controller" + suffix + ".reactivate.milliseconds", String.valueOf(20 + offset));
+        props.setProperty("pooling.controller" + suffix + ".identification.milliseconds", String.valueOf(30 + offset));
+        props.setProperty("pooling.controller" + suffix + ".active.heartbeat.milliseconds",
+                        String.valueOf(40 + offset));
+        props.setProperty("pooling.controller" + suffix + ".inter.heartbeat.milliseconds", String.valueOf(50 + offset));
+    }
+
+    /**
+     * Feature with overrides.
+     */
+    private class PoolingFeatureImpl extends PoolingFeature {
+
+        @Override
+        protected Properties getProperties(String featName) {
+            if (PoolingProperties.FEATURE_NAME.equals(featName)) {
+                return props;
+            } else {
+                throw new IllegalArgumentException("unknown feature name");
+            }
+        }
+
+        @Override
+        protected PoolingManagerImpl makeManager(String host, PolicyController controller, PoolingProperties props,
+                        CountDownLatch activeLatch) {
+
+            PoolingManagerImpl mgr = mock(PoolingManagerImpl.class);
+
+            managers.add(Pair.of(mgr, props));
+
+            return mgr;
+        }
+
+        @Override
+        protected PolicyController getController(DroolsController droolsController) {
+            if (droolsController == drools1) {
+                return controller1;
+            } else if (droolsController == drools2) {
+                return controller2;
+            } else if (droolsController == droolsDisabled) {
+                return controllerDisabled;
+            } else {
+                throw new IllegalArgumentException("unknown drools controller");
+            }
+        }
+
+        @Override
+        protected List<TopicSource> initTopicSources(Properties props) {
+            return Collections.emptyList();
+        }
+
+        @Override
+        protected List<TopicSink> initTopicSinks(Properties props) {
+            return Collections.emptyList();
+        }
+    }
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/PoolingManagerImplTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/PoolingManagerImplTest.java
new file mode 100644
index 0000000..ac60ae2
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/PoolingManagerImplTest.java
@@ -0,0 +1,995 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2020 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.LinkedList;
+import java.util.Properties;
+import java.util.Queue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.onap.policy.common.endpoints.event.comm.Topic.CommInfrastructure;
+import org.onap.policy.common.endpoints.event.comm.TopicListener;
+import org.onap.policy.drools.controller.DroolsController;
+import org.onap.policy.drools.pooling.message.BucketAssignments;
+import org.onap.policy.drools.pooling.message.Heartbeat;
+import org.onap.policy.drools.pooling.message.Message;
+import org.onap.policy.drools.pooling.message.Offline;
+import org.onap.policy.drools.pooling.state.ActiveState;
+import org.onap.policy.drools.pooling.state.IdleState;
+import org.onap.policy.drools.pooling.state.InactiveState;
+import org.onap.policy.drools.pooling.state.QueryState;
+import org.onap.policy.drools.pooling.state.StartState;
+import org.onap.policy.drools.pooling.state.State;
+import org.onap.policy.drools.system.PolicyController;
+
+class PoolingManagerImplTest {
+
+    protected static final long STD_HEARTBEAT_WAIT_MS = 10;
+    protected static final long STD_REACTIVATE_WAIT_MS = STD_HEARTBEAT_WAIT_MS + 1;
+    protected static final long STD_IDENTIFICATION_MS = STD_REACTIVATE_WAIT_MS + 1;
+    protected static final long STD_ACTIVE_HEARTBEAT_MS = STD_IDENTIFICATION_MS + 1;
+    protected static final long STD_INTER_HEARTBEAT_MS = STD_ACTIVE_HEARTBEAT_MS + 1;
+    protected static final long STD_OFFLINE_PUB_WAIT_MS = STD_INTER_HEARTBEAT_MS + 1;
+
+    private static final String MY_HOST = "my.host";
+    private static final String HOST2 = "other.host";
+
+    private static final String MY_CONTROLLER = "my.controller";
+    private static final String MY_TOPIC = "my.topic";
+
+    private static final String TOPIC2 = "topic.two";
+
+    private static final String THE_EVENT = "the event";
+
+    private static final Object DECODED_EVENT = new Object();
+
+    /**
+     * Number of publish() invocations that should be issued when the manager is
+     * started.
+     */
+    private static final int START_PUB = 1;
+
+    /**
+     * Futures that have been allocated due to calls to scheduleXxx().
+     */
+    private Queue<ScheduledFuture<?>> futures;
+
+    private PoolingProperties poolProps;
+    private ListeningController controller;
+    private TopicMessageManager topicMessageManager;
+    private boolean gotManager;
+    private ScheduledThreadPoolExecutor sched;
+    private int schedCount;
+    private DroolsController drools;
+    private Serializer ser;
+    private CountDownLatch active;
+
+    private PoolingManagerImpl mgr;
+
+    /**
+     * Setup.
+     *
+     * @throws Exception throws exception
+     */
+    @BeforeEach
+    public void setUp() throws Exception {
+        Properties plainProps = new Properties();
+
+        poolProps = mock(PoolingProperties.class);
+        when(poolProps.getSource()).thenReturn(plainProps);
+        when(poolProps.getPoolingTopic()).thenReturn(MY_TOPIC);
+        when(poolProps.getStartHeartbeatMs()).thenReturn(STD_HEARTBEAT_WAIT_MS);
+        when(poolProps.getReactivateMs()).thenReturn(STD_REACTIVATE_WAIT_MS);
+        when(poolProps.getIdentificationMs()).thenReturn(STD_IDENTIFICATION_MS);
+        when(poolProps.getActiveHeartbeatMs()).thenReturn(STD_ACTIVE_HEARTBEAT_MS);
+        when(poolProps.getInterHeartbeatMs()).thenReturn(STD_INTER_HEARTBEAT_MS);
+        when(poolProps.getOfflinePubWaitMs()).thenReturn(STD_OFFLINE_PUB_WAIT_MS);
+
+        futures = new LinkedList<>();
+        ser = new Serializer();
+        active = new CountDownLatch(1);
+
+        topicMessageManager = mock(TopicMessageManager.class);
+        gotManager = false;
+        controller = mock(ListeningController.class);
+        sched = mock(ScheduledThreadPoolExecutor.class);
+        schedCount = 0;
+        drools = mock(DroolsController.class);
+
+        when(controller.getName()).thenReturn(MY_CONTROLLER);
+        when(controller.getDrools()).thenReturn(drools);
+        when(controller.isAlive()).thenReturn(true);
+
+        when(sched.schedule(any(Runnable.class), any(Long.class), any(TimeUnit.class))).thenAnswer(args -> {
+            ScheduledFuture<?> fut = mock(ScheduledFuture.class);
+            futures.add(fut);
+
+            return fut;
+        });
+
+        when(sched.scheduleWithFixedDelay(any(Runnable.class), any(Long.class), any(Long.class), any(TimeUnit.class)))
+                        .thenAnswer(args -> {
+                            ScheduledFuture<?> fut = mock(ScheduledFuture.class);
+                            futures.add(fut);
+
+                            return fut;
+                        });
+
+        mgr = new PoolingManagerTest(MY_HOST, controller, poolProps, active);
+    }
+
+    @Test
+    void testPoolingManagerImpl() {
+        assertTrue(gotManager);
+
+        State st = mgr.getCurrent();
+        assertInstanceOf(IdleState.class, st);
+
+        // ensure the state is attached to the manager
+        assertEquals(mgr.getHost(), st.getHost());
+    }
+
+    @Test
+    void testPoolingManagerImpl_PoolEx() {
+        // throw an exception when we try to create the topic messages manager
+        PoolingFeatureException ex = new PoolingFeatureException();
+
+        assertThatThrownBy(() -> new PoolingManagerTest(MY_HOST, controller, poolProps, active) {
+            @Override
+            protected TopicMessageManager makeTopicMessagesManager(String topic) throws PoolingFeatureException {
+                throw ex;
+            }
+        }).isInstanceOf(PoolingFeatureRtException.class).hasCause(ex);
+    }
+
+    @Test
+    void testGetCurrent() throws Exception {
+        assertEquals(IdleState.class, mgr.getCurrent().getClass());
+
+        startMgr();
+
+        assertEquals(StartState.class, mgr.getCurrent().getClass());
+    }
+
+    @Test
+    void testGetHost() {
+        assertEquals(MY_HOST, mgr.getHost());
+
+        mgr = new PoolingManagerTest(HOST2, controller, poolProps, active);
+        assertEquals(HOST2, mgr.getHost());
+    }
+
+    @Test
+    void testGetTopic() {
+        assertEquals(MY_TOPIC, mgr.getTopic());
+    }
+
+    @Test
+    void testGetProperties() {
+        assertEquals(poolProps, mgr.getProperties());
+    }
+
+    @Test
+    void testBeforeStart() {
+        // not running yet
+        mgr.beforeStart();
+
+        verify(topicMessageManager).startPublisher();
+
+        assertEquals(1, schedCount);
+        verify(sched).setMaximumPoolSize(1);
+        verify(sched).setContinueExistingPeriodicTasksAfterShutdownPolicy(false);
+
+
+        // try again - nothing should happen
+        mgr.beforeStart();
+
+        verify(topicMessageManager).startPublisher();
+
+        assertEquals(1, schedCount);
+        verify(sched).setMaximumPoolSize(1);
+        verify(sched).setContinueExistingPeriodicTasksAfterShutdownPolicy(false);
+    }
+
+    @Test
+    void testAfterStart() throws Exception {
+        startMgr();
+
+        verify(topicMessageManager).startConsumer(mgr);
+
+        State st = mgr.getCurrent();
+        assertInstanceOf(StartState.class, st);
+
+        // ensure the state is attached to the manager
+        assertEquals(mgr.getHost(), st.getHost());
+
+        ArgumentCaptor<Long> timeCap = ArgumentCaptor.forClass(Long.class);
+        ArgumentCaptor<TimeUnit> unitCap = ArgumentCaptor.forClass(TimeUnit.class);
+        verify(sched).schedule(any(Runnable.class), timeCap.capture(), unitCap.capture());
+
+        assertEquals(STD_HEARTBEAT_WAIT_MS, timeCap.getValue().longValue());
+        assertEquals(TimeUnit.MILLISECONDS, unitCap.getValue());
+
+
+        // already started - nothing else happens
+        mgr.afterStart();
+
+        verify(topicMessageManager).startConsumer(mgr);
+
+        assertInstanceOf(StartState.class, mgr.getCurrent());
+
+        verify(sched).schedule(any(Runnable.class), any(Long.class), any(TimeUnit.class));
+    }
+
+    @Test
+    void testBeforeStop() throws Exception {
+        startMgr();
+        mgr.startDistributing(makeAssignments(false));
+
+        verify(topicMessageManager, times(START_PUB)).publish(any());
+
+        mgr.beforeStop();
+
+        verify(topicMessageManager).stopConsumer(mgr);
+        verify(sched).shutdownNow();
+        verify(topicMessageManager, times(START_PUB + 1)).publish(any());
+        verify(topicMessageManager).publish(contains("offline"));
+
+        assertInstanceOf(IdleState.class, mgr.getCurrent());
+
+        // verify that next message is handled locally
+        assertFalse(mgr.beforeInsert(TOPIC2, DECODED_EVENT));
+        verify(topicMessageManager, times(START_PUB + 1)).publish(any());
+    }
+
+    @Test
+    void testBeforeStop_NotRunning() {
+        final State st = mgr.getCurrent();
+
+        mgr.beforeStop();
+
+        verify(topicMessageManager, never()).stopConsumer(any());
+        verify(sched, never()).shutdownNow();
+
+        // hasn't changed states either
+        assertEquals(st, mgr.getCurrent());
+    }
+
+    @Test
+    void testBeforeStop_AfterPartialStart() {
+        // call beforeStart but not afterStart
+        mgr.beforeStart();
+
+        final State st = mgr.getCurrent();
+
+        mgr.beforeStop();
+
+        // should still shut the scheduler down
+        verify(sched).shutdownNow();
+
+        verify(topicMessageManager, never()).stopConsumer(any());
+
+        // hasn't changed states
+        assertEquals(st, mgr.getCurrent());
+    }
+
+    @Test
+    void testAfterStop() throws Exception {
+        startMgr();
+        mgr.beforeStop();
+
+        mgr.afterStop();
+
+        verify(topicMessageManager).stopPublisher(STD_OFFLINE_PUB_WAIT_MS);
+    }
+
+    @Test
+    void testBeforeLock() throws Exception {
+        startMgr();
+
+        mgr.beforeLock();
+
+        assertInstanceOf(IdleState.class, mgr.getCurrent());
+    }
+
+    @Test
+    void testAfterUnlock_AliveIdle() {
+        // this really shouldn't happen
+
+        lockMgr();
+
+        mgr.afterUnlock();
+
+        // stays in idle state, because it has no scheduler
+        assertInstanceOf(IdleState.class, mgr.getCurrent());
+    }
+
+    @Test
+    void testAfterUnlock_AliveStarted() throws Exception {
+        startMgr();
+        lockMgr();
+
+        mgr.afterUnlock();
+
+        assertInstanceOf(StartState.class, mgr.getCurrent());
+    }
+
+    @Test
+    void testAfterUnlock_StoppedIdle() throws Exception {
+        startMgr();
+        lockMgr();
+
+        // controller is stopped
+        when(controller.isAlive()).thenReturn(false);
+
+        mgr.afterUnlock();
+
+        assertInstanceOf(IdleState.class, mgr.getCurrent());
+    }
+
+    @Test
+    void testAfterUnlock_StoppedStarted() throws Exception {
+        startMgr();
+
+        // Note: don't lockMgr()
+
+        // controller is stopped
+        when(controller.isAlive()).thenReturn(false);
+
+        mgr.afterUnlock();
+
+        assertInstanceOf(StartState.class, mgr.getCurrent());
+    }
+
+    @Test
+    void testChangeState() throws Exception {
+        // start should invoke changeState()
+        startMgr();
+
+        /*
+         * now go offline while it's locked
+         */
+        lockMgr();
+
+        // should have cancelled the timers
+        assertEquals(2, futures.size());
+        verify(futures.poll()).cancel(false);
+        verify(futures.poll()).cancel(false);
+
+        /*
+         * now go back online
+         */
+        unlockMgr();
+
+        // new timers should now be active
+        assertEquals(2, futures.size());
+        verify(futures.poll(), never()).cancel(false);
+        verify(futures.poll(), never()).cancel(false);
+    }
+
+    @Test
+    void testSchedule() throws Exception {
+        // must start the scheduler
+        startMgr();
+
+        CountDownLatch latch = new CountDownLatch(1);
+
+        mgr.schedule(STD_ACTIVE_HEARTBEAT_MS, () -> {
+            latch.countDown();
+            return null;
+        });
+
+        // capture the task
+        ArgumentCaptor<Runnable> taskCap = ArgumentCaptor.forClass(Runnable.class);
+        ArgumentCaptor<Long> timeCap = ArgumentCaptor.forClass(Long.class);
+        ArgumentCaptor<TimeUnit> unitCap = ArgumentCaptor.forClass(TimeUnit.class);
+
+        verify(sched, times(2)).schedule(taskCap.capture(), timeCap.capture(), unitCap.capture());
+
+        assertEquals(STD_ACTIVE_HEARTBEAT_MS, timeCap.getValue().longValue());
+        assertEquals(TimeUnit.MILLISECONDS, unitCap.getValue());
+
+        // execute it
+        taskCap.getValue().run();
+
+        assertEquals(0, latch.getCount());
+    }
+
+    @Test
+    void testScheduleWithFixedDelay() throws Exception {
+        // must start the scheduler
+        startMgr();
+
+        CountDownLatch latch = new CountDownLatch(1);
+
+        mgr.scheduleWithFixedDelay(STD_HEARTBEAT_WAIT_MS, STD_ACTIVE_HEARTBEAT_MS, () -> {
+            latch.countDown();
+            return null;
+        });
+
+        // capture the task
+        ArgumentCaptor<Runnable> taskCap = ArgumentCaptor.forClass(Runnable.class);
+        ArgumentCaptor<Long> initCap = ArgumentCaptor.forClass(Long.class);
+        ArgumentCaptor<Long> timeCap = ArgumentCaptor.forClass(Long.class);
+        ArgumentCaptor<TimeUnit> unitCap = ArgumentCaptor.forClass(TimeUnit.class);
+
+        verify(sched, times(2)).scheduleWithFixedDelay(taskCap.capture(), initCap.capture(), timeCap.capture(),
+                        unitCap.capture());
+
+        assertEquals(STD_HEARTBEAT_WAIT_MS, initCap.getValue().longValue());
+        assertEquals(STD_ACTIVE_HEARTBEAT_MS, timeCap.getValue().longValue());
+        assertEquals(TimeUnit.MILLISECONDS, unitCap.getValue());
+
+        // execute it
+        taskCap.getValue().run();
+
+        assertEquals(0, latch.getCount());
+    }
+
+    @Test
+    void testPublishAdmin() throws Exception {
+        Offline msg = new Offline(mgr.getHost());
+        mgr.publishAdmin(msg);
+
+        assertEquals(Message.ADMIN, msg.getChannel());
+
+        verify(topicMessageManager).publish(any());
+    }
+
+    @Test
+    void testPublish() throws Exception {
+        Offline msg = new Offline(mgr.getHost());
+        mgr.publish("my.channel", msg);
+
+        assertEquals("my.channel", msg.getChannel());
+
+        verify(topicMessageManager).publish(any());
+    }
+
+    @Test
+    void testPublish_InvalidMsg() throws Exception {
+        // message is missing data
+        mgr.publish(Message.ADMIN, new Offline());
+
+        // should not have attempted to publish it
+        verify(topicMessageManager, never()).publish(any());
+    }
+
+    @Test
+    void testPublish_TopicMessageMngEx() throws Exception {
+
+        // generate exception
+        doThrow(new PoolingFeatureException()).when(topicMessageManager).publish(any());
+
+        assertThatCode(() -> mgr.publish(Message.ADMIN, new Offline(mgr.getHost()))).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testOnTopicEvent() throws Exception {
+        startMgr();
+
+        StartState st = (StartState) mgr.getCurrent();
+
+        /*
+         * give it its heart beat, that should cause it to transition to the Query state.
+         */
+        Heartbeat hb = new Heartbeat(mgr.getHost(), st.getHbTimestampMs());
+        hb.setChannel(Message.ADMIN);
+
+        String msg = ser.encodeMsg(hb);
+
+        mgr.onTopicEvent(CommInfrastructure.UEB, MY_TOPIC, msg);
+
+        assertInstanceOf(QueryState.class, mgr.getCurrent());
+    }
+
+    @Test
+    void testOnTopicEvent_NullEvent() throws Exception {
+        startMgr();
+
+        assertThatCode(() -> mgr.onTopicEvent(CommInfrastructure.UEB, TOPIC2, null)).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testBeforeOffer_Unlocked() throws Exception {
+        startMgr();
+
+        // route the message to another host
+        mgr.startDistributing(makeAssignments(false));
+
+        assertFalse(mgr.beforeOffer(TOPIC2, THE_EVENT));
+    }
+
+    @Test
+    void testBeforeOffer_Locked() throws Exception {
+        startMgr();
+        lockMgr();
+
+        // route the message to another host
+        mgr.startDistributing(makeAssignments(false));
+
+        assertTrue(mgr.beforeOffer(TOPIC2, THE_EVENT));
+    }
+
+    @Test
+    void testBeforeInsert() throws Exception {
+        startMgr();
+        lockMgr();
+
+        // route the message to this host
+        mgr.startDistributing(makeAssignments(true));
+
+        assertFalse(mgr.beforeInsert(TOPIC2, DECODED_EVENT));
+    }
+
+    @Test
+    void testHandleExternalCommInfrastructureStringStringString_NullReqId() throws Exception {
+        validateHandleReqId(null);
+    }
+
+    @Test
+    void testHandleExternalCommInfrastructureStringStringString_EmptyReqId() throws Exception {
+        validateHandleReqId("");
+    }
+
+    @Test
+    void testHandleExternalCommInfrastructureStringStringString_InvalidMsg() throws Exception {
+        startMgr();
+
+        assertFalse(mgr.beforeInsert(TOPIC2, "invalid message"));
+    }
+
+    @Test
+    void testHandleExternalCommInfrastructureStringStringString() throws Exception {
+        validateUnhandled();
+    }
+
+    @Test
+    void testHandleExternalForward_NoAssignments() throws Exception {
+        validateUnhandled();
+    }
+
+    @Test
+    void testHandleExternalForward() throws Exception {
+        validateNoForward();
+    }
+
+    @Test
+    void testHandleEvent_NullTarget() throws Exception {
+        // buckets have null targets
+        validateDiscarded(new BucketAssignments(new String[] {null, null}));
+    }
+
+    @Test
+    void testHandleEvent_SameHost() throws Exception {
+        validateNoForward();
+    }
+
+    @Test
+    void testHandleEvent_DiffHost() throws Exception {
+        // route the message to the *OTHER* host
+        validateDiscarded(makeAssignments(false));
+    }
+
+    @Test
+    void testDecodeEvent_CannotDecode() throws Exception {
+
+        mgr = new PoolingManagerTest(MY_HOST, controller, poolProps, active) {
+            @Override
+            protected boolean canDecodeEvent(DroolsController drools2, String topic2) {
+                return false;
+            }
+        };
+
+        startMgr();
+
+        when(controller.isLocked()).thenReturn(true);
+
+        // create assignments, though they are irrelevant
+        mgr.startDistributing(makeAssignments(false));
+
+        assertFalse(mgr.beforeOffer(TOPIC2, THE_EVENT));
+    }
+
+    @Test
+    void testDecodeEvent_UnsuppEx() throws Exception {
+
+        // generate exception
+        mgr = new PoolingManagerTest(MY_HOST, controller, poolProps, active) {
+            @Override
+            protected Object decodeEventWrapper(DroolsController drools2, String topic2, String event) {
+                throw new UnsupportedOperationException();
+            }
+        };
+
+        startMgr();
+
+        when(controller.isLocked()).thenReturn(true);
+
+        // create assignments, though they are irrelevant
+        mgr.startDistributing(makeAssignments(false));
+
+        assertFalse(mgr.beforeOffer(TOPIC2, THE_EVENT));
+    }
+
+    @Test
+    void testDecodeEvent_ArgEx() throws Exception {
+        // generate exception
+        mgr = new PoolingManagerTest(MY_HOST, controller, poolProps, active) {
+            @Override
+            protected Object decodeEventWrapper(DroolsController drools2, String topic2, String event) {
+                throw new IllegalArgumentException();
+            }
+        };
+
+        startMgr();
+
+        when(controller.isLocked()).thenReturn(true);
+
+        // create assignments, though they are irrelevant
+        mgr.startDistributing(makeAssignments(false));
+
+        assertFalse(mgr.beforeOffer(TOPIC2, THE_EVENT));
+    }
+
+    @Test
+    void testDecodeEvent_StateEx() throws Exception {
+        // generate exception
+        mgr = new PoolingManagerTest(MY_HOST, controller, poolProps, active) {
+            @Override
+            protected Object decodeEventWrapper(DroolsController drools2, String topic2, String event) {
+                throw new IllegalStateException();
+            }
+        };
+
+        startMgr();
+
+        when(controller.isLocked()).thenReturn(true);
+
+        // create assignments, though they are irrelevant
+        mgr.startDistributing(makeAssignments(false));
+
+        assertFalse(mgr.beforeOffer(TOPIC2, THE_EVENT));
+    }
+
+    @Test
+    void testDecodeEvent() throws Exception {
+        startMgr();
+
+        when(controller.isLocked()).thenReturn(true);
+
+        // route to another host
+        mgr.startDistributing(makeAssignments(false));
+
+        assertTrue(mgr.beforeOffer(TOPIC2, THE_EVENT));
+    }
+
+    @Test
+    void testHandleInternal() throws Exception {
+        startMgr();
+
+        StartState st = (StartState) mgr.getCurrent();
+
+        /*
+         * give it its heart beat, that should cause it to transition to the Query state.
+         */
+        Heartbeat hb = new Heartbeat(mgr.getHost(), st.getHbTimestampMs());
+        hb.setChannel(Message.ADMIN);
+
+        String msg = ser.encodeMsg(hb);
+
+        mgr.onTopicEvent(CommInfrastructure.UEB, MY_TOPIC, msg);
+
+        assertInstanceOf(QueryState.class, mgr.getCurrent());
+    }
+
+    @Test
+    void testHandleInternal_IoEx() throws Exception {
+        startMgr();
+
+        mgr.onTopicEvent(CommInfrastructure.UEB, MY_TOPIC, "invalid message");
+
+        assertInstanceOf(StartState.class, mgr.getCurrent());
+    }
+
+    @Test
+    void testHandleInternal_PoolEx() throws Exception {
+        startMgr();
+
+        StartState st = (StartState) mgr.getCurrent();
+
+        Heartbeat hb = new Heartbeat(mgr.getHost(), st.getHbTimestampMs());
+
+        /*
+         * do NOT set the channel - this will cause the message to be invalid, triggering
+         * an exception
+         */
+
+        String msg = ser.encodeMsg(hb);
+
+        mgr.onTopicEvent(CommInfrastructure.UEB, MY_TOPIC, msg);
+
+        assertInstanceOf(StartState.class, mgr.getCurrent());
+    }
+
+    @Test
+    void testStartDistributing() throws Exception {
+        validateNoForward();
+
+
+        // null assignments should cause message to be processed locally
+        mgr.startDistributing(null);
+        assertFalse(mgr.beforeInsert(TOPIC2, DECODED_EVENT));
+        verify(topicMessageManager, times(START_PUB)).publish(any());
+
+
+        // message for this host
+        mgr.startDistributing(makeAssignments(true));
+        assertFalse(mgr.beforeInsert(TOPIC2, DECODED_EVENT));
+
+
+        // message for another host
+        mgr.startDistributing(makeAssignments(false));
+        assertTrue(mgr.beforeInsert(TOPIC2, DECODED_EVENT));
+    }
+
+    @Test
+    void testGoStart() {
+        State st = mgr.goStart();
+        assertInstanceOf(StartState.class, st);
+        assertEquals(mgr.getHost(), st.getHost());
+    }
+
+    @Test
+    void testGoQuery() {
+        BucketAssignments asgn = new BucketAssignments(new String[] {HOST2});
+        mgr.startDistributing(asgn);
+
+        State st = mgr.goQuery();
+
+        assertInstanceOf(QueryState.class, st);
+        assertEquals(mgr.getHost(), st.getHost());
+        assertEquals(asgn, mgr.getAssignments());
+    }
+
+    @Test
+    void testGoActive() {
+        BucketAssignments asgn = new BucketAssignments(new String[] {HOST2});
+        mgr.startDistributing(asgn);
+
+        State st = mgr.goActive();
+
+        assertInstanceOf(ActiveState.class, st);
+        assertEquals(mgr.getHost(), st.getHost());
+        assertEquals(asgn, mgr.getAssignments());
+        assertEquals(0, active.getCount());
+    }
+
+    @Test
+    void testGoInactive() {
+        State st = mgr.goInactive();
+        assertInstanceOf(InactiveState.class, st);
+        assertEquals(mgr.getHost(), st.getHost());
+        assertEquals(1, active.getCount());
+    }
+
+    @Test
+    void testTimerActionRun() throws Exception {
+        // must start the scheduler
+        startMgr();
+
+        CountDownLatch latch = new CountDownLatch(1);
+
+        mgr.schedule(STD_ACTIVE_HEARTBEAT_MS, () -> {
+            latch.countDown();
+            return null;
+        });
+
+        // capture the task
+        ArgumentCaptor<Runnable> taskCap = ArgumentCaptor.forClass(Runnable.class);
+
+        verify(sched, times(2)).schedule(taskCap.capture(), any(Long.class), any(TimeUnit.class));
+
+        // execute it
+        taskCap.getValue().run();
+
+        assertEquals(0, latch.getCount());
+    }
+
+    @Test
+    void testTimerActionRun_DiffState() throws Exception {
+        // must start the scheduler
+        startMgr();
+
+        CountDownLatch latch = new CountDownLatch(1);
+
+        mgr.schedule(STD_ACTIVE_HEARTBEAT_MS, () -> {
+            latch.countDown();
+            return null;
+        });
+
+        // capture the task
+        ArgumentCaptor<Runnable> taskCap = ArgumentCaptor.forClass(Runnable.class);
+
+        verify(sched, times(2)).schedule(taskCap.capture(), any(Long.class), any(TimeUnit.class));
+
+        // give it a heartbeat so that it transitions to the query state
+        StartState st = (StartState) mgr.getCurrent();
+        Heartbeat hb = new Heartbeat(mgr.getHost(), st.getHbTimestampMs());
+        hb.setChannel(Message.ADMIN);
+
+        String msg = ser.encodeMsg(hb);
+
+        mgr.onTopicEvent(CommInfrastructure.UEB, MY_TOPIC, msg);
+
+        assertInstanceOf(QueryState.class, mgr.getCurrent());
+
+        // execute it
+        taskCap.getValue().run();
+
+        // it should NOT have counted down
+        assertEquals(1, latch.getCount());
+    }
+
+    private void validateHandleReqId(String requestId) throws PoolingFeatureException {
+        startMgr();
+
+        assertFalse(mgr.beforeInsert(TOPIC2, DECODED_EVENT));
+    }
+
+    private void validateNoForward() throws PoolingFeatureException {
+        startMgr();
+
+        // route the message to this host
+        mgr.startDistributing(makeAssignments(true));
+
+        assertFalse(mgr.beforeInsert(TOPIC2, DECODED_EVENT));
+
+        verify(topicMessageManager, times(START_PUB)).publish(any());
+    }
+
+    private void validateUnhandled() throws PoolingFeatureException {
+        startMgr();
+        assertFalse(mgr.beforeInsert(TOPIC2, DECODED_EVENT));
+    }
+
+    private void validateDiscarded(BucketAssignments bucketAssignments) throws PoolingFeatureException {
+        startMgr();
+
+        // buckets have null targets
+        mgr.startDistributing(bucketAssignments);
+
+        assertTrue(mgr.beforeInsert(TOPIC2, DECODED_EVENT));
+    }
+
+    /**
+     * Makes an assignment with two buckets.
+     *
+     * @param sameHost {@code true} if the REQUEST_ID should hash to the
+     *        manager's bucket, {@code false} if it should hash to the other host's bucket
+     * @return a new bucket assignment
+     */
+    private BucketAssignments makeAssignments(boolean sameHost) {
+        int slot = DECODED_EVENT.hashCode() % 2;
+
+        // slot numbers are 0 and 1 - reverse them if it's for a different host
+        if (!sameHost) {
+            slot = 1 - slot;
+        }
+
+        String[] asgn = new String[2];
+        asgn[slot] = mgr.getHost();
+        asgn[1 - slot] = HOST2;
+
+        return new BucketAssignments(asgn);
+    }
+
+    /**
+     * Invokes methods necessary to start the manager.
+     *
+     */
+    private void startMgr() {
+        mgr.beforeStart();
+        mgr.afterStart();
+    }
+
+    /**
+     * Invokes methods necessary to lock the manager.
+     */
+    private void lockMgr() {
+        mgr.beforeLock();
+        when(controller.isLocked()).thenReturn(true);
+    }
+
+    /**
+     * Invokes methods necessary to unlock the manager.
+     */
+    private void unlockMgr() {
+        mgr.afterUnlock();
+        when(controller.isLocked()).thenReturn(false);
+    }
+
+    /**
+     * Used to create a mock object that implements both super interfaces.
+     */
+    private static interface ListeningController extends TopicListener, PolicyController {
+
+    }
+
+    /**
+     * Manager with overrides.
+     */
+    private class PoolingManagerTest extends PoolingManagerImpl {
+
+        public PoolingManagerTest(String host, PolicyController controller, PoolingProperties props,
+                        CountDownLatch activeLatch) {
+
+            super(host, controller, props, activeLatch);
+        }
+
+        @Override
+        protected TopicMessageManager makeTopicMessagesManager(String topic) throws PoolingFeatureException {
+            gotManager = true;
+            return topicMessageManager;
+        }
+
+        @Override
+        protected ScheduledThreadPoolExecutor makeScheduler() {
+            ++schedCount;
+            return sched;
+        }
+
+        @Override
+        protected boolean canDecodeEvent(DroolsController drools2, String topic2) {
+            return (drools2 == drools && TOPIC2.equals(topic2));
+        }
+
+        @Override
+        protected Object decodeEventWrapper(DroolsController drools2, String topic2, String event) {
+            if (drools2 == drools && TOPIC2.equals(topic2) && event == THE_EVENT) {
+                return DECODED_EVENT;
+            } else {
+                return null;
+            }
+        }
+    }
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/PoolingPropertiesTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/PoolingPropertiesTest.java
new file mode 100644
index 0000000..383e007
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/PoolingPropertiesTest.java
@@ -0,0 +1,190 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2020 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.onap.policy.drools.pooling.PoolingProperties.ACTIVE_HEARTBEAT_MS;
+import static org.onap.policy.drools.pooling.PoolingProperties.FEATURE_ENABLED;
+import static org.onap.policy.drools.pooling.PoolingProperties.IDENTIFICATION_MS;
+import static org.onap.policy.drools.pooling.PoolingProperties.INTER_HEARTBEAT_MS;
+import static org.onap.policy.drools.pooling.PoolingProperties.OFFLINE_AGE_MS;
+import static org.onap.policy.drools.pooling.PoolingProperties.OFFLINE_LIMIT;
+import static org.onap.policy.drools.pooling.PoolingProperties.OFFLINE_PUB_WAIT_MS;
+import static org.onap.policy.drools.pooling.PoolingProperties.POOLING_TOPIC;
+import static org.onap.policy.drools.pooling.PoolingProperties.PREFIX;
+import static org.onap.policy.drools.pooling.PoolingProperties.REACTIVATE_MS;
+import static org.onap.policy.drools.pooling.PoolingProperties.START_HEARTBEAT_MS;
+
+import java.util.Properties;
+import java.util.function.Function;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.onap.policy.common.utils.properties.exception.PropertyException;
+
+public class PoolingPropertiesTest {
+
+    private static final String CONTROLLER = "a.controller";
+
+    private static final String STD_POOLING_TOPIC = "my.topic";
+    public static final boolean STD_FEATURE_ENABLED = true;
+    public static final int STD_OFFLINE_LIMIT = 10;
+    public static final long STD_OFFLINE_AGE_MS = 1000L;
+    public static final long STD_OFFLINE_PUB_WAIT_MS = 2000L;
+    public static final long STD_START_HEARTBEAT_MS = 3000L;
+    public static final long STD_REACTIVATE_MS = 4000L;
+    public static final long STD_IDENTIFICATION_MS = 5000L;
+    public static final long STD_ACTIVE_HEARTBEAT_MS = 7000L;
+    public static final long STD_INTER_HEARTBEAT_MS = 8000L;
+
+    private Properties plain;
+    private PoolingProperties pooling;
+
+    /**
+     * Setup.
+     *
+     * @throws Exception throws an exception
+     */
+    @BeforeEach
+    public void setUp() throws Exception {
+        plain = makeProperties();
+
+        pooling = new PoolingProperties(CONTROLLER, plain);
+    }
+
+    @Test
+    void testPoolingProperties() {
+        // ensure no exceptions
+        assertThatCode(() -> new PoolingProperties(CONTROLLER, plain)).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testGetSource() {
+        assertEquals(plain, pooling.getSource());
+    }
+
+    @Test
+    void testGetPoolingTopic() {
+        assertEquals(STD_POOLING_TOPIC, pooling.getPoolingTopic());
+    }
+
+    @Test
+    void testGetOfflineLimit() throws PropertyException {
+        doTest(OFFLINE_LIMIT, STD_OFFLINE_LIMIT, 1000, xxx -> pooling.getOfflineLimit());
+    }
+
+    @Test
+    void testGetOfflineAgeMs() throws PropertyException {
+        doTest(OFFLINE_AGE_MS, STD_OFFLINE_AGE_MS, 60000L, xxx -> pooling.getOfflineAgeMs());
+    }
+
+    @Test
+    void testGetOfflinePubWaitMs() throws PropertyException {
+        doTest(OFFLINE_PUB_WAIT_MS, STD_OFFLINE_PUB_WAIT_MS, 3000L, xxx -> pooling.getOfflinePubWaitMs());
+    }
+
+    @Test
+    void testGetStartHeartbeatMs() throws PropertyException {
+        doTest(START_HEARTBEAT_MS, STD_START_HEARTBEAT_MS, 100000L, xxx -> pooling.getStartHeartbeatMs());
+    }
+
+    @Test
+    void testGetReactivateMs() throws PropertyException {
+        doTest(REACTIVATE_MS, STD_REACTIVATE_MS, 50000L, xxx -> pooling.getReactivateMs());
+    }
+
+    @Test
+    void testGetIdentificationMs() throws PropertyException {
+        doTest(IDENTIFICATION_MS, STD_IDENTIFICATION_MS, 50000L, xxx -> pooling.getIdentificationMs());
+    }
+
+    @Test
+    void testGetActiveHeartbeatMs() throws PropertyException {
+        doTest(ACTIVE_HEARTBEAT_MS, STD_ACTIVE_HEARTBEAT_MS, 50000L, xxx -> pooling.getActiveHeartbeatMs());
+    }
+
+    @Test
+    void testGetInterHeartbeatMs() throws PropertyException {
+        doTest(INTER_HEARTBEAT_MS, STD_INTER_HEARTBEAT_MS, 15000L, xxx -> pooling.getInterHeartbeatMs());
+    }
+
+    /**
+     * Tests a particular property. Verifies that the correct value is returned if the
+     * specialized property has a value or the property has no value. Also verifies that
+     * the property name can be generalized.
+     *
+     * @param propnm name of the property of interest
+     * @param specValue expected specialized value
+     * @param dfltValue expected default value
+     * @param func function to get the field
+     * @throws PropertyException if an error occurs
+     */
+    private <T> void doTest(String propnm, T specValue, T dfltValue, Function<Void, T> func) throws PropertyException {
+        /*
+         * With specialized property
+         */
+        pooling = new PoolingProperties(CONTROLLER, plain);
+        assertEquals(specValue, func.apply(null), "special " + propnm);
+
+        /*
+         * Without the property - should use the default value.
+         */
+        plain.remove(specialize(propnm));
+        plain.remove(propnm);
+        pooling = new PoolingProperties(CONTROLLER, plain);
+        assertEquals(dfltValue, func.apply(null), "default " + propnm);
+    }
+
+    /**
+     * Makes a set of properties, where all the properties are specialized for the
+     * controller.
+     *
+     * @return a new property set
+     */
+    private Properties makeProperties() {
+        Properties props = new Properties();
+
+        props.setProperty(specialize(POOLING_TOPIC), STD_POOLING_TOPIC);
+        props.setProperty(specialize(FEATURE_ENABLED), "" + STD_FEATURE_ENABLED);
+        props.setProperty(specialize(OFFLINE_LIMIT), "" + STD_OFFLINE_LIMIT);
+        props.setProperty(specialize(OFFLINE_AGE_MS), "" + STD_OFFLINE_AGE_MS);
+        props.setProperty(specialize(OFFLINE_PUB_WAIT_MS), "" + STD_OFFLINE_PUB_WAIT_MS);
+        props.setProperty(specialize(START_HEARTBEAT_MS), "" + STD_START_HEARTBEAT_MS);
+        props.setProperty(specialize(REACTIVATE_MS), "" + STD_REACTIVATE_MS);
+        props.setProperty(specialize(IDENTIFICATION_MS), "" + STD_IDENTIFICATION_MS);
+        props.setProperty(specialize(ACTIVE_HEARTBEAT_MS), "" + STD_ACTIVE_HEARTBEAT_MS);
+        props.setProperty(specialize(INTER_HEARTBEAT_MS), "" + STD_INTER_HEARTBEAT_MS);
+
+        return props;
+    }
+
+    /**
+     * Embeds a specializer within a property name, after the prefix.
+     *
+     * @param propnm property name into which it should be embedded
+     * @return the property name, with the specializer embedded within it
+     */
+    private String specialize(String propnm) {
+        String suffix = propnm.substring(PREFIX.length());
+        return PREFIX + PoolingPropertiesTest.CONTROLLER + "." + suffix;
+    }
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/SerializerTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/SerializerTest.java
new file mode 100644
index 0000000..a81ea68
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/SerializerTest.java
@@ -0,0 +1,85 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2020 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import com.google.gson.JsonParseException;
+import org.junit.jupiter.api.Test;
+import org.onap.policy.drools.pooling.message.Message;
+import org.onap.policy.drools.pooling.message.Query;
+
+class SerializerTest {
+
+    @Test
+    void testSerializer() {
+        assertThatCode(Serializer::new).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testEncodeMsg_testDecodeMsg() {
+        Serializer ser = new Serializer();
+
+        Query msg = new Query("hostA");
+        msg.setChannel("channelB");
+
+        String encoded = ser.encodeMsg(msg);
+        assertNotNull(encoded);
+
+        Message decoded = ser.decodeMsg(encoded);
+        assertEquals(Query.class, decoded.getClass());
+
+        assertEquals(msg.getSource(), decoded.getSource());
+        assertEquals(msg.getChannel(), decoded.getChannel());
+
+        // should work a second time, too
+        encoded = ser.encodeMsg(msg);
+        assertNotNull(encoded);
+
+        decoded = ser.decodeMsg(encoded);
+        assertEquals(Query.class, decoded.getClass());
+
+        assertEquals(msg.getSource(), decoded.getSource());
+        assertEquals(msg.getChannel(), decoded.getChannel());
+
+        // invalid subclass when encoding
+        Message msg2 = new Message() {};
+        assertThatThrownBy(() -> ser.encodeMsg(msg2)).isInstanceOf(JsonParseException.class)
+                        .hasMessageContaining("cannot serialize");
+
+        // missing type when decoding
+        final String enc2 = encoded.replaceAll("type", "other-field-name");
+
+        assertThatThrownBy(() -> ser.decodeMsg(enc2)).isInstanceOf(JsonParseException.class)
+                        .hasMessageContaining("does not contain a field named");
+
+        // invalid type
+        final String enc3 = encoded.replaceAll("query", "invalid-type");
+
+        assertThatThrownBy(() -> ser.decodeMsg(enc3)).isInstanceOf(JsonParseException.class)
+                        .hasMessage("cannot deserialize \"invalid-type\"");
+    }
+
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/TopicMessageManagerTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/TopicMessageManagerTest.java
new file mode 100644
index 0000000..7409848
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/TopicMessageManagerTest.java
@@ -0,0 +1,322 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2020 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.onap.policy.common.endpoints.event.comm.TopicListener;
+import org.onap.policy.common.endpoints.event.comm.TopicSink;
+import org.onap.policy.common.endpoints.event.comm.TopicSource;
+
+class TopicMessageManagerTest {
+
+    private static final String EXPECTED = "expected";
+    private static final String MY_TOPIC = "my.topic";
+    private static final String MSG = "a message";
+
+    private TopicListener listener;
+    private TopicSource source;
+    private boolean gotSources;
+    private TopicSink sink;
+    private boolean gotSinks;
+    private TopicMessageManager mgr;
+
+    /**
+     * Setup.
+     *
+     * @throws Exception throws an exception
+     */
+    @BeforeEach
+    public void setUp() throws Exception {
+        listener = mock(TopicListener.class);
+        source = mock(TopicSource.class);
+        gotSources = false;
+        sink = mock(TopicSink.class);
+        gotSinks = false;
+
+        when(source.getTopic()).thenReturn(MY_TOPIC);
+
+        when(sink.getTopic()).thenReturn(MY_TOPIC);
+        when(sink.send(any())).thenReturn(true);
+
+        mgr = new TopicMessageManagerImpl(MY_TOPIC);
+    }
+
+    @Test
+    void testTopicMessageManager() {
+        // verify that the init methods were called
+        assertTrue(gotSources);
+        assertTrue(gotSinks);
+    }
+
+    @Test
+    void testTopicMessageManager_PoolingEx() {
+        // force error by having no topics match
+        when(source.getTopic()).thenReturn("");
+
+        assertThrows(PoolingFeatureException.class, () -> new TopicMessageManagerImpl(MY_TOPIC));
+    }
+
+    @Test
+    void testTopicMessageManager_IllegalArgEx() {
+        // force error
+        assertThrows(PoolingFeatureException.class, () ->
+            new TopicMessageManagerImpl(MY_TOPIC) {
+            @Override
+            protected List<TopicSource> getTopicSources() {
+                throw new IllegalArgumentException(EXPECTED);
+            }
+        });
+    }
+
+    @Test
+    void testGetTopic() {
+        assertEquals(MY_TOPIC, mgr.getTopic());
+    }
+
+    @Test
+    void testFindTopicSource_NotFound() {
+        // one item in list, and its topic doesn't match
+        assertThrows(PoolingFeatureException.class, () -> new TopicMessageManagerImpl(MY_TOPIC) {
+            @Override
+            protected List<TopicSource> getTopicSources() {
+                return Collections.singletonList(mock(TopicSource.class));
+            }
+        });
+    }
+
+    @Test
+    void testFindTopicSource_EmptyList() {
+        // empty list
+        assertThrows(PoolingFeatureException.class, () -> new TopicMessageManagerImpl(MY_TOPIC) {
+            @Override
+            protected List<TopicSource> getTopicSources() {
+                return Collections.emptyList();
+            }
+        });
+    }
+
+    @Test
+    void testFindTopicSink_NotFound() {
+        // one item in list, and its topic doesn't match
+        assertThrows(PoolingFeatureException.class, () -> new TopicMessageManagerImpl(MY_TOPIC) {
+            @Override
+            protected List<TopicSink> getTopicSinks() {
+                return Collections.singletonList(mock(TopicSink.class));
+            }
+        });
+    }
+
+    @Test
+    void testFindTopicSink_EmptyList() {
+        // empty list
+        assertThrows(PoolingFeatureException.class, () -> new TopicMessageManagerImpl(MY_TOPIC) {
+            @Override
+            protected List<TopicSink> getTopicSinks() {
+                return Collections.emptyList();
+            }
+        });
+    }
+
+    @Test
+    void testStartPublisher() throws PoolingFeatureException {
+
+        mgr.startPublisher();
+
+        // restart should have no effect
+        mgr.startPublisher();
+
+        // should be able to publish now
+        mgr.publish(MSG);
+        verify(sink).send(MSG);
+    }
+
+    @Test
+    void testStopPublisher() {
+        // not publishing yet, so stopping should have no effect
+        mgr.stopPublisher(0);
+
+        // now start it
+        mgr.startPublisher();
+
+        // this time, stop should do something
+        mgr.stopPublisher(0);
+
+        // re-stopping should have no effect
+        assertThatCode(() -> mgr.stopPublisher(0)).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testStopPublisher_WithDelay() {
+
+        mgr.startPublisher();
+
+        long tbeg = System.currentTimeMillis();
+
+        mgr.stopPublisher(100L);
+
+        assertTrue(System.currentTimeMillis() >= tbeg + 100L);
+    }
+
+    @Test
+    void testStopPublisher_WithDelayInterrupted() throws Exception {
+
+        mgr.startPublisher();
+
+        long minms = 2000L;
+
+        // tell the publisher to stop in minms + additional time
+        CountDownLatch latch = new CountDownLatch(1);
+        Thread thread = new Thread(() -> {
+            latch.countDown();
+            mgr.stopPublisher(minms + 3000L);
+        });
+        thread.start();
+
+        // wait for the thread to start
+        latch.await();
+
+        // interrupt it - it should immediately finish its work
+        thread.interrupt();
+
+        // wait for it to stop, but only wait the minimum time
+        thread.join(minms);
+
+        assertFalse(thread.isAlive());
+    }
+
+    @Test
+    void testStartConsumer() {
+        // not started yet
+        verify(source, never()).register(any());
+
+        mgr.startConsumer(listener);
+        verify(source).register(listener);
+
+        // restart should have no effect
+        mgr.startConsumer(listener);
+        verify(source).register(listener);
+    }
+
+    @Test
+    void testStopConsumer() {
+        // not consuming yet, so stopping should have no effect
+        mgr.stopConsumer(listener);
+        verify(source, never()).unregister(any());
+
+        // now start it
+        mgr.startConsumer(listener);
+
+        // this time, stop should do something
+        mgr.stopConsumer(listener);
+        verify(source).unregister(listener);
+
+        // re-stopping should have no effect
+        mgr.stopConsumer(listener);
+        verify(source).unregister(listener);
+    }
+
+    @Test
+    void testPublish() throws PoolingFeatureException {
+        // cannot publish before starting
+        assertThatThrownBy(() -> mgr.publish(MSG)).as("publish,pre").isInstanceOf(PoolingFeatureException.class);
+
+        mgr.startPublisher();
+
+        // publish several messages
+        mgr.publish(MSG);
+        verify(sink).send(MSG);
+
+        mgr.publish(MSG + "a");
+        verify(sink).send(MSG + "a");
+
+        mgr.publish(MSG + "b");
+        verify(sink).send(MSG + "b");
+
+        // stop and verify we can no longer publish
+        mgr.stopPublisher(0);
+        assertThatThrownBy(() -> mgr.publish(MSG)).as("publish,stopped").isInstanceOf(PoolingFeatureException.class);
+    }
+
+    @Test
+    void testPublish_SendFailed() {
+        mgr.startPublisher();
+
+        // arrange for send() to fail
+        when(sink.send(MSG)).thenReturn(false);
+
+        assertThrows(PoolingFeatureException.class, () -> mgr.publish(MSG));
+    }
+
+    @Test
+    void testPublish_SendEx() {
+        mgr.startPublisher();
+
+        // arrange for send() to throw an exception
+        doThrow(new IllegalStateException(EXPECTED)).when(sink).send(MSG);
+
+        assertThrows(PoolingFeatureException.class, () -> mgr.publish(MSG));
+    }
+
+    /**
+     * Manager with overrides.
+     */
+    private class TopicMessageManagerImpl extends TopicMessageManager {
+
+        public TopicMessageManagerImpl(String topic) throws PoolingFeatureException {
+            super(topic);
+        }
+
+        @Override
+        protected List<TopicSource> getTopicSources() {
+            gotSources = true;
+
+            // three sources, with the desired one in the middle
+            return Arrays.asList(mock(TopicSource.class), source, mock(TopicSource.class));
+        }
+
+        @Override
+        protected List<TopicSink> getTopicSinks() {
+            gotSinks = true;
+
+            // three sinks, with the desired one in the middle
+            return Arrays.asList(mock(TopicSink.class), sink, mock(TopicSink.class));
+        }
+    }
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/feature-pooling-messages.properties b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/feature-pooling-messages.properties
new file mode 100644
index 0000000..b89e406
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/feature-pooling-messages.properties
@@ -0,0 +1,47 @@
+# Copyright 2018 AT&T Intellectual Property. All rights reserved
+# Modifications Copyright (C) 2024 Nordix Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+pooling.controllerA.topic = topic.A
+pooling.controllerA.enabled = true
+pooling.controllerA.offline.queue.limit = 5
+pooling.controllerA.offline.queue.age.milliseconds = 100
+pooling.controllerA.start.heartbeat.milliseconds = 10
+pooling.controllerA.reactivate.milliseconds = 20
+pooling.controllerA.identification.milliseconds = 30
+pooling.controllerA.active.heartbeat.milliseconds = 40
+pooling.controllerA.inter.heartbeat.milliseconds = 50
+
+pooling.controllerB.topic = topic.B
+pooling.controllerB.enabled = true
+pooling.controllerB.offline.queue.limit = 6
+pooling.controllerB.offline.queue.age.milliseconds = 101
+pooling.controllerB.start.heartbeat.milliseconds = 11
+pooling.controllerB.reactivate.milliseconds = 21
+pooling.controllerB.identification.milliseconds = 31
+pooling.controllerB.active.heartbeat.milliseconds = 41
+pooling.controllerB.inter.heartbeat.milliseconds = 51
+
+pooling.controllerDisabled.enabled = false
+
+# this has an invalid property
+pooling.controllerException.topic = topic.B
+pooling.controllerException.enabled = true
+pooling.controllerException.offline.queue.limit = INVALID NUMBER
+pooling.controllerException.offline.queue.age.milliseconds = 101
+pooling.controllerException.start.heartbeat.milliseconds = 11
+pooling.controllerException.reactivate.milliseconds = 21
+pooling.controllerException.identification.milliseconds = 31
+pooling.controllerException.active.heartbeat.milliseconds = 41
+pooling.controllerException.inter.heartbeat.milliseconds = 51
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/BucketAssignmentsTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/BucketAssignmentsTest.java
new file mode 100644
index 0000000..ca47f9c
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/BucketAssignmentsTest.java
@@ -0,0 +1,361 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2020 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.util.Arrays;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import org.junit.jupiter.api.Test;
+import org.onap.policy.drools.pooling.PoolingFeatureException;
+
+class BucketAssignmentsTest {
+
+    @Test
+    void testBucketAssignments() {
+        assertThatCode(BucketAssignments::new).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testBucketAssignmentsStringArray() {
+        String[] arr = {"abc", "def"};
+        BucketAssignments asgn = new BucketAssignments(arr);
+
+        assertNotNull(asgn.getHostArray());
+        assertEquals(Arrays.toString(arr), Arrays.toString(asgn.getHostArray()));
+    }
+
+    @Test
+    void testGetHostArray_testSetHostArray() {
+
+        String[] arr = {"abc", "def"};
+        BucketAssignments asgn = new BucketAssignments(arr);
+
+        assertNotNull(asgn.getHostArray());
+        assertEquals(Arrays.toString(arr), Arrays.toString(asgn.getHostArray()));
+
+        String[] arr2 = {"xyz"};
+        asgn.setHostArray(arr2);
+
+        assertNotNull(asgn.getHostArray());
+        assertEquals(Arrays.toString(arr2), Arrays.toString(asgn.getHostArray()));
+    }
+
+    @Test
+    void testGetLeader() {
+        // host array is null
+        BucketAssignments asgn = new BucketAssignments();
+        assertNull(asgn.getLeader());
+
+        // array is non-null, but empty
+        asgn.setHostArray(new String[0]);
+        assertNull(asgn.getLeader());
+
+        // all entries are null
+        asgn.setHostArray(new String[5]);
+        assertNull(asgn.getLeader());
+
+        // some entries are null
+        asgn.setHostArray(new String[] {null, "abc", null});
+        assertEquals("abc", asgn.getLeader());
+
+        // only one entry
+        asgn.setHostArray(new String[] {"abc"});
+        assertEquals("abc", asgn.getLeader());
+
+        // first is least
+        asgn.setHostArray(new String[] {"Ahost", "Bhost", "Chost"});
+        assertEquals("Ahost", asgn.getLeader());
+
+        // middle is least
+        asgn.setHostArray(new String[] {"Xhost", "Bhost", "Chost"});
+        assertEquals("Bhost", asgn.getLeader());
+
+        // last is least
+        asgn.setHostArray(new String[] {"Xhost", "Yhost", "Chost"});
+        assertEquals("Chost", asgn.getLeader());
+
+        // multiple entries
+        asgn.setHostArray(new String[] {"Xhost", "Bhost", "Chost", "Bhost", "Xhost", "Chost"});
+        assertEquals("Bhost", asgn.getLeader());
+    }
+
+    @Test
+    void testHasAssignment() {
+        // host array is null
+        BucketAssignments asgn = new BucketAssignments();
+        assertFalse(asgn.hasAssignment("abc"));
+
+        // array is non-null, but empty
+        asgn.setHostArray(new String[0]);
+        assertFalse(asgn.hasAssignment("abc"));
+
+        // all entries are null
+        asgn.setHostArray(new String[5]);
+        assertFalse(asgn.hasAssignment("abc"));
+
+        // some entries are null
+        asgn.setHostArray(new String[] {null, "abc", null});
+        assertTrue(asgn.hasAssignment("abc"));
+
+        // only one entry
+        asgn.setHostArray(new String[] {"abc"});
+        assertTrue(asgn.hasAssignment("abc"));
+
+        // appears as first entry
+        asgn.setHostArray(new String[] {"abc", "Bhost", "Chost"});
+        assertTrue(asgn.hasAssignment("abc"));
+
+        // appears in middle
+        asgn.setHostArray(new String[] {"Xhost", "abc", "Chost"});
+        assertTrue(asgn.hasAssignment("abc"));
+
+        // appears last
+        asgn.setHostArray(new String[] {"Xhost", "Yhost", "abc"});
+        assertTrue(asgn.hasAssignment("abc"));
+
+        // appears repeatedly
+        asgn.setHostArray(new String[] {"Xhost", "Bhost", "Chost", "Bhost", "Xhost", "Chost"});
+        assertTrue(asgn.hasAssignment("Bhost"));
+    }
+
+    @Test
+    void testGetAllHosts() {
+        // host array is null
+        BucketAssignments asgn = new BucketAssignments();
+        assertEquals("[]", getSortedHosts(asgn).toString());
+
+        // array is non-null, but empty
+        asgn.setHostArray(new String[0]);
+        assertEquals("[]", getSortedHosts(asgn).toString());
+
+        // all entries are null
+        asgn.setHostArray(new String[5]);
+        assertEquals("[]", getSortedHosts(asgn).toString());
+
+        // some entries are null
+        asgn.setHostArray(new String[] {null, "abc", null});
+        assertEquals("[abc]", getSortedHosts(asgn).toString());
+
+        // only one entry
+        asgn.setHostArray(new String[] {"abc"});
+        assertEquals("[abc]", getSortedHosts(asgn).toString());
+
+        // multiple, repeated entries
+        asgn.setHostArray(new String[] {"def", "abc", "def", "ghi", "def", "def", "xyz"});
+        assertEquals("[abc, def, ghi, xyz]", getSortedHosts(asgn).toString());
+    }
+
+    /**
+     * Gets the hosts, sorted, so that the order is predictable.
+     *
+     * @param asgn assignment whose hosts are to be retrieved
+     * @return a new, sorted set of hosts
+     */
+    private SortedSet<String> getSortedHosts(BucketAssignments asgn) {
+        return new TreeSet<>(asgn.getAllHosts());
+    }
+
+    @Test
+    void testGetAssignedHost() {
+        // host array is null
+        BucketAssignments asgn = new BucketAssignments();
+        assertNull(asgn.getAssignedHost(3));
+
+        // array is non-null, but empty
+        asgn.setHostArray(new String[0]);
+        assertNull(asgn.getAssignedHost(3));
+
+        // all entries are null
+        asgn.setHostArray(new String[5]);
+        assertNull(asgn.getAssignedHost(3));
+
+        // multiple, repeated entries
+        String[] arr = {"def", "abc", "def", "ghi", "def", "def", "xyz"};
+        asgn.setHostArray(arr);
+
+        /*
+         * get assignments for consecutive integers, including negative numbers and
+         * numbers extending past the length of the array.
+         *
+         */
+        TreeSet<String> seen = new TreeSet<>();
+        for (int x = -1; x < arr.length + 2; ++x) {
+            seen.add(asgn.getAssignedHost(x));
+        }
+
+        TreeSet<String> expected = new TreeSet<>(Arrays.asList(arr));
+        assertEquals(expected, seen);
+
+        // try a much bigger number
+        assertNotNull(asgn.getAssignedHost(arr.length * 1000));
+    }
+
+    @Test
+    void testSize() {
+        // host array is null
+        BucketAssignments asgn = new BucketAssignments();
+        assertEquals(0, asgn.size());
+
+        // array is non-null, but empty
+        asgn.setHostArray(new String[0]);
+        assertEquals(0, asgn.size());
+
+        // all entries are null
+        asgn.setHostArray(new String[5]);
+        assertEquals(5, asgn.size());
+
+        // multiple, repeated entries
+        String[] arr = {"def", "abc", "def", "ghi", "def", "def", "xyz"};
+        asgn.setHostArray(arr);
+        assertEquals(arr.length, asgn.size());
+    }
+
+    @Test
+    void testCheckValidity() throws Exception {
+        // host array is null
+        BucketAssignments asgn = new BucketAssignments();
+        expectException(asgn);
+
+        // array is non-null, but empty
+        asgn.setHostArray(new String[0]);
+        expectException(asgn);
+
+        // array is too big
+        asgn.setHostArray(new String[BucketAssignments.MAX_BUCKETS + 1]);
+        expectException(asgn);
+
+        // all entries are null
+        asgn.setHostArray(new String[5]);
+        expectException(asgn);
+
+        // null at the beginning
+        asgn.setHostArray(new String[] {null, "Bhost", "Chost"});
+        expectException(asgn);
+
+        // null in the middle
+        asgn.setHostArray(new String[] {"Ahost", null, "Chost"});
+        expectException(asgn);
+
+        // null at the end
+        asgn.setHostArray(new String[] {"Ahost", "Bhost", null});
+        expectException(asgn);
+
+        // only one entry
+        asgn.setHostArray(new String[] {"abc"});
+        asgn.checkValidity();
+
+        // multiple entries
+        asgn.setHostArray(new String[] {"Ahost", "Bhost", "Chost"});
+        asgn.checkValidity();
+    }
+
+    @Test
+    void testHashCode() {
+        // with null assignments
+        BucketAssignments asgn = new BucketAssignments();
+        asgn.hashCode();
+
+        // with empty array
+        asgn = new BucketAssignments(new String[0]);
+        asgn.hashCode();
+
+        // with null items
+        asgn = new BucketAssignments(new String[] {"abc", null, "def"});
+        asgn.hashCode();
+
+        // same assignments
+        asgn = new BucketAssignments(new String[] {"abc", null, "def"});
+        int code = asgn.hashCode();
+
+        asgn = new BucketAssignments(new String[] {"abc", null, "def"});
+        assertEquals(code, asgn.hashCode());
+
+        // slightly different values (i.e., changed "def" to "eef")
+        asgn = new BucketAssignments(new String[] {"abc", null, "eef"});
+        assertNotEquals(code, asgn.hashCode());
+    }
+
+    @Test
+    void testEquals() {
+        // null object
+        BucketAssignments asgn = new BucketAssignments();
+        assertNotEquals(null, asgn);
+
+        // same object
+        asgn = new BucketAssignments();
+        assertEquals(asgn, asgn);
+
+        // different class of object
+        asgn = new BucketAssignments();
+        assertNotEquals("not an assignment object", asgn);
+
+        assertNotEquals(asgn, new BucketAssignments(new String[] {"abc"}));
+
+        // with null assignments
+        asgn = new BucketAssignments();
+        assertEquals(asgn, new BucketAssignments());
+
+        // with empty array
+        asgn = new BucketAssignments(new String[0]);
+        assertEquals(asgn, asgn);
+
+        assertNotEquals(asgn, new BucketAssignments());
+        assertNotEquals(asgn, new BucketAssignments(new String[] {"abc"}));
+
+        // with null items
+        String[] arr = {"abc", null, "def"};
+        asgn = new BucketAssignments(arr);
+        assertEquals(asgn, asgn);
+        assertEquals(asgn, new BucketAssignments(arr));
+        assertEquals(asgn, new BucketAssignments(new String[] {"abc", null, "def"}));
+
+        assertNotEquals(asgn, new BucketAssignments());
+        assertNotEquals(asgn, new BucketAssignments(new String[] {"abc", null, "XYZ"}));
+
+        assertNotEquals(asgn, new BucketAssignments());
+    }
+
+    /**
+     * Expects an exception when checkValidity() is called.
+     *
+     * @param asgn assignments to be checked
+     */
+    private void expectException(BucketAssignments asgn) {
+        try {
+            asgn.checkValidity();
+            fail("missing exception");
+
+        } catch (PoolingFeatureException expected) {
+            // success
+        }
+    }
+
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/HeartbeatTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/HeartbeatTest.java
new file mode 100644
index 0000000..142ebbc
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/HeartbeatTest.java
@@ -0,0 +1,59 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class HeartbeatTest extends SupportBasicMessageTester<Heartbeat> {
+    
+    /**
+     * Sequence number to validate time stamps within the heart beat.
+     */
+    private long sequence = 0;
+
+    public HeartbeatTest() {
+        super(Heartbeat.class);
+    }
+
+    @Override
+    public Heartbeat makeValidMessage() {
+        Heartbeat msg = new Heartbeat(VALID_HOST, ++sequence);
+        msg.setChannel(VALID_CHANNEL);
+
+        return msg;
+    }
+
+    @Override
+    public void testDefaultConstructorFields(Heartbeat msg) {
+        super.testDefaultConstructorFields(msg);
+        
+        assertEquals(sequence, msg.getTimestampMs());
+    }
+
+    @Override
+    public void testValidFields(Heartbeat msg) {
+        super.testValidFields(msg);
+        
+        assertEquals(sequence, msg.getTimestampMs());
+    }
+
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/IdentificationTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/IdentificationTest.java
new file mode 100644
index 0000000..dced372
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/IdentificationTest.java
@@ -0,0 +1,74 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class IdentificationTest extends SupportMessageWithAssignmentsTester<Identification> {
+
+    public IdentificationTest() {
+        super(Identification.class);
+    }
+
+    @BeforeEach
+    public void setUp() {
+        setNullAssignments(false);
+    }
+
+    /**
+     * The superclass will already invoke testJsonEncodeDecode() to verify that
+     * things work with a fully populated message. This verifies that it also
+     * works if the assignments are null.
+     * 
+     * @throws Exception if an error occurs
+     */
+    @Test
+    final void testJsonEncodeDecode_WithNullAssignments() throws Exception {
+        setNullAssignments(true);
+        testJsonEncodeDecode();
+    }
+
+    /**
+     * The superclass will already invoke testCheckValidity() to
+     * verify that things work with a fully populated message. This verifies
+     * that it also works if the assignments are null.
+     * 
+     * @throws Exception if an error occurs
+     */
+    @Test
+    void testCheckValidity_NullAssignments() throws Exception {
+        // null assignments are OK
+        Identification msg = makeValidMessage();
+        msg.setAssignments(null);
+        msg.checkValidity();
+    }
+
+    @Override
+    public Identification makeValidMessage() {
+        Identification msg = new Identification(VALID_HOST, (isNullAssignments() ? null : VALID_ASGN));
+        msg.setChannel(VALID_CHANNEL);
+
+        return msg;
+    }
+
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/LeaderTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/LeaderTest.java
new file mode 100644
index 0000000..15d9d33
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/LeaderTest.java
@@ -0,0 +1,73 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class LeaderTest extends SupportMessageWithAssignmentsTester<Leader> {
+
+    public LeaderTest() {
+        super(Leader.class);
+    }
+
+    @BeforeEach
+    public void setUp() {
+        setNullAssignments(false);
+    }
+
+    /**
+     * The superclass will already invoke testCheckValidity_InvalidFields() to
+     * verify that things work with a fully populated message. This verifies
+     * that it also works if the assignments are null.
+     *
+     */
+    @Test
+    void testCheckValidity_InvalidFields_NullAssignments() {
+        // null assignments are invalid
+        expectCheckValidityFailure(msg -> msg.setAssignments(null));
+    }
+
+    @Test
+    void testCheckValidity_SourceIsNotLeader() {
+        Leader ldr = makeValidMessage();
+
+        ldr.setSource("xyz");
+
+        // the source does not have an assignment
+        BucketAssignments asgnUnassigned = new BucketAssignments(new String[] {"abc", "def"});
+        expectCheckValidityFailure(msg -> msg.setAssignments(asgnUnassigned));
+
+        // the source is not the smallest UUID in this assignment
+        BucketAssignments asgnNotSmallest = new BucketAssignments(new String[] {VALID_HOST_PREDECESSOR, VALID_HOST});
+        expectCheckValidityFailure(msg -> msg.setAssignments(asgnNotSmallest));
+    }
+
+    @Override
+    public Leader makeValidMessage() {
+        Leader msg = new Leader(VALID_HOST, (isNullAssignments() ? null : VALID_ASGN));
+        msg.setChannel(VALID_CHANNEL);
+
+        return msg;
+    }
+
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/MessageTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/MessageTest.java
new file mode 100644
index 0000000..cf2cb69
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/MessageTest.java
@@ -0,0 +1,82 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.Test;
+
+class MessageTest extends SupportBasicMessageTester<Message> {
+
+    public MessageTest() {
+        super(Message.class);
+    }
+
+    @Test
+    void testGetSource_testSetSource() {
+        Message msg = new Message();
+
+        msg.setSource("hello");
+        assertEquals("hello", msg.getSource());
+        assertNull(msg.getChannel());
+
+        msg.setSource("world");
+        assertEquals("world", msg.getSource());
+        assertNull(msg.getChannel());
+    }
+
+    @Test
+    void testGetChannel_testSetChannel() {
+        Message msg = new Message();
+
+        msg.setChannel("hello");
+        assertEquals("hello", msg.getChannel());
+        assertNull(msg.getSource());
+
+        msg.setChannel("world");
+        assertEquals("world", msg.getChannel());
+        assertNull(msg.getSource());
+    }
+
+    @Test
+    void testCheckValidity_InvalidFields() {
+        // null or empty source
+        expectCheckValidityFailure_NullOrEmpty((msg, value) -> msg.setSource(value));
+
+        // null or empty channel
+        expectCheckValidityFailure_NullOrEmpty((msg, value) -> msg.setChannel(value));
+    }
+
+    /**
+     * Makes a message that will pass the validity check.
+     * 
+     * @return a valid Message
+     */
+    public Message makeValidMessage() {
+        Message msg = new Message(VALID_HOST);
+        msg.setChannel(VALID_CHANNEL);
+
+        return msg;
+    }
+
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/OfflineTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/OfflineTest.java
new file mode 100644
index 0000000..29a7fad
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/OfflineTest.java
@@ -0,0 +1,38 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+public class OfflineTest extends SupportBasicMessageTester<Offline> {
+
+    public OfflineTest() {
+        super(Offline.class);
+    }
+
+    @Override
+    public Offline makeValidMessage() {
+        Offline msg = new Offline(VALID_HOST);
+        msg.setChannel(VALID_CHANNEL);
+
+        return msg;
+    }
+
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/QueryTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/QueryTest.java
new file mode 100644
index 0000000..737bcc1
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/QueryTest.java
@@ -0,0 +1,38 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+public class QueryTest extends SupportBasicMessageTester<Query> {
+
+    public QueryTest() {
+        super(Query.class);
+    }
+
+    @Override
+    public Query makeValidMessage() {
+        Query msg = new Query(VALID_HOST);
+        msg.setChannel(VALID_CHANNEL);
+
+        return msg;
+    }
+
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/SupportBasicMessageTester.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/SupportBasicMessageTester.java
new file mode 100644
index 0000000..2fe905a
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/SupportBasicMessageTester.java
@@ -0,0 +1,251 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018-2019 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import org.junit.jupiter.api.Test;
+import org.onap.policy.drools.pooling.PoolingFeatureException;
+import org.onap.policy.drools.pooling.Serializer;
+
+/**
+ * Superclass used to test subclasses of {@link Message}.
+ * 
+ * @param <T> type of {@link Message} subclass that this tests
+ */
+public abstract class SupportBasicMessageTester<T extends Message> {
+    // values set by makeValidMessage()
+    public static final String VALID_HOST_PREDECESSOR = "hostA";
+    public static final String VALID_HOST = "hostB";
+    public static final String VALID_CHANNEL = "channelC";
+
+    /**
+     * Used to perform JSON serialization and de-serialization.
+     */
+    public final Serializer mapper = new Serializer();
+
+    /**
+     * The subclass of the type of Message being tested.
+     */
+    private final Class<T> subclazz;
+
+    /**
+     * Constructor.
+     * 
+     * @param subclazz subclass of {@link Message} being tested
+     */
+    public SupportBasicMessageTester(Class<T> subclazz) {
+        this.subclazz = subclazz;
+    }
+
+    /**
+     * Creates a default Message and verifies that the source and channel are
+     * {@code null}.
+     * 
+     */
+    @Test
+    public final void testDefaultConstructor() {
+        testDefaultConstructorFields(makeDefaultMessage());
+    }
+
+    /**
+     * Tests that the Message has the correct source, and that the channel is
+     * {@code null}.
+     * 
+     */
+    @Test
+    public final void testConstructorWithArgs() {
+        testValidFields(makeValidMessage());
+    }
+
+    /**
+     * Makes a valid message and then verifies that it can be serialized and
+     * de-serialized. Verifies that the de-serialized message is of the same
+     * type, and has the same content, as the original.
+     * 
+     * @throws Exception if an error occurs
+     */
+    @Test
+    public final void testJsonEncodeDecode() throws Exception {
+        T originalMsg = makeValidMessage();
+
+        Message msg;
+        if (originalMsg.getClass() == Message.class) {
+            msg = originalMsg;
+        } else {
+            msg = mapper.decodeMsg(mapper.encodeMsg(originalMsg));
+        }
+        
+        assertEquals(subclazz, msg.getClass());
+
+        msg.checkValidity();
+
+        testValidFields(subclazz.cast(msg));
+    }
+
+    /**
+     * Creates a valid Message and verifies that checkValidity() passes.
+     * 
+     * @throws PoolingFeatureException if an error occurs
+     */
+    @Test
+    public final void testCheckValidity_Ok() throws PoolingFeatureException {
+        T msg = makeValidMessage();
+        msg.checkValidity();
+
+        testValidFields(subclazz.cast(msg));
+    }
+
+    /**
+     * Creates a default Message and verifies that checkValidity() fails. Does
+     * not throw an exception.
+     */
+    @Test
+    public final void testCheckValidity_DefaultConstructor() {
+        try {
+            makeDefaultMessage().checkValidity();
+            fail("missing exception");
+
+        } catch (PoolingFeatureException expected) {
+            // success
+        }
+    }
+
+    /**
+     * Creates a message via {@link #makeValidMessage()}, updates it via the
+     * given function, and then invokes the checkValidity() method on it. It is
+     * expected that the checkValidity() will throw an exception.
+     * 
+     * @param func function to update the message prior to invoking
+     *        checkValidity()
+     */
+    public void expectCheckValidityFailure(MessageUpdateFunction<T> func) {
+        try {
+            T msg = makeValidMessage();
+            func.update(msg);
+
+            msg.checkValidity();
+
+            fail("missing exception");
+
+        } catch (PoolingFeatureException expected) {
+            // success
+        }
+    }
+
+    /**
+     * Creates a message via {@link #makeValidMessage()}, updates one of its
+     * fields via the given function, and then invokes the checkValidity()
+     * method on it. It is expected that the checkValidity() will throw an
+     * exception. It checks both the case when the message's field is set to
+     * {@code null}, and when it is set to empty (i.e., "").
+     * 
+     * @param func function to update the message's field prior to invoking
+     *        checkValidity()
+     */
+    public void expectCheckValidityFailure_NullOrEmpty(MessageFieldUpdateFunction<T> func) {
+        expectCheckValidityFailure(msg -> func.update(msg, null));
+        expectCheckValidityFailure(msg -> func.update(msg, ""));
+    }
+
+    /**
+     * Makes a message using the default constructor.
+     * 
+     * @return a new Message
+     */
+    public final T makeDefaultMessage() {
+        try {
+            return subclazz.getConstructor().newInstance();
+
+        } catch (Exception e) {
+            throw new AssertionError(e);
+        }
+    }
+
+
+    // the remaining methods will typically be overridden
+
+    /**
+     * Makes a message that will pass the validity check. Note: this should use
+     * the non-default constructor, and the source and channel should be set to
+     * VALID_HOST and VALID_CHANNEL, respectively.
+     * 
+     * @return a valid Message
+     */
+    public abstract T makeValidMessage();
+
+    /**
+     * Verifies that fields are set as expected by
+     * {@link #makeDefaultMessage()}.
+     * 
+     * @param msg the default Message
+     */
+    public void testDefaultConstructorFields(T msg) {
+        assertNull(msg.getSource());
+        assertNull(msg.getChannel());
+    }
+
+    /**
+     * Verifies that fields are set as expected by {@link #makeValidMessage()}.
+     *
+     * @param msg message whose fields are to be validated
+     */
+    public void testValidFields(T msg) {
+        assertEquals(VALID_HOST, msg.getSource());
+        assertEquals(VALID_CHANNEL, msg.getChannel());
+    }
+
+    /**
+     * Function that updates a message.
+     *
+     * @param <T> type of Message the function updates
+     */
+    @FunctionalInterface
+    public static interface MessageUpdateFunction<T extends Message> {
+
+        /**
+         * Updates a message.
+         *
+         * @param msg message to be updated
+         */
+        void update(T msg);
+    }
+
+    /**
+     * Function that updates a single field within a message.
+     *
+     * @param <T> type of Message the function updates
+     */
+    @FunctionalInterface
+    public static interface MessageFieldUpdateFunction<T extends Message> {
+
+        /**
+         * Updates a field within a message.
+         *
+         * @param msg message to be updated
+         * @param newValue new field value
+         */
+        void update(T msg, String newValue);
+    }
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/SupportMessageWithAssignmentsTester.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/SupportMessageWithAssignmentsTester.java
new file mode 100644
index 0000000..3814553
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/message/SupportMessageWithAssignmentsTester.java
@@ -0,0 +1,103 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.message;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Superclass used to test subclasses of {@link MessageWithAssignments}.
+ * 
+ * @param <T> type of {@link MessageWithAssignments} subclass that this tests
+ */
+@Setter
+@Getter
+public abstract class SupportMessageWithAssignmentsTester<T extends MessageWithAssignments>
+        extends SupportBasicMessageTester<T> {
+    // values set by makeValidMessage()
+    public static final String[] VALID_ARRAY = {VALID_HOST, VALID_HOST + "_xxx"};
+    public static final BucketAssignments VALID_ASGN = new BucketAssignments(VALID_ARRAY);
+
+    /**
+     * {@code True} if {@code null} assignments are allowed, {@code false}
+     * otherwise.
+     */
+    private boolean nullAssignments;
+
+    /**
+     * Constructor.
+     * 
+     * @param subclazz subclass of {@link MessageWithAssignments} being tested
+     */
+    public SupportMessageWithAssignmentsTester(Class<T> subclazz) {
+        super(subclazz);
+    }
+
+    @Test
+    public void testCheckValidity_InvalidFields() {
+        // null source (i.e., superclass field)
+        expectCheckValidityFailure(msg -> msg.setSource(null));
+
+        // empty assignments
+        expectCheckValidityFailure(msg -> msg.setAssignments(new BucketAssignments(new String[0])));
+
+        // invalid assignment
+        String[] invalidAssignment = {"abc", null};
+        expectCheckValidityFailure(msg -> msg.setAssignments(new BucketAssignments(invalidAssignment)));
+    }
+
+    @Test
+    public void testGetAssignments_testSetAssignments() {
+        MessageWithAssignments msg = makeValidMessage();
+
+        // from constructor
+        assertEquals(VALID_ASGN, msg.getAssignments());
+
+        BucketAssignments asgn = new BucketAssignments();
+        msg.setAssignments(asgn);
+        assertEquals(asgn, msg.getAssignments());
+    }
+
+    @Override
+    public void testDefaultConstructorFields(T msg) {
+        super.testDefaultConstructorFields(msg);
+
+        assertNull(msg.getAssignments());
+    }
+
+    @Override
+    public void testValidFields(T msg) {
+        super.testValidFields(msg);
+
+        if (nullAssignments) {
+            assertNull(msg.getAssignments());
+
+        } else {
+            assertEquals(VALID_ASGN, msg.getAssignments());
+        }
+    }
+
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/ActiveStateTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/ActiveStateTest.java
new file mode 100644
index 0000000..ce9adb9
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/ActiveStateTest.java
@@ -0,0 +1,470 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2020 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2020, 2024 Nordix Foundation
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.commons.lang3.tuple.Triple;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.onap.policy.drools.pooling.message.BucketAssignments;
+import org.onap.policy.drools.pooling.message.Heartbeat;
+import org.onap.policy.drools.pooling.message.Leader;
+import org.onap.policy.drools.pooling.message.Offline;
+import org.onap.policy.drools.pooling.message.Query;
+
+class ActiveStateTest extends SupportBasicStateTester {
+
+    private ActiveState state;
+
+    /**
+     * Setup.
+     */
+    @Override
+    @BeforeEach
+    public void setUp() throws Exception {
+        super.setUp();
+
+        state = new ActiveState(mgr);
+    }
+
+    @Test
+    void testStart() {
+        state.start();
+
+        // ensure the timers were created
+        verify(mgr, atLeast(1)).scheduleWithFixedDelay(anyLong(), anyLong(), any(StateTimerTask.class));
+
+        // ensure a heart beat was generated
+        Pair<String, Heartbeat> msg = capturePublishedMessage(Heartbeat.class);
+        assertEquals(MY_HOST, msg.getRight().getSource());
+    }
+
+    @Test
+    void testProcessHeartbeat_NullHost() {
+        assertNull(state.process(new Heartbeat()));
+
+        assertFalse(state.isMyHeartbeatSeen());
+        assertFalse(state.isPredHeartbeatSeen());
+
+        verify(mgr, never()).goInactive();
+        verify(mgr, never()).goQuery();
+    }
+
+    @Test
+    void testProcessHeartbeat_MyHost() {
+        assertNull(state.process(new Heartbeat(MY_HOST, 0L)));
+
+        assertTrue(state.isMyHeartbeatSeen());
+        assertFalse(state.isPredHeartbeatSeen());
+
+        verify(mgr, never()).goInactive();
+        verify(mgr, never()).goQuery();
+    }
+
+    @Test
+    void testProcessHeartbeat_Predecessor() {
+        assertNull(state.process(new Heartbeat(HOST2, 0L)));
+
+        assertFalse(state.isMyHeartbeatSeen());
+        assertTrue(state.isPredHeartbeatSeen());
+
+        verify(mgr, never()).goInactive();
+        verify(mgr, never()).goQuery();
+    }
+
+    @Test
+    void testProcessHeartbeat_OtherHost() {
+        assertNull(state.process(new Heartbeat(HOST3, 0L)));
+
+        assertFalse(state.isMyHeartbeatSeen());
+        assertFalse(state.isPredHeartbeatSeen());
+
+        verify(mgr, never()).goInactive();
+        verify(mgr, never()).goQuery();
+    }
+
+    @Test
+    void testProcessOffline_NullHost() {
+        // should be ignored
+        assertNull(state.process(new Offline()));
+    }
+
+    @Test
+    void testProcessOffline_UnassignedHost() {
+        // HOST4 is not in the assignment list - should be ignored
+        assertNull(state.process(new Offline(HOST4)));
+    }
+
+    @Test
+    void testProcessOffline_IAmLeader() {
+        // configure the next state
+        State next = mock(State.class);
+        when(mgr.goActive()).thenReturn(next);
+
+        // one of the assigned hosts went offline
+        assertEquals(next, state.process(new Offline(HOST1)));
+
+        // should have sent a new Leader message
+        Leader msg = captureAdminMessage(Leader.class);
+
+        assertEquals(MY_HOST, msg.getSource());
+
+        // check new bucket assignments
+        assertEquals(Arrays.asList(MY_HOST, MY_HOST, HOST2), Arrays.asList(msg.getAssignments().getHostArray()));
+    }
+
+    @Test
+    void testProcessOffline_PredecessorIsLeaderNowOffline() {
+        // configure the next state
+        State next = mock(State.class);
+        when(mgr.goActive()).thenReturn(next);
+
+        // I am not the leader, but my predecessor was
+        mgr.startDistributing(new BucketAssignments(new String[] {PREV_HOST, MY_HOST, HOST1}));
+        state = new ActiveState(mgr);
+
+        // my predecessor went offline
+        assertEquals(next, state.process(new Offline(PREV_HOST)));
+
+        // should have sent a new Leader message
+        Leader msg = captureAdminMessage(Leader.class);
+
+        assertEquals(MY_HOST, msg.getSource());
+
+        // check new bucket assignments
+        assertEquals(Arrays.asList(MY_HOST, MY_HOST, HOST1), Arrays.asList(msg.getAssignments().getHostArray()));
+    }
+
+    @Test
+    void testProcessOffline__PredecessorIsNotLeaderNowOffline() {
+        // I am not the leader, and neither is my predecessor
+        mgr.startDistributing(new BucketAssignments(new String[] {PREV_HOST, MY_HOST, PREV_HOST2}));
+        state = new ActiveState(mgr);
+
+        /*
+         *
+         * PREV_HOST2 has buckets and is my predecessor, but it isn't the leader thus
+         * should be ignored.
+         */
+        assertNull(state.process(new Offline(PREV_HOST2)));
+    }
+
+    @Test
+    void testProcessOffline_OtherAssignedHostOffline() {
+        // I am not the leader
+        mgr.startDistributing(new BucketAssignments(new String[] {PREV_HOST, MY_HOST, HOST1}));
+        state = new ActiveState(mgr);
+
+        /*
+         * HOST1 has buckets, but it isn't the leader and it isn't my predecessor, thus
+         * should be ignored.
+         */
+        assertNull(state.process(new Offline(HOST1)));
+    }
+
+    @Test
+    void testProcessLeader_Invalid() {
+        Leader msg = new Leader(PREV_HOST, null);
+
+        // should stay in the same state, and not start distributing
+        assertNull(state.process(msg));
+        verify(mgr, never()).startDistributing(any());
+        verify(mgr, never()).goActive();
+        verify(mgr, never()).goInactive();
+
+        // info should be unchanged
+        assertEquals(MY_HOST, state.getLeader());
+        assertEquals(ASGN3, state.getAssignments());
+    }
+
+    @Test
+    void testProcessLeader_BadLeader() {
+        String[] arr = {HOST2, HOST1};
+        BucketAssignments asgn = new BucketAssignments(arr);
+
+        // now send a Leader message for that leader
+        Leader msg = new Leader(HOST1, asgn);
+
+        State next = mock(State.class);
+        when(mgr.goQuery()).thenReturn(next);
+
+        // should go Query, but not start distributing
+        assertEquals(next, state.process(msg));
+        verify(mgr, never()).startDistributing(asgn);
+    }
+
+    @Test
+    void testProcessLeader_GoodLeader() {
+        String[] arr = {HOST2, PREV_HOST, MY_HOST};
+        BucketAssignments asgn = new BucketAssignments(arr);
+
+        // now send a Leader message for that leader
+        Leader msg = new Leader(PREV_HOST, asgn);
+
+        State next = mock(State.class);
+        when(mgr.goActive()).thenReturn(next);
+
+        // should go Active and start distributing
+        assertEquals(next, state.process(msg));
+        verify(mgr).startDistributing(asgn);
+    }
+
+    @Test
+    void testActiveState() {
+        assertEquals(MY_HOST, state.getLeader());
+        assertEquals(ASGN3, state.getAssignments());
+
+        // verify that it determined its neighbors
+        assertEquals(HOST1, state.getSuccHost());
+        assertEquals(HOST2, state.getPredHost());
+    }
+
+    @Test
+    void testDetmNeighbors() {
+        // if only one host (i.e., itself)
+        mgr.startDistributing(new BucketAssignments(new String[] {MY_HOST, MY_HOST}));
+        state = new ActiveState(mgr);
+        assertNull(state.getSuccHost());
+        assertEquals("", state.getPredHost());
+
+        // two hosts
+        mgr.startDistributing(new BucketAssignments(new String[] {MY_HOST, HOST2}));
+        state = new ActiveState(mgr);
+        assertEquals(HOST2, state.getSuccHost());
+        assertEquals(HOST2, state.getPredHost());
+
+        // three hosts
+        mgr.startDistributing(new BucketAssignments(new String[] {HOST3, MY_HOST, HOST2}));
+        state = new ActiveState(mgr);
+        assertEquals(HOST2, state.getSuccHost());
+        assertEquals(HOST3, state.getPredHost());
+
+        // more hosts
+        mgr.startDistributing(new BucketAssignments(new String[] {HOST3, MY_HOST, HOST2, HOST4}));
+        state = new ActiveState(mgr);
+        assertEquals(HOST2, state.getSuccHost());
+        assertEquals(HOST4, state.getPredHost());
+    }
+
+    @Test
+    void testAddTimers_WithPredecessor() {
+        // invoke start() to add the timers
+        state.start();
+
+        assertEquals(3, repeatedSchedules.size());
+
+        Triple<Long, Long, StateTimerTask> timer;
+
+        // heart beat generator
+        timer = repeatedTasks.remove();
+        assertEquals(STD_INTER_HEARTBEAT_MS, timer.getLeft().longValue());
+        assertEquals(STD_INTER_HEARTBEAT_MS, timer.getMiddle().longValue());
+
+        // my heart beat checker
+        timer = repeatedTasks.remove();
+        assertEquals(STD_ACTIVE_HEARTBEAT_MS, timer.getLeft().longValue());
+        assertEquals(STD_ACTIVE_HEARTBEAT_MS, timer.getMiddle().longValue());
+
+        // predecessor's heart beat checker
+        timer = repeatedTasks.remove();
+        assertEquals(STD_ACTIVE_HEARTBEAT_MS, timer.getLeft().longValue());
+        assertEquals(STD_ACTIVE_HEARTBEAT_MS, timer.getMiddle().longValue());
+    }
+
+    @Test
+    void testAddTimers_SansPredecessor() {
+        // only one host, thus no predecessor
+        mgr.startDistributing(new BucketAssignments(new String[] {MY_HOST, MY_HOST}));
+        state = new ActiveState(mgr);
+
+        // invoke start() to add the timers
+        state.start();
+
+        assertEquals(2, repeatedSchedules.size());
+
+        Triple<Long, Long, StateTimerTask> timer;
+
+        // heart beat generator
+        timer = repeatedTasks.remove();
+        assertEquals(STD_INTER_HEARTBEAT_MS, timer.getLeft().longValue());
+        assertEquals(STD_INTER_HEARTBEAT_MS, timer.getMiddle().longValue());
+
+        // my heart beat checker
+        timer = repeatedTasks.remove();
+        assertEquals(STD_ACTIVE_HEARTBEAT_MS, timer.getLeft().longValue());
+        assertEquals(STD_ACTIVE_HEARTBEAT_MS, timer.getMiddle().longValue());
+    }
+
+    @Test
+    void testAddTimers_HeartbeatGenerator() {
+        // only one host so we only have to look at one heart beat at a time
+        mgr.startDistributing(new BucketAssignments(new String[] {MY_HOST}));
+        state = new ActiveState(mgr);
+
+        // invoke start() to add the timers
+        state.start();
+
+        Triple<Long, Long, StateTimerTask> task = repeatedTasks.remove();
+
+        verify(mgr).publish(anyString(), any(Heartbeat.class));
+
+        // fire the task
+        assertNull(task.getRight().fire());
+
+        // should have generated a second pair of heart beats
+        verify(mgr, times(2)).publish(anyString(), any(Heartbeat.class));
+
+        Pair<String, Heartbeat> msg = capturePublishedMessage(Heartbeat.class);
+        assertEquals(MY_HOST, msg.getLeft());
+        assertEquals(MY_HOST, msg.getRight().getSource());
+    }
+
+    @Test
+    void testAddTimers_MyHeartbeatSeen() {
+        // invoke start() to add the timers
+        state.start();
+
+        Triple<Long, Long, StateTimerTask> task = repeatedTasks.get(1);
+
+        // indicate that this host is still alive
+        state.process(new Heartbeat(MY_HOST, 0L));
+
+        // set up next state
+        State next = mock(State.class);
+        when(mgr.goInactive()).thenReturn(next);
+
+        // fire the task - should not transition
+        assertNull(task.getRight().fire());
+
+        verify(mgr, never()).publishAdmin(any(Query.class));
+    }
+
+    @Test
+    void testAddTimers_MyHeartbeatMissed() {
+        // invoke start() to add the timers
+        state.start();
+
+        Triple<Long, Long, StateTimerTask> task = repeatedTasks.get(1);
+
+        // set up next state
+        State next = mock(State.class);
+        when(mgr.goStart()).thenReturn(next);
+
+        // fire the task - should transition
+        assertEquals(next, task.getRight().fire());
+
+        // should continue to distribute
+        verify(mgr, never()).startDistributing(null);
+
+        // should publish an offline message
+        Offline msg = captureAdminMessage(Offline.class);
+        assertEquals(MY_HOST, msg.getSource());
+    }
+
+    @Test
+    void testAddTimers_PredecessorHeartbeatSeen() {
+        // invoke start() to add the timers
+        state.start();
+
+        Triple<Long, Long, StateTimerTask> task = repeatedTasks.get(2);
+
+        // indicate that the predecessor is still alive
+        state.process(new Heartbeat(HOST2, 0L));
+
+        // set up next state, just in case
+        State next = mock(State.class);
+        when(mgr.goQuery()).thenReturn(next);
+
+        // fire the task - should NOT transition
+        assertNull(task.getRight().fire());
+
+        verify(mgr, never()).publishAdmin(any(Query.class));
+    }
+
+    @Test
+    void testAddTimers_PredecessorHeartbeatMissed() {
+        // invoke start() to add the timers
+        state.start();
+
+        Triple<Long, Long, StateTimerTask> task = repeatedTasks.get(2);
+
+        // set up next state
+        State next = mock(State.class);
+        when(mgr.goQuery()).thenReturn(next);
+
+        // fire the task - should transition
+        assertEquals(next, task.getRight().fire());
+
+        verify(mgr).publishAdmin(any(Query.class));
+    }
+
+    @Test
+    void testGenHeartbeat_OneHost() {
+        // only one host (i.e., itself)
+        mgr.startDistributing(new BucketAssignments(new String[] {MY_HOST}));
+        state = new ActiveState(mgr);
+
+        state.start();
+
+        verify(mgr, times(1)).publish(any(), any());
+
+        Pair<String, Heartbeat> msg = capturePublishedMessage(Heartbeat.class);
+        assertEquals(MY_HOST, msg.getLeft());
+        assertEquals(MY_HOST, msg.getRight().getSource());
+    }
+
+    @Test
+    void testGenHeartbeat_MultipleHosts() {
+        state.start();
+
+        verify(mgr, times(2)).publish(any(), any());
+
+        Pair<String, Heartbeat> msg;
+        int index = 0;
+
+        // this message should go to itself
+        msg = capturePublishedMessage(Heartbeat.class, index++);
+        assertEquals(MY_HOST, msg.getLeft());
+        assertEquals(MY_HOST, msg.getRight().getSource());
+
+        // this message should go to its successor
+        msg = capturePublishedMessage(Heartbeat.class, index++);
+        assertEquals(HOST1, msg.getLeft());
+        assertEquals(MY_HOST, msg.getRight().getSource());
+    }
+
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/IdleStateTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/IdleStateTest.java
new file mode 100644
index 0000000..51e2773
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/IdleStateTest.java
@@ -0,0 +1,98 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2020 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.onap.policy.drools.pooling.message.BucketAssignments;
+import org.onap.policy.drools.pooling.message.Heartbeat;
+import org.onap.policy.drools.pooling.message.Identification;
+import org.onap.policy.drools.pooling.message.Leader;
+import org.onap.policy.drools.pooling.message.Offline;
+import org.onap.policy.drools.pooling.message.Query;
+
+class IdleStateTest extends SupportBasicStateTester {
+
+    private IdleState state;
+
+    /**
+     * Setup.
+     */
+    @BeforeEach
+    public void setUp() throws Exception {
+        super.setUp();
+
+        state = new IdleState(mgr);
+    }
+
+    @Test
+    void testProcessHeartbeat() {
+        assertNull(state.process(new Heartbeat(PREV_HOST, 0L)));
+        verifyNothingPublished();
+    }
+
+    @Test
+    void testProcessIdentification() {
+        assertNull(state.process(new Identification(PREV_HOST, null)));
+        verifyNothingPublished();
+    }
+
+    @Test
+    void testProcessLeader() {
+        BucketAssignments asgn = new BucketAssignments(new String[] {HOST2, PREV_HOST, MY_HOST});
+        Leader msg = new Leader(PREV_HOST, asgn);
+
+        State next = mock(State.class);
+        when(mgr.goActive()).thenReturn(next);
+
+        // should stay in current state, but start distributing
+        assertNull(state.process(msg));
+        verify(mgr).startDistributing(asgn);
+    }
+
+    @Test
+    void testProcessOffline() {
+        assertNull(state.process(new Offline(PREV_HOST)));
+        verifyNothingPublished();
+    }
+
+    @Test
+    void testProcessQuery() {
+        assertNull(state.process(new Query()));
+        verifyNothingPublished();
+    }
+
+    /**
+     * Verifies that nothing was published on either channel.
+     */
+    private void verifyNothingPublished() {
+        verify(mgr, never()).publish(any(), any());
+        verify(mgr, never()).publishAdmin(any());
+    }
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/InactiveStateTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/InactiveStateTest.java
new file mode 100644
index 0000000..a5ee2d0
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/InactiveStateTest.java
@@ -0,0 +1,121 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2020 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2020, 2024 Nordix Foundation
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.apache.commons.lang3.tuple.Pair;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.onap.policy.drools.pooling.message.BucketAssignments;
+import org.onap.policy.drools.pooling.message.Identification;
+import org.onap.policy.drools.pooling.message.Leader;
+import org.onap.policy.drools.pooling.message.Query;
+
+class InactiveStateTest extends SupportBasicStateTester {
+
+    private InactiveState state;
+
+    /**
+     * Setup.
+     *
+     */
+    @Override
+    @BeforeEach
+    public void setUp() throws Exception {
+        super.setUp();
+
+        state = new InactiveState(mgr);
+    }
+
+    @Test
+    void testProcessLeader() {
+        State next = mock(State.class);
+        when(mgr.goActive()).thenReturn(next);
+
+        String[] arr = {PREV_HOST, MY_HOST, HOST1};
+        BucketAssignments asgn = new BucketAssignments(arr);
+        Leader msg = new Leader(PREV_HOST, asgn);
+
+        assertEquals(next, state.process(msg));
+        verify(mgr).startDistributing(asgn);
+    }
+
+    @Test
+    void testProcessLeader_Invalid() {
+        Leader msg = new Leader(PREV_HOST, null);
+
+        // should stay in the same state, and not start distributing
+        assertNull(state.process(msg));
+        verify(mgr, never()).startDistributing(any());
+        verify(mgr, never()).goActive();
+        verify(mgr, never()).goInactive();
+    }
+
+    @Test
+    void testProcessQuery() {
+        State next = mock(State.class);
+        when(mgr.goQuery()).thenReturn(next);
+
+        assertEquals(next, state.process(new Query()));
+
+        Identification ident = captureAdminMessage(Identification.class);
+        assertEquals(MY_HOST, ident.getSource());
+        assertEquals(ASGN3, ident.getAssignments());
+    }
+
+    @Test
+    void testGoInatcive() {
+        assertNull(state.goInactive());
+    }
+
+    @Test
+    void testStart() {
+        state.start();
+
+        Pair<Long, StateTimerTask> timer = onceTasks.remove();
+
+        assertEquals(STD_REACTIVATE_WAIT_MS, timer.getLeft().longValue());
+
+        // invoke the task - it should go to the state returned by the mgr
+        State next = mock(State.class);
+        when(mgr.goStart()).thenReturn(next);
+
+        assertEquals(next, timer.getRight().fire());
+    }
+
+    @Test
+    void testInactiveState() {
+        /*
+         * Prove the state is attached to the manager by invoking getHost(), which
+         * delegates to the manager.
+         */
+        assertEquals(MY_HOST, state.getHost());
+    }
+
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/ProcessingStateTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/ProcessingStateTest.java
new file mode 100644
index 0000000..dbac761
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/ProcessingStateTest.java
@@ -0,0 +1,396 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2020-2021 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.onap.policy.drools.pooling.message.BucketAssignments;
+import org.onap.policy.drools.pooling.message.Identification;
+import org.onap.policy.drools.pooling.message.Leader;
+import org.onap.policy.drools.pooling.message.Message;
+import org.onap.policy.drools.pooling.message.Query;
+import org.onap.policy.drools.pooling.state.ProcessingState.HostBucket;
+
+class ProcessingStateTest extends SupportBasicStateTester {
+
+    private ProcessingState state;
+    private HostBucket hostBucket;
+
+    /**
+     * Setup.
+     */
+    @BeforeEach
+    public void setUp() throws Exception {
+        super.setUp();
+
+        state = new ProcessingState(mgr, MY_HOST);
+        hostBucket = new HostBucket(MY_HOST);
+    }
+
+    @Test
+    void testProcessQuery() {
+        State next = mock(State.class);
+        when(mgr.goQuery()).thenReturn(next);
+
+        assertEquals(next, state.process(new Query()));
+
+        Identification ident = captureAdminMessage(Identification.class);
+        assertEquals(MY_HOST, ident.getSource());
+        assertEquals(ASGN3, ident.getAssignments());
+    }
+
+    @Test
+    void testProcessingState() {
+        /*
+         * Null assignments should be OK.
+         */
+        when(mgr.getAssignments()).thenReturn(null);
+        state = new ProcessingState(mgr, LEADER);
+
+        /*
+         * Empty assignments should be OK.
+         */
+        when(mgr.getAssignments()).thenReturn(EMPTY_ASGN);
+        state = new ProcessingState(mgr, LEADER);
+        assertEquals(MY_HOST, state.getHost());
+        assertEquals(LEADER, state.getLeader());
+        assertEquals(EMPTY_ASGN, state.getAssignments());
+
+        /*
+         * Now try something with assignments.
+         */
+        when(mgr.getAssignments()).thenReturn(ASGN3);
+        state = new ProcessingState(mgr, LEADER);
+
+        /*
+         * Prove the state is attached to the manager by invoking getHost(), which
+         * delegates to the manager.
+         */
+        assertEquals(MY_HOST, state.getHost());
+
+        assertEquals(LEADER, state.getLeader());
+        assertEquals(ASGN3, state.getAssignments());
+    }
+
+    @Test
+    void testProcessingState_NullLeader() {
+        when(mgr.getAssignments()).thenReturn(EMPTY_ASGN);
+        assertThrows(NullPointerException.class, () -> state = new ProcessingState(mgr, null));
+    }
+
+    @Test
+    void testProcessingState_ZeroLengthHostArray() {
+        when(mgr.getAssignments()).thenReturn(new BucketAssignments(new String[] {}));
+        assertThrows(IllegalArgumentException.class, () -> state = new ProcessingState(mgr, LEADER));
+    }
+
+    @Test
+    void testGetAssignments() {
+        // assignments from constructor
+        assertEquals(ASGN3, state.getAssignments());
+
+        // null assignments - no effect
+        state.setAssignments(null);
+        assertEquals(ASGN3, state.getAssignments());
+
+        // empty assignments
+        state.setAssignments(EMPTY_ASGN);
+        assertEquals(EMPTY_ASGN, state.getAssignments());
+
+        // non-empty assignments
+        state.setAssignments(ASGN3);
+        assertEquals(ASGN3, state.getAssignments());
+    }
+
+    @Test
+    void testSetAssignments() {
+        state.setAssignments(null);
+        verify(mgr, never()).startDistributing(any());
+
+        state.setAssignments(ASGN3);
+        verify(mgr).startDistributing(ASGN3);
+    }
+
+    @Test
+    void testGetLeader() {
+        // check value from constructor
+        assertEquals(MY_HOST, state.getLeader());
+
+        state.setLeader(HOST2);
+        assertEquals(HOST2, state.getLeader());
+
+        state.setLeader(HOST3);
+        assertEquals(HOST3, state.getLeader());
+    }
+
+    @Test
+    void testSetLeader() {
+        state.setLeader(MY_HOST);
+        assertEquals(MY_HOST, state.getLeader());
+    }
+
+    @Test
+    void testSetLeader_Null() {
+        assertThrows(NullPointerException.class, () -> state.setLeader(null));
+    }
+
+    @Test
+    void testIsLeader() {
+        state.setLeader(MY_HOST);
+        assertTrue(state.isLeader());
+
+        state.setLeader(HOST2);
+        assertFalse(state.isLeader());
+    }
+
+    @Test
+    void testBecomeLeader() {
+        State next = mock(State.class);
+        when(mgr.goActive()).thenReturn(next);
+
+        assertEquals(next, state.becomeLeader(sortHosts(MY_HOST, HOST2)));
+
+        Leader msg = captureAdminMessage(Leader.class);
+
+        verify(mgr).startDistributing(msg.getAssignments());
+        verify(mgr).goActive();
+    }
+
+    @Test
+    void testBecomeLeader_NotFirstAlive() {
+        // alive list contains something before my host name
+        var sortedHosts = sortHosts(PREV_HOST, MY_HOST);
+        assertThrows(IllegalArgumentException.class, () -> state.becomeLeader(sortedHosts));
+    }
+
+    @Test
+    void testMakeLeader() throws Exception {
+        state.becomeLeader(sortHosts(MY_HOST, HOST2));
+
+        Leader msg = captureAdminMessage(Leader.class);
+
+        // need a channel before invoking checkValidity()
+        msg.setChannel(Message.ADMIN);
+
+        msg.checkValidity();
+
+        assertEquals(MY_HOST, msg.getSource());
+        assertNotNull(msg.getAssignments());
+        assertTrue(msg.getAssignments().hasAssignment(MY_HOST));
+        assertTrue(msg.getAssignments().hasAssignment(HOST2));
+
+        // this one wasn't in the list of hosts, so it should have been removed
+        assertFalse(msg.getAssignments().hasAssignment(HOST1));
+    }
+
+    @Test
+    void testMakeAssignments() throws Exception {
+        state.becomeLeader(sortHosts(MY_HOST, HOST2));
+
+        captureAssignments().checkValidity();
+    }
+
+    @Test
+    void testMakeBucketArray_NullAssignments() {
+        when(mgr.getAssignments()).thenReturn(null);
+        state = new ProcessingState(mgr, MY_HOST);
+        state.becomeLeader(sortHosts(MY_HOST));
+
+        String[] arr = captureHostArray();
+
+        assertEquals(BucketAssignments.MAX_BUCKETS, arr.length);
+
+        assertTrue(Arrays.stream(arr).allMatch(MY_HOST::equals));
+    }
+
+    @Test
+    void testMakeBucketArray_ZeroAssignments() {
+        // bucket assignment with a zero-length array
+        state.setAssignments(new BucketAssignments(new String[0]));
+
+        state.becomeLeader(sortHosts(MY_HOST));
+
+        String[] arr = captureHostArray();
+
+        assertEquals(BucketAssignments.MAX_BUCKETS, arr.length);
+
+        assertTrue(Arrays.stream(arr).allMatch(MY_HOST::equals));
+    }
+
+    @Test
+    void testMakeBucketArray() {
+        /*
+         * All hosts are still alive, so it should have the exact same assignments as it
+         * had to start.
+         */
+        state.setAssignments(ASGN3);
+        state.becomeLeader(sortHosts(HOST_ARR3));
+
+        String[] arr = captureHostArray();
+
+        assertNotSame(HOST_ARR3, arr);
+        assertEquals(Arrays.asList(HOST_ARR3), Arrays.asList(arr));
+    }
+
+    @Test
+    void testRemoveExcessHosts() {
+        /*
+         * All hosts are still alive, plus some others.
+         */
+        state.setAssignments(ASGN3);
+        state.becomeLeader(sortHosts(MY_HOST, HOST1, HOST2, HOST3, HOST4));
+
+        // assignments should be unchanged
+        assertEquals(Arrays.asList(HOST_ARR3), captureHostList());
+    }
+
+    @Test
+    void testAddIndicesToHostBuckets() {
+        // some are null, some hosts are no longer alive
+        String[] asgn = {null, MY_HOST, HOST3, null, HOST4, HOST1, HOST2};
+
+        state.setAssignments(new BucketAssignments(asgn));
+        state.becomeLeader(sortHosts(MY_HOST, HOST1, HOST2));
+
+        // every bucket should be assigned to one of the three hosts
+        String[] expected = {MY_HOST, MY_HOST, HOST1, HOST2, MY_HOST, HOST1, HOST2};
+        assertEquals(Arrays.asList(expected), captureHostList());
+    }
+
+    @Test
+    void testAssignNullBuckets() {
+        /*
+         * Ensure buckets are assigned to the host with the fewest buckets.
+         */
+        String[] asgn = {MY_HOST, HOST1, MY_HOST, null, null, null, null, null, MY_HOST};
+
+        state.setAssignments(new BucketAssignments(asgn));
+        state.becomeLeader(sortHosts(MY_HOST, HOST1, HOST2));
+
+        String[] expected = {MY_HOST, HOST1, MY_HOST, HOST2, HOST1, HOST2, HOST1, HOST2, MY_HOST};
+        assertEquals(Arrays.asList(expected), captureHostList());
+    }
+
+    @Test
+    void testRebalanceBuckets() {
+        /*
+         * Some are very lopsided.
+         */
+        String[] asgn = {MY_HOST, HOST1, MY_HOST, MY_HOST, MY_HOST, MY_HOST, HOST1, HOST2, HOST1, HOST3};
+
+        state.setAssignments(new BucketAssignments(asgn));
+        state.becomeLeader(sortHosts(MY_HOST, HOST1, HOST2, HOST3));
+
+        String[] expected = {HOST2, HOST1, HOST3, MY_HOST, MY_HOST, MY_HOST, HOST1, HOST2, HOST1, HOST3};
+        assertEquals(Arrays.asList(expected), captureHostList());
+    }
+
+    @Test
+    void testHostBucketRemove_testHostBucketAdd_testHostBucketSize() {
+        assertEquals(0, hostBucket.size());
+
+        hostBucket.add(20);
+        hostBucket.add(30);
+        hostBucket.add(40);
+        assertEquals(3, hostBucket.size());
+
+        assertEquals(20, hostBucket.remove().intValue());
+        assertEquals(30, hostBucket.remove().intValue());
+        assertEquals(1, hostBucket.size());
+
+        // add more before taking the last item
+        hostBucket.add(50);
+        hostBucket.add(60);
+        assertEquals(3, hostBucket.size());
+
+        assertEquals(40, hostBucket.remove().intValue());
+        assertEquals(50, hostBucket.remove().intValue());
+        assertEquals(60, hostBucket.remove().intValue());
+        assertEquals(0, hostBucket.size());
+
+        // add more AFTER taking the last item
+        hostBucket.add(70);
+        assertEquals(70, hostBucket.remove().intValue());
+        assertEquals(0, hostBucket.size());
+    }
+
+    @Test
+    void testHostBucketCompareTo() {
+        HostBucket hb1 = new HostBucket(PREV_HOST);
+        HostBucket hb2 = new HostBucket(MY_HOST);
+
+        assertEquals(0, hb1.compareTo(hb1));
+        assertEquals(0, hb1.compareTo(new HostBucket(PREV_HOST)));
+
+        // both empty
+        assertTrue(hb1.compareTo(hb2) < 0);
+        assertTrue(hb2.compareTo(hb1) > 0);
+
+        // hb1 has one bucket, so it should not be larger
+        hb1.add(100);
+        assertTrue(hb1.compareTo(hb2) > 0);
+        assertTrue(hb2.compareTo(hb1) < 0);
+
+        // hb1 has two buckets, so it should still be larger
+        hb1.add(200);
+        assertTrue(hb1.compareTo(hb2) > 0);
+        assertTrue(hb2.compareTo(hb1) < 0);
+
+        // hb1 has two buckets, hb2 has one, so hb1 should still be larger
+        hb2.add(1000);
+        assertTrue(hb1.compareTo(hb2) > 0);
+        assertTrue(hb2.compareTo(hb1) < 0);
+
+        // same number of buckets, so hb2 should now be larger
+        hb2.add(2000);
+        assertTrue(hb1.compareTo(hb2) < 0);
+        assertTrue(hb2.compareTo(hb1) > 0);
+
+        // hb2 has more buckets, it should still be larger
+        hb2.add(3000);
+        assertTrue(hb1.compareTo(hb2) < 0);
+        assertTrue(hb2.compareTo(hb1) > 0);
+    }
+
+    @Test
+    void testHostBucketHashCode() {
+        assertThrows(UnsupportedOperationException.class, () -> hostBucket.hashCode());
+    }
+
+    @Test
+    void testHostBucketEquals() {
+        assertThrows(UnsupportedOperationException.class, () -> hostBucket.equals(hostBucket));
+    }
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/QueryStateTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/QueryStateTest.java
new file mode 100644
index 0000000..aae6e05
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/QueryStateTest.java
@@ -0,0 +1,444 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2020 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2020, 2024 Nordix Foundation
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.apache.commons.lang3.tuple.Pair;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.onap.policy.drools.pooling.message.BucketAssignments;
+import org.onap.policy.drools.pooling.message.Identification;
+import org.onap.policy.drools.pooling.message.Leader;
+import org.onap.policy.drools.pooling.message.Offline;
+
+class QueryStateTest extends SupportBasicStateTester {
+
+    private QueryState state;
+
+    /**
+     * Setup.
+     */
+    @Override
+    @BeforeEach
+    public void setUp() throws Exception {
+        super.setUp();
+
+        state = new QueryState(mgr);
+    }
+
+    @Test
+    void testGoQuery() {
+        assertNull(state.goQuery());
+    }
+
+    @Test
+    void testStart() {
+        state.start();
+
+        Pair<Long, StateTimerTask> timer = onceTasks.remove();
+
+        assertEquals(STD_IDENTIFICATION_MS, timer.getLeft().longValue());
+        assertNotNull(timer.getRight());
+    }
+
+    @Test
+    void testProcessIdentification_SameSource() {
+        String[] arr = {HOST2, PREV_HOST, MY_HOST};
+        BucketAssignments asgn = new BucketAssignments(arr);
+
+        assertNull(state.process(new Identification(MY_HOST, asgn)));
+
+        // info should be unchanged
+        assertEquals(MY_HOST, state.getLeader());
+        verify(mgr, never()).startDistributing(asgn);
+    }
+
+    @Test
+    void testProcessIdentification_DiffSource() {
+        String[] arr = {HOST2, PREV_HOST, MY_HOST};
+        BucketAssignments asgn = new BucketAssignments(arr);
+
+        assertNull(state.process(new Identification(HOST2, asgn)));
+
+        // leader should be unchanged
+        assertEquals(MY_HOST, state.getLeader());
+
+        // should have picked up the assignments
+        verify(mgr).startDistributing(asgn);
+    }
+
+    @Test
+    void testProcessLeader_Invalid() {
+        Leader msg = new Leader(PREV_HOST, null);
+
+        // should stay in the same state, and not start distributing
+        assertNull(state.process(msg));
+        verify(mgr, never()).startDistributing(any());
+        verify(mgr, never()).goActive();
+        verify(mgr, never()).goInactive();
+
+        // info should be unchanged
+        assertEquals(MY_HOST, state.getLeader());
+        assertEquals(ASGN3, state.getAssignments());
+    }
+
+    @Test
+    void testProcessLeader_SameLeader() {
+        String[] arr = {HOST2, PREV_HOST, MY_HOST};
+        BucketAssignments asgn = new BucketAssignments(arr);
+
+        // identify a leader that's better than my host
+        assertNull(state.process(new Identification(PREV_HOST, asgn)));
+
+        // now send a Leader message for that leader
+        Leader msg = new Leader(PREV_HOST, asgn);
+
+        State next = mock(State.class);
+        when(mgr.goActive()).thenReturn(next);
+
+        // should go Active and start distributing
+        assertEquals(next, state.process(msg));
+        verify(mgr, never()).goInactive();
+
+        // Ident msg + Leader msg = times(2)
+        verify(mgr, times(2)).startDistributing(asgn);
+    }
+
+    @Test
+    void testProcessLeader_BetterLeaderWithAssignment() {
+        String[] arr = {HOST2, PREV_HOST, MY_HOST};
+        BucketAssignments asgn = new BucketAssignments(arr);
+        Leader msg = new Leader(PREV_HOST, asgn);
+
+        State next = mock(State.class);
+        when(mgr.goActive()).thenReturn(next);
+
+        // should go Active and start distributing
+        assertEquals(next, state.process(msg));
+        verify(mgr).startDistributing(asgn);
+        verify(mgr, never()).goInactive();
+    }
+
+    @Test
+    void testProcessLeader_BetterLeaderWithoutAssignment() {
+        String[] arr = {HOST2, PREV_HOST, HOST1};
+        BucketAssignments asgn = new BucketAssignments(arr);
+        Leader msg = new Leader(PREV_HOST, asgn);
+
+        State next = mock(State.class);
+        when(mgr.goInactive()).thenReturn(next);
+
+        // should go Inactive, but start distributing
+        assertEquals(next, state.process(msg));
+        verify(mgr).startDistributing(asgn);
+        verify(mgr, never()).goActive();
+    }
+
+    @Test
+    void testProcessLeader_NotABetterLeader() {
+        // no assignments yet
+        mgr.startDistributing(null);
+        state = new QueryState(mgr);
+
+        BucketAssignments asgn = new BucketAssignments(new String[] {HOST1, HOST2});
+        Leader msg = new Leader(HOST1, asgn);
+
+        State next = mock(State.class);
+        when(mgr.goInactive()).thenReturn(next);
+
+        // should stay in the same state
+        assertNull(state.process(msg));
+        verify(mgr, never()).goActive();
+        verify(mgr, never()).goInactive();
+
+        // should have started distributing
+        verify(mgr).startDistributing(asgn);
+
+        // this host should still be the leader
+        assertEquals(MY_HOST, state.getLeader());
+
+        // new assignments
+        assertEquals(asgn, state.getAssignments());
+    }
+
+    @Test
+    void testProcessOffline_NullHost() {
+        assertNull(state.process(new Offline()));
+        assertEquals(MY_HOST, state.getLeader());
+    }
+
+    @Test
+    void testProcessOffline_SameHost() {
+        assertNull(state.process(new Offline(MY_HOST)));
+        assertEquals(MY_HOST, state.getLeader());
+    }
+
+    @Test
+    void testProcessOffline_DiffHost() {
+        BucketAssignments asgn = new BucketAssignments(new String[] {PREV_HOST, HOST1});
+        mgr.startDistributing(asgn);
+        state = new QueryState(mgr);
+
+        // tell it that the hosts are alive
+        state.process(new Identification(PREV_HOST, asgn));
+        state.process(new Identification(HOST1, asgn));
+
+        // #2 goes offline
+        assertNull(state.process(new Offline(HOST1)));
+
+        // #1 should still be the leader
+        assertEquals(PREV_HOST, state.getLeader());
+
+        // #1 goes offline
+        assertNull(state.process(new Offline(PREV_HOST)));
+
+        // this should still be the leader now
+        assertEquals(MY_HOST, state.getLeader());
+    }
+
+    @Test
+    void testQueryState() {
+        /*
+         * Prove the state is attached to the manager by invoking getHost(), which
+         * delegates to the manager.
+         */
+        assertEquals(MY_HOST, state.getHost());
+    }
+
+    @Test
+    void testAwaitIdentification_MissingSelfIdent() {
+        state.start();
+
+        Pair<Long, StateTimerTask> timer = onceTasks.remove();
+
+        assertEquals(STD_IDENTIFICATION_MS, timer.getLeft().longValue());
+        assertNotNull(timer.getRight());
+
+        // should published an Offline message and go inactive
+
+        State next = mock(State.class);
+        when(mgr.goStart()).thenReturn(next);
+
+        assertEquals(next, timer.getRight().fire());
+
+        // should continue distributing
+        verify(mgr, never()).startDistributing(null);
+
+        Offline msg = captureAdminMessage(Offline.class);
+        assertEquals(MY_HOST, msg.getSource());
+    }
+
+    @Test
+    void testAwaitIdentification_Leader() {
+        state.start();
+        state.process(new Identification(MY_HOST, null));
+
+        Pair<Long, StateTimerTask> timer = onceTasks.remove();
+
+        assertEquals(STD_IDENTIFICATION_MS, timer.getLeft().longValue());
+        assertNotNull(timer.getRight());
+
+        State next = mock(State.class);
+        when(mgr.goActive()).thenReturn(next);
+
+        assertEquals(next, timer.getRight().fire());
+
+        // should have published a Leader message
+        Leader msg = captureAdminMessage(Leader.class);
+        assertEquals(MY_HOST, msg.getSource());
+        assertTrue(msg.getAssignments().hasAssignment(MY_HOST));
+    }
+
+    @Test
+    void testAwaitIdentification_HasAssignment() {
+        // not the leader, but has an assignment
+        BucketAssignments asgn = new BucketAssignments(new String[] {PREV_HOST, MY_HOST, HOST2});
+        mgr.startDistributing(asgn);
+        state = new QueryState(mgr);
+
+        state.start();
+        state.process(new Identification(MY_HOST, null));
+
+        // tell it the leader is still active
+        state.process(new Identification(PREV_HOST, asgn));
+
+        Pair<Long, StateTimerTask> timer = onceTasks.remove();
+
+        assertEquals(STD_IDENTIFICATION_MS, timer.getLeft().longValue());
+        assertNotNull(timer.getRight());
+
+        // set up active state, as that's what it should return
+        State next = mock(State.class);
+        when(mgr.goActive()).thenReturn(next);
+
+        assertEquals(next, timer.getRight().fire());
+
+        // should NOT have published a Leader message
+        assertTrue(admin.isEmpty());
+
+        // should have gone active with the current assignments
+        verify(mgr).goActive();
+    }
+
+    @Test
+    void testAwaitIdentification_NoAssignment() {
+        // not the leader and no assignment
+        BucketAssignments asgn = new BucketAssignments(new String[] {HOST1, HOST2});
+        mgr.startDistributing(asgn);
+        state = new QueryState(mgr);
+
+        state.start();
+        state.process(new Identification(MY_HOST, null));
+
+        // tell it the leader is still active
+        state.process(new Identification(PREV_HOST, asgn));
+
+        Pair<Long, StateTimerTask> timer = onceTasks.remove();
+
+        assertEquals(STD_IDENTIFICATION_MS, timer.getLeft().longValue());
+        assertNotNull(timer.getRight());
+
+        // set up inactive state, as that's what it should return
+        State next = mock(State.class);
+        when(mgr.goInactive()).thenReturn(next);
+
+        assertEquals(next, timer.getRight().fire());
+
+        // should NOT have published a Leader message
+        assertTrue(admin.isEmpty());
+    }
+
+    @Test
+    void testRecordInfo_NullSource() {
+        state.setAssignments(ASGN3);
+        state.setLeader(MY_HOST);
+
+        BucketAssignments asgn = new BucketAssignments(new String[] {PREV_HOST, MY_HOST, HOST2});
+        state.process(new Identification(null, asgn));
+
+        // leader unchanged
+        assertEquals(MY_HOST, state.getLeader());
+
+        // assignments still updated
+        assertEquals(asgn, state.getAssignments());
+    }
+
+    @Test
+    void testRecordInfo_SourcePreceedsMyHost() {
+        state.setAssignments(ASGN3);
+        state.setLeader(MY_HOST);
+
+        BucketAssignments asgn = new BucketAssignments(new String[] {PREV_HOST, MY_HOST, HOST2});
+        state.process(new Identification(PREV_HOST, asgn));
+
+        // new leader
+        assertEquals(PREV_HOST, state.getLeader());
+
+        // assignments still updated
+        assertEquals(asgn, state.getAssignments());
+    }
+
+    @Test
+    void testRecordInfo_SourceFollowsMyHost() {
+        mgr.startDistributing(null);
+        state.setLeader(MY_HOST);
+
+        BucketAssignments asgn = new BucketAssignments(new String[] {HOST1, HOST2});
+        state.process(new Identification(HOST1, asgn));
+
+        // leader unchanged
+        assertEquals(MY_HOST, state.getLeader());
+
+        // assignments still updated
+        assertEquals(asgn, state.getAssignments());
+    }
+
+    @Test
+    void testRecordInfo_NewIsNull() {
+        state.setAssignments(ASGN3);
+        state.process(new Identification(HOST1, null));
+
+        assertEquals(ASGN3, state.getAssignments());
+    }
+
+    @Test
+    void testRecordInfo_NewIsEmpty() {
+        state.setAssignments(ASGN3);
+        state.process(new Identification(PREV_HOST, new BucketAssignments()));
+
+        assertEquals(ASGN3, state.getAssignments());
+    }
+
+    @Test
+    void testRecordInfo_OldIsNull() {
+        mgr.startDistributing(null);
+
+        BucketAssignments asgn = new BucketAssignments(new String[] {HOST1, HOST2});
+        state.process(new Identification(HOST1, asgn));
+
+        assertEquals(asgn, state.getAssignments());
+    }
+
+    @Test
+    void testRecordInfo_OldIsEmpty() {
+        state.setAssignments(new BucketAssignments());
+
+        BucketAssignments asgn = new BucketAssignments(new String[] {HOST1, HOST2});
+        state.process(new Identification(HOST1, asgn));
+
+        assertEquals(asgn, state.getAssignments());
+    }
+
+    @Test
+    void testRecordInfo_NewLeaderPreceedsOld() {
+        state.setAssignments(ASGN3);
+        state.setLeader(MY_HOST);
+
+        BucketAssignments asgn = new BucketAssignments(new String[] {PREV_HOST, MY_HOST, HOST2});
+        state.process(new Identification(HOST3, asgn));
+
+        assertEquals(asgn, state.getAssignments());
+    }
+
+    @Test
+    void testRecordInfo_NewLeaderSucceedsOld() {
+        state.setAssignments(ASGN3);
+        state.setLeader(MY_HOST);
+
+        BucketAssignments asgn = new BucketAssignments(new String[] {HOST2, HOST3});
+        state.process(new Identification(HOST3, asgn));
+
+        // should be unchanged
+        assertEquals(ASGN3, state.getAssignments());
+    }
+
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/StartStateTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/StartStateTest.java
new file mode 100644
index 0000000..1a85304
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/StartStateTest.java
@@ -0,0 +1,184 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2020 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2020, 2024 Nordix Foundation
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.commons.lang3.tuple.Triple;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.onap.policy.drools.pooling.message.Heartbeat;
+import org.onap.policy.drools.pooling.message.Identification;
+import org.onap.policy.drools.pooling.message.Leader;
+import org.onap.policy.drools.pooling.message.Offline;
+import org.onap.policy.drools.pooling.message.Query;
+
+class StartStateTest extends SupportBasicStateTester {
+
+    private StartState state;
+
+    /**
+     * Setup.
+     */
+    @Override
+    @BeforeEach
+    public void setUp() throws Exception {
+        super.setUp();
+
+        state = new StartState(mgr);
+    }
+
+    @Test
+    void testStart() {
+        state.start();
+
+        Pair<String, Heartbeat> msg = capturePublishedMessage(Heartbeat.class);
+
+        assertEquals(MY_HOST, msg.getLeft());
+        assertEquals(state.getHbTimestampMs(), msg.getRight().getTimestampMs());
+
+
+        /*
+         * Verify heartbeat generator
+         */
+        Triple<Long, Long, StateTimerTask> generator = repeatedTasks.removeFirst();
+
+        assertEquals(STD_INTER_HEARTBEAT_MS, generator.getLeft().longValue());
+        assertEquals(STD_INTER_HEARTBEAT_MS, generator.getMiddle().longValue());
+
+        // invoke the task - it should generate another heartbeat
+        assertNull(generator.getRight().fire());
+        verify(mgr, times(2)).publish(MY_HOST, msg.getRight());
+
+        // and again
+        assertNull(generator.getRight().fire());
+        verify(mgr, times(3)).publish(MY_HOST, msg.getRight());
+
+
+        /*
+         * Verify heartbeat checker
+         */
+        Pair<Long, StateTimerTask> checker = onceTasks.removeFirst();
+
+        assertEquals(STD_HEARTBEAT_WAIT_MS, checker.getLeft().longValue());
+
+        // invoke the task - it should go to the state returned by the mgr
+        State next = mock(State.class);
+        when(mgr.goInactive()).thenReturn(next);
+
+        assertEquals(next, checker.getRight().fire());
+
+        verify(mgr).startDistributing(null);
+    }
+
+    @Test
+    void testStartStatePoolingManager() {
+        /*
+         * Prove the state is attached to the manager by invoking getHost(), which
+         * delegates to the manager.
+         */
+        assertEquals(MY_HOST, state.getHost());
+    }
+
+    @Test
+    void testStartStateState() {
+        // create a new state from the current state
+        state = new StartState(mgr);
+
+        /*
+         * Prove the state is attached to the manager by invoking getHost(), which
+         * delegates to the manager.
+         */
+        assertEquals(MY_HOST, state.getHost());
+    }
+
+    @Test
+    void testProcessHeartbeat() {
+        Heartbeat msg = new Heartbeat();
+
+        // no matching data in heart beat
+        assertNull(state.process(msg));
+        verify(mgr, never()).publishAdmin(any());
+
+        // same source, different time stamp
+        msg.setSource(MY_HOST);
+        msg.setTimestampMs(state.getHbTimestampMs() - 1);
+        assertNull(state.process(msg));
+        verify(mgr, never()).publishAdmin(any());
+
+        // same time stamp, different source
+        msg.setSource("unknown");
+        msg.setTimestampMs(state.getHbTimestampMs());
+        assertNull(state.process(msg));
+        verify(mgr, never()).publishAdmin(any());
+
+        // matching heart beat
+        msg.setSource(MY_HOST);
+        msg.setTimestampMs(state.getHbTimestampMs());
+
+        State next = mock(State.class);
+        when(mgr.goQuery()).thenReturn(next);
+
+        assertEquals(next, state.process(msg));
+
+        verify(mgr).publishAdmin(any(Query.class));
+    }
+
+    @Test
+    void testProcessIdentification() {
+        assertNull(state.process(new Identification(MY_HOST, null)));
+    }
+
+    @Test
+    void testProcessLeader() {
+        assertNull(state.process(new Leader(MY_HOST, null)));
+    }
+
+    @Test
+    void testProcessOffline() {
+        assertNull(state.process(new Offline(HOST1)));
+    }
+
+    @Test
+    void testProcessQuery() {
+        assertNull(state.process(new Query()));
+    }
+
+    @Test
+    void testGetHbTimestampMs() {
+        long tcurrent = System.currentTimeMillis();
+        assertTrue(new StartState(mgr).getHbTimestampMs() >= tcurrent);
+
+        tcurrent = System.currentTimeMillis();
+        assertTrue(new StartState(mgr).getHbTimestampMs() >= tcurrent);
+    }
+
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/StateTest.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/StateTest.java
new file mode 100644
index 0000000..cfae6f3
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/StateTest.java
@@ -0,0 +1,466 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2020 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2024 Nordix Foundation.
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.onap.policy.drools.pooling.CancellableScheduledTask;
+import org.onap.policy.drools.pooling.PoolingManager;
+import org.onap.policy.drools.pooling.message.BucketAssignments;
+import org.onap.policy.drools.pooling.message.Heartbeat;
+import org.onap.policy.drools.pooling.message.Identification;
+import org.onap.policy.drools.pooling.message.Leader;
+import org.onap.policy.drools.pooling.message.Offline;
+import org.onap.policy.drools.pooling.message.Query;
+
+class StateTest extends SupportBasicStateTester {
+
+    private State state;
+
+    /**
+     * Setup.
+     */
+    @BeforeEach
+    public void setUp() throws Exception {
+        super.setUp();
+
+        state = new MyState(mgr);
+    }
+
+    @Test
+    void testStatePoolingManager() {
+        /*
+         * Prove the state is attached to the manager by invoking getHost(), which
+         * delegates to the manager.
+         */
+        assertEquals(MY_HOST, state.getHost());
+    }
+
+    @Test
+    void testStateState() {
+        // allocate a new state, copying from the old state
+        state = new MyState(mgr);
+
+        /*
+         * Prove the state is attached to the manager by invoking getHost(), which
+         * delegates to the manager.
+         */
+        assertEquals(MY_HOST, state.getHost());
+    }
+
+    @Test
+    void testCancelTimers() {
+        int delay = 100;
+        int initDelay = 200;
+
+        /*
+         * Create three tasks tasks.
+         */
+
+        StateTimerTask task1 = mock(StateTimerTask.class);
+        StateTimerTask task2 = mock(StateTimerTask.class);
+        StateTimerTask task3 = mock(StateTimerTask.class);
+
+        // two tasks via schedule()
+        state.schedule(delay, task1);
+        state.schedule(delay, task2);
+
+        // one task via scheduleWithFixedDelay()
+        state.scheduleWithFixedDelay(initDelay, delay, task3);
+
+        // ensure all were scheduled, but not yet canceled
+        verify(mgr).schedule(delay, task1);
+        verify(mgr).schedule(delay, task2);
+        verify(mgr).scheduleWithFixedDelay(initDelay, delay, task3);
+
+        CancellableScheduledTask sched1 = onceSchedules.removeFirst();
+        CancellableScheduledTask sched2 = onceSchedules.removeFirst();
+        CancellableScheduledTask sched3 = repeatedSchedules.removeFirst();
+
+        verify(sched1, never()).cancel();
+        verify(sched2, never()).cancel();
+        verify(sched3, never()).cancel();
+
+        /*
+         * Cancel the timers.
+         */
+        state.cancelTimers();
+
+        // verify that all were cancelled
+        verify(sched1).cancel();
+        verify(sched2).cancel();
+        verify(sched3).cancel();
+    }
+
+    @Test
+    void testStart() {
+        assertThatCode(() -> state.start()).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testGoStart() {
+        State next = mock(State.class);
+        when(mgr.goStart()).thenReturn(next);
+
+        State next2 = state.goStart();
+        assertEquals(next, next2);
+    }
+
+    @Test
+    void testGoQuery() {
+        State next = mock(State.class);
+        when(mgr.goQuery()).thenReturn(next);
+
+        State next2 = state.goQuery();
+        assertEquals(next, next2);
+    }
+
+    @Test
+    void testGoActive_WithAssignment() {
+        State act = mock(State.class);
+        State inact = mock(State.class);
+
+        when(mgr.goActive()).thenReturn(act);
+        when(mgr.goInactive()).thenReturn(inact);
+
+        String[] arr = {HOST2, PREV_HOST, MY_HOST};
+        BucketAssignments asgn = new BucketAssignments(arr);
+
+        assertEquals(act, state.goActive(asgn));
+
+        verify(mgr).startDistributing(asgn);
+    }
+
+    @Test
+    void testGoActive_WithoutAssignment() {
+        State act = mock(State.class);
+        State inact = mock(State.class);
+
+        when(mgr.goActive()).thenReturn(act);
+        when(mgr.goInactive()).thenReturn(inact);
+
+        String[] arr = {HOST2, PREV_HOST};
+        BucketAssignments asgn = new BucketAssignments(arr);
+
+        assertEquals(inact, state.goActive(asgn));
+
+        verify(mgr).startDistributing(asgn);
+    }
+
+    @Test
+    void testGoActive_NullAssignment() {
+        State act = mock(State.class);
+        State inact = mock(State.class);
+
+        when(mgr.goActive()).thenReturn(act);
+        when(mgr.goInactive()).thenReturn(inact);
+
+        assertEquals(inact, state.goActive(null));
+
+        verify(mgr, never()).startDistributing(any());
+    }
+
+    @Test
+    void testGoInactive() {
+        State next = mock(State.class);
+        when(mgr.goInactive()).thenReturn(next);
+
+        State next2 = state.goInactive();
+        assertEquals(next, next2);
+    }
+
+    @Test
+    void testProcessHeartbeat() {
+        assertNull(state.process(new Heartbeat()));
+    }
+
+    @Test
+    void testProcessIdentification() {
+        assertNull(state.process(new Identification()));
+    }
+
+    @Test
+    void testProcessLeader() {
+        String[] arr = {HOST2, HOST1};
+        BucketAssignments asgn = new BucketAssignments(arr);
+        Leader msg = new Leader(HOST1, asgn);
+
+        // should ignore it
+        assertNull(state.process(msg));
+        verify(mgr).startDistributing(asgn);
+    }
+
+    @Test
+    void testProcessLeader_Invalid() {
+        Leader msg = new Leader(PREV_HOST, null);
+
+        // should stay in the same state, and not start distributing
+        assertNull(state.process(msg));
+        verify(mgr, never()).startDistributing(any());
+        verify(mgr, never()).goActive();
+        verify(mgr, never()).goInactive();
+    }
+
+    @Test
+    void testIsValidLeader_NullAssignment() {
+        assertFalse(state.isValid(new Leader(PREV_HOST, null)));
+    }
+
+    @Test
+    void testIsValidLeader_NullSource() {
+        String[] arr = {HOST2, PREV_HOST, MY_HOST};
+        BucketAssignments asgn = new BucketAssignments(arr);
+        assertFalse(state.isValid(new Leader(null, asgn)));
+    }
+
+    @Test
+    void testIsValidLeader_EmptyAssignment() {
+        assertFalse(state.isValid(new Leader(PREV_HOST, new BucketAssignments())));
+    }
+
+    @Test
+    void testIsValidLeader_FromSelf() {
+        String[] arr = {HOST2, MY_HOST};
+        BucketAssignments asgn = new BucketAssignments(arr);
+
+        assertFalse(state.isValid(new Leader(MY_HOST, asgn)));
+    }
+
+    @Test
+    void testIsValidLeader_WrongLeader() {
+        String[] arr = {HOST2, HOST3};
+        BucketAssignments asgn = new BucketAssignments(arr);
+
+        assertFalse(state.isValid(new Leader(HOST1, asgn)));
+    }
+
+    @Test
+    void testIsValidLeader() {
+        String[] arr = {HOST2, HOST1};
+        BucketAssignments asgn = new BucketAssignments(arr);
+
+        assertTrue(state.isValid(new Leader(HOST1, asgn)));
+    }
+
+    @Test
+    void testProcessOffline() {
+        assertNull(state.process(new Offline()));
+    }
+
+    @Test
+    void testProcessQuery() {
+        assertNull(state.process(new Query()));
+    }
+
+    @Test
+    void testPublishIdentification() {
+        Identification msg = new Identification();
+        state.publish(msg);
+
+        verify(mgr).publishAdmin(msg);
+    }
+
+    @Test
+    void testPublishLeader() {
+        Leader msg = new Leader();
+        state.publish(msg);
+
+        verify(mgr).publishAdmin(msg);
+    }
+
+    @Test
+    void testPublishOffline() {
+        Offline msg = new Offline();
+        state.publish(msg);
+
+        verify(mgr).publishAdmin(msg);
+    }
+
+    @Test
+    void testPublishQuery() {
+        Query msg = new Query();
+        state.publish(msg);
+
+        verify(mgr).publishAdmin(msg);
+    }
+
+    @Test
+    void testPublishStringHeartbeat() {
+        String chnl = "channelH";
+        Heartbeat msg = new Heartbeat();
+
+        state.publish(chnl, msg);
+
+        verify(mgr).publish(chnl, msg);
+    }
+
+    @Test
+    void testStartDistributing() {
+        BucketAssignments asgn = new BucketAssignments();
+        state.startDistributing(asgn);
+
+        verify(mgr).startDistributing(asgn);
+    }
+
+    @Test
+    void testStartDistributing_NullAssignments() {
+        state.startDistributing(null);
+
+        verify(mgr, never()).startDistributing(any());
+    }
+
+    @Test
+    void testSchedule() {
+        int delay = 100;
+
+        StateTimerTask task = mock(StateTimerTask.class);
+
+        state.schedule(delay, task);
+
+        CancellableScheduledTask sched = onceSchedules.removeFirst();
+
+        // scheduled, but not canceled yet
+        verify(mgr).schedule(delay, task);
+        verify(sched, never()).cancel();
+
+        /*
+         * Ensure the state added the timer to its list by telling it to cancel its timers
+         * and then seeing if this timer was canceled.
+         */
+        state.cancelTimers();
+        verify(sched).cancel();
+    }
+
+    @Test
+    void testScheduleWithFixedDelay() {
+        int initdel = 100;
+        int delay = 200;
+
+        StateTimerTask task = mock(StateTimerTask.class);
+
+        state.scheduleWithFixedDelay(initdel, delay, task);
+
+        CancellableScheduledTask sched = repeatedSchedules.removeFirst();
+
+        // scheduled, but not canceled yet
+        verify(mgr).scheduleWithFixedDelay(initdel, delay, task);
+        verify(sched, never()).cancel();
+
+        /*
+         * Ensure the state added the timer to its list by telling it to cancel its timers
+         * and then seeing if this timer was canceled.
+         */
+        state.cancelTimers();
+        verify(sched).cancel();
+    }
+
+    @Test
+    void testMissedHeartbeat() {
+        State next = mock(State.class);
+        when(mgr.goStart()).thenReturn(next);
+
+        State next2 = state.missedHeartbeat();
+        assertEquals(next, next2);
+
+        // should continue to distribute
+        verify(mgr, never()).startDistributing(null);
+
+        Offline msg = captureAdminMessage(Offline.class);
+        assertEquals(MY_HOST, msg.getSource());
+    }
+
+    @Test
+    void testInternalTopicFailed() {
+        State next = mock(State.class);
+        when(mgr.goInactive()).thenReturn(next);
+
+        State next2 = state.internalTopicFailed();
+        assertEquals(next, next2);
+
+        // should stop distributing
+        verify(mgr).startDistributing(null);
+
+        Offline msg = captureAdminMessage(Offline.class);
+        assertEquals(MY_HOST, msg.getSource());
+    }
+
+    @Test
+    void testMakeHeartbeat() {
+        long timestamp = 30000L;
+        Heartbeat msg = state.makeHeartbeat(timestamp);
+
+        assertEquals(MY_HOST, msg.getSource());
+        assertEquals(timestamp, msg.getTimestampMs());
+    }
+
+    @Test
+    void testMakeIdentification() {
+        Identification ident = state.makeIdentification();
+        assertEquals(MY_HOST, ident.getSource());
+        assertEquals(ASGN3, ident.getAssignments());
+    }
+
+    @Test
+    void testMakeOffline() {
+        Offline msg = state.makeOffline();
+
+        assertEquals(MY_HOST, msg.getSource());
+    }
+
+    @Test
+    void testMakeQuery() {
+        Query msg = state.makeQuery();
+
+        assertEquals(MY_HOST, msg.getSource());
+    }
+
+    @Test
+    void testGetHost() {
+        assertEquals(MY_HOST, state.getHost());
+    }
+
+    @Test
+    void testGetTopic() {
+        assertEquals(MY_TOPIC, state.getTopic());
+    }
+
+    /**
+     * State used for testing purposes, with abstract methods implemented.
+     */
+    private static class MyState extends State {
+
+        public MyState(PoolingManager mgr) {
+            super(mgr);
+        }
+    }
+}
diff --git a/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/SupportBasicStateTester.java b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/SupportBasicStateTester.java
new file mode 100644
index 0000000..8f2c916
--- /dev/null
+++ b/feature-pooling-messages/src/test/java/org/onap/policy/drools/pooling/state/SupportBasicStateTester.java
@@ -0,0 +1,282 @@
+/*
+ * ============LICENSE_START=======================================================
+ * ONAP
+ * ================================================================================
+ * Copyright (C) 2018, 2020 AT&T Intellectual Property. All rights reserved.
+ * Modifications Copyright (C) 2020, 2024 Nordix Foundation
+ * ================================================================================
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * ============LICENSE_END=========================================================
+ */
+
+package org.onap.policy.drools.pooling.state;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.commons.lang3.tuple.Triple;
+import org.onap.policy.drools.pooling.CancellableScheduledTask;
+import org.onap.policy.drools.pooling.PoolingManager;
+import org.onap.policy.drools.pooling.PoolingProperties;
+import org.onap.policy.drools.pooling.message.BucketAssignments;
+import org.onap.policy.drools.pooling.message.Leader;
+import org.onap.policy.drools.pooling.message.Message;
+
+/**
+ * Superclass used to test subclasses of {@link State}.
+ */
+public class SupportBasicStateTester {
+
+    protected static final long STD_HEARTBEAT_WAIT_MS = 10;
+    protected static final long STD_REACTIVATE_WAIT_MS = STD_HEARTBEAT_WAIT_MS + 1;
+    protected static final long STD_IDENTIFICATION_MS = STD_REACTIVATE_WAIT_MS + 1;
+    protected static final long STD_ACTIVE_HEARTBEAT_MS = STD_IDENTIFICATION_MS + 1;
+    protected static final long STD_INTER_HEARTBEAT_MS = STD_ACTIVE_HEARTBEAT_MS + 1;
+
+    protected static final String MY_TOPIC = "myTopic";
+
+    protected static final String PREV_HOST = "prevHost";
+    protected static final String PREV_HOST2 = PREV_HOST + "A";
+
+    // this follows PREV_HOST, alphabetically
+    protected static final String MY_HOST = PREV_HOST + "X";
+
+    // these follow MY_HOST, alphabetically
+    protected static final String HOST1 = MY_HOST + "1";
+    protected static final String HOST2 = MY_HOST + "2";
+    protected static final String HOST3 = MY_HOST + "3";
+    protected static final String HOST4 = MY_HOST + "4";
+
+    protected static final String LEADER = HOST1;
+
+    protected static final String[] HOST_ARR3 = {HOST1, MY_HOST, HOST2};
+
+    protected static final BucketAssignments EMPTY_ASGN = new BucketAssignments();
+    protected static final BucketAssignments ASGN3 = new BucketAssignments(HOST_ARR3);
+
+    /**
+     * Scheduled tasks returned by schedule().
+     */
+    protected LinkedList<CancellableScheduledTask> onceSchedules;
+
+    /**
+     * Tasks captured via schedule().
+     */
+    protected LinkedList<Pair<Long, StateTimerTask>> onceTasks;
+
+    /**
+     * Scheduled tasks returned by scheduleWithFixedDelay().
+     */
+    protected LinkedList<CancellableScheduledTask> repeatedSchedules;
+
+    /**
+     * Tasks captured via scheduleWithFixedDelay().
+     */
+    protected LinkedList<Triple<Long, Long, StateTimerTask>> repeatedTasks;
+
+    /**
+     * Messages captured via publish().
+     */
+    protected LinkedList<Pair<String, Message>> published;
+
+    /**
+     * Messages captured via publishAdmin().
+     */
+    protected LinkedList<Message> admin;
+
+    protected PoolingManager mgr;
+    protected PoolingProperties props;
+    protected State prevState;
+
+    public SupportBasicStateTester() {
+        super();
+    }
+
+    /**
+     * Setup.
+     *
+     * @throws Exception throws exception
+     */
+    public void setUp() throws Exception {
+        onceSchedules = new LinkedList<>();
+        onceTasks = new LinkedList<>();
+
+        repeatedSchedules = new LinkedList<>();
+        repeatedTasks = new LinkedList<>();
+
+        published = new LinkedList<>();
+        admin = new LinkedList<>();
+
+        mgr = mock(PoolingManager.class);
+        props = mock(PoolingProperties.class);
+
+        when(mgr.getHost()).thenReturn(MY_HOST);
+        when(mgr.getTopic()).thenReturn(MY_TOPIC);
+        when(mgr.getProperties()).thenReturn(props);
+
+        when(props.getStartHeartbeatMs()).thenReturn(STD_HEARTBEAT_WAIT_MS);
+        when(props.getReactivateMs()).thenReturn(STD_REACTIVATE_WAIT_MS);
+        when(props.getIdentificationMs()).thenReturn(STD_IDENTIFICATION_MS);
+        when(props.getActiveHeartbeatMs()).thenReturn(STD_ACTIVE_HEARTBEAT_MS);
+        when(props.getInterHeartbeatMs()).thenReturn(STD_INTER_HEARTBEAT_MS);
+
+        prevState = new State(mgr) {};
+
+        // capture publish() arguments
+        doAnswer(invocation -> {
+            Object[] args = invocation.getArguments();
+            published.add(Pair.of((String) args[0], (Message) args[1]));
+
+            return null;
+        }).when(mgr).publish(anyString(), any(Message.class));
+
+        // capture publishAdmin() arguments
+        doAnswer(invocation -> {
+            Object[] args = invocation.getArguments();
+            admin.add((Message) args[0]);
+
+            return null;
+        }).when(mgr).publishAdmin(any(Message.class));
+
+        // capture schedule() arguments, and return a new future
+        when(mgr.schedule(anyLong(), any(StateTimerTask.class))).thenAnswer(invocation -> {
+            Object[] args = invocation.getArguments();
+            onceTasks.add(Pair.of((Long) args[0], (StateTimerTask) args[1]));
+
+            CancellableScheduledTask sched = mock(CancellableScheduledTask.class);
+            onceSchedules.add(sched);
+            return sched;
+        });
+
+        // capture scheduleWithFixedDelay() arguments, and return a new future
+        when(mgr.scheduleWithFixedDelay(anyLong(), anyLong(), any(StateTimerTask.class))).thenAnswer(invocation -> {
+            Object[] args = invocation.getArguments();
+            repeatedTasks.add(Triple.of((Long) args[0], (Long) args[1], (StateTimerTask) args[2]));
+
+            CancellableScheduledTask sched = mock(CancellableScheduledTask.class);
+            repeatedSchedules.add(sched);
+            return sched;
+        });
+
+        // get/set assignments in the manager
+        AtomicReference<BucketAssignments> asgn = new AtomicReference<>(ASGN3);
+
+        when(mgr.getAssignments()).thenAnswer(args -> asgn.get());
+
+        doAnswer(args -> {
+            asgn.set(args.getArgument(0));
+            return null;
+        }).when(mgr).startDistributing(any());
+    }
+
+    /**
+     * Makes a sorted set of hosts.
+     *
+     * @param hosts the hosts to be sorted
+     * @return the set of hosts, sorted
+     */
+    protected SortedSet<String> sortHosts(String... hosts) {
+        return new TreeSet<>(Arrays.asList(hosts));
+    }
+
+    /**
+     * Captures the host array from the Leader message published to the admin channel.
+     *
+     * @return the host array, as a list
+     */
+    protected List<String> captureHostList() {
+        return Arrays.asList(captureHostArray());
+    }
+
+    /**
+     * Captures the host array from the Leader message published to the admin channel.
+     *
+     * @return the host array
+     */
+    protected String[] captureHostArray() {
+        BucketAssignments asgn = captureAssignments();
+
+        String[] arr = asgn.getHostArray();
+        assertNotNull(arr);
+
+        return arr;
+    }
+
+    /**
+     * Captures the assignments from the Leader message published to the admin channel.
+     *
+     * @return the bucket assignments
+     */
+    protected BucketAssignments captureAssignments() {
+        Leader msg = captureAdminMessage(Leader.class);
+
+        BucketAssignments asgn = msg.getAssignments();
+        assertNotNull(asgn);
+        return asgn;
+    }
+
+    /**
+     * Captures the message published to the admin channel.
+     *
+     * @param clazz type of {@link Message} to capture
+     * @return the message that was published
+     */
+    protected <T extends Message> T captureAdminMessage(Class<T> clazz) {
+        return captureAdminMessage(clazz, 0);
+    }
+
+    /**
+     * Captures the message published to the admin channel.
+     *
+     * @param clazz type of {@link Message} to capture
+     * @param index index of the item to be captured
+     * @return the message that was published
+     */
+    protected <T extends Message> T captureAdminMessage(Class<T> clazz, int index) {
+        return clazz.cast(admin.get(index));
+    }
+
+    /**
+     * Captures the message published to the non-admin channels.
+     *
+     * @param clazz type of {@link Message} to capture
+     * @return the (channel,message) pair that was published
+     */
+    protected <T extends Message> Pair<String, T> capturePublishedMessage(Class<T> clazz) {
+        return capturePublishedMessage(clazz, 0);
+    }
+
+    /**
+     * Captures the message published to the non-admin channels.
+     *
+     * @param clazz type of {@link Message} to capture
+     * @param index index of the item to be captured
+     * @return the (channel,message) pair that was published
+     */
+    protected <T extends Message> Pair<String, T> capturePublishedMessage(Class<T> clazz, int index) {
+        Pair<String, Message> msg = published.get(index);
+        return Pair.of(msg.getLeft(), clazz.cast(msg.getRight()));
+    }
+}
diff --git a/feature-pooling-messages/src/test/resources/logback-test.xml b/feature-pooling-messages/src/test/resources/logback-test.xml
new file mode 100644
index 0000000..45d8201
--- /dev/null
+++ b/feature-pooling-messages/src/test/resources/logback-test.xml
@@ -0,0 +1,36 @@
+<!--
+  ============LICENSE_START=======================================================
+  ONAP 
+  ================================================================================
+  Copyright (C) 2018 AT&T Intellectual Property. All rights reserved.
+  ================================================================================
+  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.
+  ============LICENSE_END=========================================================
+  -->
+<configuration>
+
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <Pattern>
+                %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36}.%M\(%line\) - %msg%n
+            </Pattern>
+        </encoder>
+    </appender>
+    
+    <logger name="org.onap.policy.drools.http.server.test" level="INFO"/>
+
+    <root level="WARN">
+        <appender-ref ref="STDOUT"/>
+    </root>
+
+</configuration>
\ No newline at end of file