aboutsummaryrefslogtreecommitdiffstats
path: root/feature-server-pool/src/main/java/org/onap
diff options
context:
space:
mode:
authorStraubs, Ralph (rs8887) <rs8887@att.com>2020-02-04 03:26:30 -0600
committerChou, Joseph (jc2555) <jc2555@att.com>2020-03-04 10:33:48 -0500
commit001320ed1ecbdf3b2f58d261f008f681da3f4c67 (patch)
treebeb71c4577fd6b4f9bc843a5b0a58566a77009f2 /feature-server-pool/src/main/java/org/onap
parentbbcf7265e9b74e689bd8974f9684a6cd5c95fd9f (diff)
Add feature-server-pool to the ONAP drools-pdp repository.
Issue-ID: POLICY-2351 Change-Id: I8ddde547a73a51c04c8dd9f1d66232e8281599a9 Signed-off-by: Straubs, Ralph (rs8887) <rs8887@att.com>
Diffstat (limited to 'feature-server-pool/src/main/java/org/onap')
-rw-r--r--feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Bucket.java2495
-rw-r--r--feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Discovery.java354
-rw-r--r--feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Events.java103
-rw-r--r--feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/ExtendedObjectInputStream.java70
-rw-r--r--feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/FeatureServerPool.java986
-rw-r--r--feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Keyword.java507
-rw-r--r--feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Leader.java573
-rw-r--r--feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/MainLoop.java186
-rw-r--r--feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/RestServerPool.java447
-rw-r--r--feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Server.java1352
-rw-r--r--feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/ServerPoolApi.java79
-rw-r--r--feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/ServerPoolProperties.java332
-rw-r--r--feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/TargetLock.java2821
-rw-r--r--feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Util.java181
-rw-r--r--feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/persistence/Persistence.java875
15 files changed, 11361 insertions, 0 deletions
diff --git a/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Bucket.java b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Bucket.java
new file mode 100644
index 00000000..2236506e
--- /dev/null
+++ b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Bucket.java
@@ -0,0 +1,2495 @@
+/*
+ * ============LICENSE_START=======================================================
+ * feature-server-pool
+ * ================================================================================
+ * Copyright (C) 2020 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=========================================================
+ */
+
+package org.onap.policy.drools.serverpool;
+
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.BUCKET_CONFIRMED_TIMEOUT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.BUCKET_TIME_TO_LIVE;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.BUCKET_UNCONFIRMED_GRACE_PERIOD;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.BUCKET_UNCONFIRMED_TIMEOUT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_BUCKET_CONFIRMED_TIMEOUT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_BUCKET_TIME_TO_LIVE;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_BUCKET_UNCONFIRMED_GRACE_PERIOD;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_BUCKET_UNCONFIRMED_TIMEOUT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.getProperty;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.io.Serializable;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Queue;
+import java.util.Random;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.UUID;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.LinkedTransferQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The server pool uses an algorithmic way to map things like transactions
+ * (identified by a 'requestID') and locks (identified by a string key)
+ * into a server handling that transaction or lock. It does this by mapping
+ * the string name into one of a set of predefined hash buckets, with each
+ * bucket being assigned to one of the active servers.
+ * In other words:
+ * string key -> hash bucket (fixed mapping, known to all servers)
+ * hash bucket -> server (assignments may change when servers go up or down,
+ * but remains fairly static when the system is stable)
+ * With this approach, there is no global dynamic table that needs to be
+ * updated as transactions, or other objects come and go.
+ * Each instance of class 'Bucket' corresponds to one of the hash buckets,
+ * there are static methods that provide the overall abstraction, as well
+ * as some supporting classes.
+ */
+
+@Getter
+@Setter
+public class Bucket {
+ private static Logger logger = LoggerFactory.getLogger(Bucket.class);
+
+ /*
+ * Listener class to handle state changes that may lead to
+ * reassignments of buckets
+ */
+ private static EventHandler eventHandler = new EventHandler();
+
+ // Used to hash keywords into buckets
+ private static MessageDigest messageDigest;
+
+ static {
+ // register Listener class
+ Events.register(eventHandler);
+
+ // create MD5 MessageDigest -- used to hash keywords
+ try {
+ messageDigest = MessageDigest.getInstance("MD5");
+ } catch (NoSuchAlgorithmException e) {
+ throw new ExceptionInInitializerError(e);
+ }
+ }
+
+ /*
+ * Values extracted from properties
+ */
+
+ private static String timeToLive;
+ private static long confirmedTimeout;
+ private static long unconfirmedTimeout;
+ private static long unconfirmedGracePeriod;
+
+ /*
+ * Tags for encoding of bucket data
+ */
+ private static final int END_OF_PARAMETERS_TAG = 0;
+ private static final int OWNER_UPDATE = 1;
+ private static final int OWNER_NULL = 2;
+ private static final int PRIMARY_BACKUP_UPDATE = 3;
+ private static final int PRIMARY_BACKUP_NULL = 4;
+ private static final int SECONDARY_BACKUP_UPDATE = 5;
+ private static final int SECONDARY_BACKUP_NULL = 6;
+
+ // This is the table itself -- the current size is fixed at 1024 buckets
+ public static final int BUCKETCOUNT = 1024;
+ private static Bucket[] indexToBucket = new Bucket[BUCKETCOUNT];
+
+ static {
+ // create hash bucket entries, but there are no assignments yet
+ for (int i = 0 ; i < indexToBucket.length ; i += 1) {
+ Bucket bucket = new Bucket(i);
+ indexToBucket[i] = bucket;
+ }
+ }
+
+ // this is a list of all objects registered for the 'Backup' interface
+ private static List<Backup> backupList = new LinkedList<>();
+
+ // 'rebalance' is a non-null value when rebalancing is in progress
+ private static Object rebalanceLock = new Object();
+ private static Rebalance rebalance = null;
+
+ // bucket number
+ private volatile int index;
+
+ // owner of the bucket -- this is the host where messages should be directed
+ private volatile Server owner = null;
+
+ // this host will take over as the owner if the current owner goes down,
+ // and may also contain backup data to support persistence
+ private volatile Server primaryBackup = null;
+
+ // this is a secondary backup host, which can be used if both owner and
+ // primary backup go out in quick succession
+ private volatile Server secondaryBackup = null;
+
+ // when we are in a transient state, certain events are forwarded to
+ // this object
+ private volatile State state = null;
+
+ // storage for additional data
+ private Map<Class<?>, Object> adjuncts = new HashMap<Class<?>, Object>();
+
+ // BACKUP data (only buckets for where we are the owner, or a backup)
+
+ // TBD: need fields for outgoing queues for application message transfers
+
+ /**
+ * This method triggers registration of 'eventHandler', and also extracts
+ * property values.
+ */
+ static void startup() {
+ int intTimeToLive =
+ getProperty(BUCKET_TIME_TO_LIVE, DEFAULT_BUCKET_TIME_TO_LIVE);
+ timeToLive = String.valueOf(intTimeToLive);
+ confirmedTimeout =
+ getProperty(BUCKET_CONFIRMED_TIMEOUT, DEFAULT_BUCKET_CONFIRMED_TIMEOUT);
+ unconfirmedTimeout =
+ getProperty(BUCKET_UNCONFIRMED_TIMEOUT,
+ DEFAULT_BUCKET_UNCONFIRMED_TIMEOUT);
+ unconfirmedGracePeriod =
+ getProperty(BUCKET_UNCONFIRMED_GRACE_PERIOD,
+ DEFAULT_BUCKET_UNCONFIRMED_GRACE_PERIOD);
+ }
+
+ /**
+ * Constructor -- called when building the 'indexToBucket' table.
+ *
+ * @param index the bucket number
+ */
+ private Bucket(int index) {
+ this.index = index;
+ }
+
+ /**
+ * This method converts a String keyword into the corresponding bucket
+ * number.
+ *
+ * @param value the keyword to be converted
+ * @return the bucket number
+ */
+ public static int bucketNumber(String value) {
+ /*
+ * It would be possible to create a new 'MessageDigest' instance each
+ * It would be possible to create a new 'MessageDigest' instance each
+ * time this method is called, and avoid the need for synchronization.
+ * However, past experience has taught me that this might involve a
+ * considerable amount of computation, due to internal table
+ * initialization, so it shouldn't be done this way for performance
+ * reasons.
+ * If we start running into blocking issues because there are too many
+ * simultaneous calls to this method, we can initialize an array of these
+ * objects, and iterate over them using an AtomicInteger index.
+ */
+ synchronized (messageDigest) {
+ /*
+ * Note that we only need the first two bytes of this, even though
+ * 16 bytes are produced. There may be other operations that can be
+ * used to more efficiently map keyword -> hash bucket. The only
+ * issue is the same algorithm must be used on all servers, and it
+ * should produce a fairly even distribution across all of the buckets.
+ */
+ byte[] digest = messageDigest.digest(value.getBytes());
+ return ((Byte.toUnsignedInt(digest[0]) << 8)
+ | Byte.toUnsignedInt(digest[1])) & 0x3ff;
+ }
+ }
+
+ /**
+ * Fetch the server associated with a particular bucket number.
+ *
+ * @param bucketNumber a bucket number in the range 0-1023
+ * @return the Server that currently handles the bucket,
+ * or 'null' if none is currently assigned
+ */
+ public static Server bucketToServer(int bucketNumber) {
+ Bucket bucket = indexToBucket[bucketNumber];
+ return bucket.getOwner();
+ }
+
+ /**
+ * Fetch the bucket object associated with a particular bucket number.
+ *
+ * @param bucketNumber a bucket number in the range 0-1023
+ * @return the Bucket associated with this bucket number
+ */
+ public static Bucket getBucket(int bucketNumber) {
+ return indexToBucket[bucketNumber];
+ }
+
+ /**
+ * Fetch the bucket object associated with a particular keyword.
+ *
+ * @param value the keyword to be converted
+ * @return the Bucket associated with this keyword
+ */
+ public static Bucket getBucket(String value) {
+ return indexToBucket[bucketNumber(value)];
+ }
+
+ /**
+ * Determine if the associated key is assigned to the current server.
+ *
+ * @param key the keyword to be hashed
+ * @return 'true' if the associated bucket is assigned to this server,
+ * 'false' if not
+ */
+ public static boolean isKeyOnThisServer(String key) {
+ int bucketNumber = bucketNumber(key);
+ Bucket bucket = indexToBucket[bucketNumber];
+ return bucket.getOwner() == Server.getThisServer();
+ }
+
+ /**
+ * This method is called to start a 'rebalance' operation in a background
+ * thread, but it only does this on the lead server. Being balanced means
+ * the following:
+ * 1) Each server owns approximately the same number of buckets
+ * 2) If any server were to fail, and the designated primaries take over
+ * for all of that server's buckets, all remaining servers would still
+ * own approximately the same number of buckets.
+ * 3) If any two servers were to fail, and the designated primaries were
+ * to take over for the failed server's buckets (secondaries would take
+ * for buckets where the owner and primary are OOS), all remaining
+ * servers would still own approximately the same number of buckets.
+ * 4) Each server should have approximately the same number of
+ * (primary-backup + secondary-backup) buckets that it is responsible for.
+ * 5) The primary backup for each bucket must be on the same site as the
+ * owner, and the secondary backup must be on a different site.
+ */
+ private static void rebalance() {
+ if (Leader.getLeader() == Server.getThisServer()) {
+ Rebalance rb = new Rebalance();
+ synchronized (rebalanceLock) {
+ // the most recent 'Rebalance' instance is the only valid one
+ rebalance = rb;
+ }
+
+ new Thread("BUCKET REBALANCER") {
+ @Override
+ public void run() {
+ /*
+ * copy bucket and host data,
+ * generating a temporary internal table.
+ */
+ rb.copyData();
+
+ /*
+ * allocate owners for all buckets without an owner,
+ * and rebalance bucket owners, if necessary --
+ * this takes card of item #1, above.
+ */
+ rb.allocateBuckets();
+
+ /*
+ * make sure that primary backups always have the same site
+ * as the owner, and secondary backups always have a different
+ * site -- this takes care of #5, above.
+ */
+ rb.checkSiteValues();
+
+ /*
+ * adjust primary backup lists to take care of item #2, above
+ * (taking #5 into account).
+ */
+ rb.rebalancePrimaryBackups();
+
+ /*
+ * allocate secondary backups, and take care of items
+ * #3 and #4, above (taking #5 into account).
+ */
+ rb.rebalanceSecondaryBackups();
+
+ try {
+ synchronized (rebalanceLock) {
+ /*
+ * if another 'Rebalance' instance has started in the
+ * mean time, don't do the update.
+ */
+ if (rebalance == rb) {
+ /*
+ * build a message containing all of the updated bucket
+ * information, process it internally in this host
+ * (lead server), and send it out to others in the
+ * "notify list".
+ */
+ rb.generateBucketMessage();
+ rebalance = null;
+ }
+ }
+ } catch (IOException e) {
+ logger.error("Exception in Rebalance.generateBucketMessage",
+ e);
+ }
+ }
+ }.start();
+ }
+ }
+
+ /**
+ * Handle an incoming /bucket/update REST message.
+ *
+ * @param data base64-encoded data, containing all bucket updates
+ */
+ static void updateBucket(byte[] data) {
+ final byte[] packet = Base64.getDecoder().decode(data);
+ Runnable task = () -> {
+ try {
+ /*
+ * process the packet, handling any updates
+ */
+ if (updateBucketInternal(packet)) {
+ /*
+ * updates have occurred -- forward this packet to
+ * all servers in the "notify list"
+ */
+ logger.info("One or more bucket updates occurred");
+ Entity<String> entity =
+ Entity.entity(new String(data, StandardCharsets.UTF_8),
+ MediaType.APPLICATION_OCTET_STREAM_TYPE);
+ for (Server server : Server.getNotifyList()) {
+ server.post("bucket/update", entity);
+ }
+ }
+ } catch (Exception e) {
+ logger.error("Exception in Bucket.updateBucket", e);
+ }
+ };
+ MainLoop.queueWork(task);
+ }
+
+ /**
+ * This method supports the 'updateBucket' method, and runs entirely within
+ * the 'MainLoop' thread.
+ */
+ private static boolean updateBucketInternal(byte[] packet) throws IOException {
+ boolean changes = false;
+
+ ByteArrayInputStream bis = new ByteArrayInputStream(packet);
+ DataInputStream dis = new DataInputStream(bis);
+
+ // the packet contains a sequence of bucket updates
+ while (dis.available() != 0) {
+ // first parameter = bucket number
+ int index = dis.readUnsignedShort();
+
+ // locate the corresponding 'Bucket' object
+ Bucket bucket = indexToBucket[index];
+
+ // indicates whether changes occurred to the bucket
+ boolean bucketChanges = false;
+
+ /*
+ * the remainder of the information for this bucket consists of
+ * a sequence of '<tag> [ <associated-data> ]' followed by the tag
+ * value 'END_OF_PARAMETERS_TAG'.
+ */
+ int tag;
+ while ((tag = dis.readUnsignedByte()) != END_OF_PARAMETERS_TAG) {
+ switch (tag) {
+ case OWNER_UPDATE: {
+ // <OWNER_UPDATE> <owner-uuid> -- owner UUID specified
+ bucketChanges = updateBucketInternalOwnerUpdate(bucket, dis, index);
+ break;
+ }
+
+ case OWNER_NULL: {
+ // <OWNER_NULL> -- owner UUID should be set to 'null'
+ if (bucket.getOwner() != null) {
+ logger.info("Bucket {} owner: {}->null",
+ index, bucket.getOwner());
+ bucketChanges = true;
+ synchronized (bucket) {
+ bucket.setOwner(null);
+ bucket.setState(null);
+ }
+ }
+ break;
+ }
+
+ case PRIMARY_BACKUP_UPDATE: {
+ // <PRIMARY_BACKUP_UPDATE> <primary-backup-uuid> --
+ // primary backup UUID specified
+ Server newPrimaryBackup =
+ Server.getServer(Util.readUuid(dis));
+ if (bucket.primaryBackup != newPrimaryBackup) {
+ logger.info("Bucket {} primary backup: {}->{}", index,
+ bucket.primaryBackup, newPrimaryBackup);
+ bucketChanges = true;
+ bucket.primaryBackup = newPrimaryBackup;
+ }
+ break;
+ }
+
+ case PRIMARY_BACKUP_NULL: {
+ // <PRIMARY_BACKUP_NULL> --
+ // primary backup should be set to 'null'
+ if (bucket.primaryBackup != null) {
+ logger.info("Bucket {} primary backup: {}->null",
+ index, bucket.primaryBackup);
+ bucketChanges = true;
+ bucket.primaryBackup = null;
+ }
+ break;
+ }
+
+ case SECONDARY_BACKUP_UPDATE: {
+ // <SECONDARY_BACKUP_UPDATE> <secondary-backup-uuid> --
+ // secondary backup UUID specified
+ Server newSecondaryBackup =
+ Server.getServer(Util.readUuid(dis));
+ if (bucket.secondaryBackup != newSecondaryBackup) {
+ logger.info("Bucket {} secondary backup: {}->{}", index,
+ bucket.secondaryBackup, newSecondaryBackup);
+ bucketChanges = true;
+ bucket.secondaryBackup = newSecondaryBackup;
+ }
+ break;
+ }
+
+ case SECONDARY_BACKUP_NULL: {
+ // <SECONDARY_BACKUP_NULL> --
+ // secondary backup should be set to 'null'
+ if (bucket.secondaryBackup != null) {
+ logger.info("Bucket {} secondary backup: {}->null",
+ index, bucket.secondaryBackup);
+ bucketChanges = true;
+ bucket.secondaryBackup = null;
+ }
+ break;
+ }
+
+ default:
+ logger.error("Illegal tag: {}", tag);
+ break;
+ }
+ }
+ if (bucketChanges) {
+ // give audit a chance to run
+ changes = true;
+ bucket.stateChanged();
+ }
+ }
+ return changes;
+ }
+
+ /**
+ * Update bucket owner information.
+ *
+ * @param bucket the bucket in process
+ * @param dis data input stream contains the update
+ * @param index the bucket number
+ * @return a value indicate bucket changes
+ */
+ private static boolean updateBucketInternalOwnerUpdate(Bucket bucket, DataInputStream dis,
+ int index) throws IOException {
+ boolean bucketChanges = false;
+ Server newOwner = Server.getServer(Util.readUuid(dis));
+ if (bucket.getOwner() != newOwner) {
+ logger.info("Bucket {} owner: {}->{}",
+ index, bucket.getOwner(), newOwner);
+ bucketChanges = true;
+
+ Server thisServer = Server.getThisServer();
+ Server oldOwner = bucket.getOwner();
+ bucket.setOwner(newOwner);
+ if (thisServer == oldOwner) {
+ // the current server is the old owner
+ if (bucket.getState() == null) {
+ bucket.state = bucket.new OldOwner(newOwner);
+ }
+ } else if (thisServer == newOwner) {
+ // the current server the new owner
+ if (bucket.getState() == null) {
+ bucket.state = bucket.new NewOwner(true, oldOwner);
+ } else {
+ // new owner has been confirmed
+ // orig bucket.state.newOwner();
+ bucket.state.newOwner();
+ }
+ }
+ }
+ return bucketChanges;
+ }
+
+ /**
+ * Forward a message to the specified bucket number. If the bucket is
+ * in a transient state (the value of 'state' is not 'null'), the handling
+ * is determined by that state.
+ *
+ * @param bucketNumber the bucket number determined by extracting the
+ * keyword from 'message'
+ * @param message the message to be forwarded/processed
+ * @return a value of 'true' indicates the message has been "handled"
+ * (forwarded or queued), and 'false' indicates it has not, and needs
+ * to be handled locally.
+ */
+ public static boolean forward(int bucketNumber, Message message) {
+ Bucket bucket = indexToBucket[bucketNumber];
+ Server server;
+
+ synchronized (bucket) {
+ if (bucket.state != null) {
+ // we are in a transient state -- the handling is state-specific
+ return bucket.state.forward(message);
+ }
+ server = bucket.getOwner();
+ }
+
+ if (server == null || server == Server.getThisServer()) {
+ // this needs to be processed locally
+ return false;
+ } else {
+ // send message to remote server
+ message.sendToServer(server, bucketNumber);
+ return true;
+ }
+ }
+
+ /**
+ * This is a convenience method, which forwards a message through the
+ * bucket associated with the specified keyword.
+ *
+ * @param keyword the keyword extracted from 'message'
+ * keyword from 'message'
+ * @param message the message to be forwarded/processed
+ * @return a value of 'true' indicates the message has been "handled"
+ * (forwarded or queued), and 'false' indicates it has not, and needs
+ * to be handled locally.
+ */
+ public static boolean forward(String keyword, Message message) {
+ return forward(bucketNumber(keyword), message);
+ }
+
+ /**
+ * Forward a message to the specified bucket number. If the bucket is
+ * in a transient state (the value of 'state' is not 'null'), the handling
+ * is determined by that state. This is a variant of the 'forward' method,
+ * which handles local processing, instead of just returning 'false'.
+ *
+ * @param bucketNumber the bucket number determined by extracting the
+ * keyword from 'message'
+ * @param message the message to be forwarded/processed
+ */
+ public static void forwardAndProcess(int bucketNumber, Message message) {
+ if (!forward(bucketNumber, message)) {
+ message.process();
+ }
+ }
+
+ /**
+ * Forward a message to the specified bucket number. If the bucket is
+ * in a transient state (the value of 'state' is not 'null'), the handling
+ * is determined by that state. This is a variant of the 'forward' method,
+ * which handles local processing, instead of just returning 'false'.
+ *
+ * @param keyword the keyword extracted from 'message'
+ * keyword from 'message'
+ * @param message the message to be forwarded/processed
+ */
+ public static void forwardAndProcess(String keyword, Message message) {
+ forwardAndProcess(bucketNumber(keyword), message);
+ }
+
+ /**
+ * Handle an incoming /cmd/dumpBuckets REST message.
+ *
+ * @param out the 'PrintStream' to use for displaying information
+ */
+ public static void dumpBuckets(final PrintStream out) {
+ /*
+ * we aren't really doing a 'Rebalance' here, but the 'copyData' method
+ * is useful for extracting the data, and determining the buckets
+ * associated with each server.
+ */
+ Rebalance rb = new Rebalance();
+ rb.copyData();
+
+ /*
+ * this method is not accessing anything in the 'Server' or 'Bucket'
+ * table, so it doesn't need to run within the 'MainLoop' thread.
+ */
+ rb.dumpBucketsInternal(out);
+ }
+
+ /**
+ * Handle an incoming /cmd/bucketMessage REST message -- this is only
+ * used for testing the routing of messages between servers.
+ *
+ * @param out the 'PrintStream' to use for displaying information
+ * @param keyword the keyword that is hashed to select the bucket number
+ * @param message the message to send to the remote end
+ * @throws IOException when error occurred
+ */
+ public static void bucketMessage(
+ final PrintStream out, final String keyword, String message) throws IOException {
+
+ if (keyword == null) {
+ out.println("'keyword' is mandatory");
+ return;
+ }
+ if (message == null) {
+ message = "Message generated at " + new Date();
+ }
+ final int bucketNumber = bucketNumber(keyword);
+ Server server = bucketToServer(bucketNumber);
+
+ if (server == null) {
+ /*
+ * selected bucket has no server assigned -- this should only be a
+ * transient situation, until 'rebalance' is run.
+ */
+ out.println("Bucket is " + bucketNumber + ", which has no owner");
+ } else if (server == Server.getThisServer()) {
+ /*
+ * the selected bucket is associated with this particular server --
+ * no forwarding is needed.
+ */
+ out.println("Bucket is " + bucketNumber
+ + ", which is owned by this server: " + server.getUuid());
+ } else {
+ /*
+ * the selected bucket is assigned to a different server -- forward
+ * the message.
+ */
+ out.println("Bucket is " + bucketNumber + ": sending from\n"
+ + " " + Server.getThisServer().getUuid() + " to \n"
+ + " " + server.getUuid());
+
+ // do a POST call of /bucket/bucketResponse to the remoote server
+ Entity<String> entity =
+ Entity.entity(new String(message.getBytes(), StandardCharsets.UTF_8),
+ MediaType.TEXT_PLAIN);
+
+ /*
+ * the POST itself runs in a server-specific thread, and
+ * 'responseQueue' is used to pass back the response.
+ */
+ final LinkedTransferQueue<Response> responseQueue =
+ new LinkedTransferQueue<>();
+
+ server.post("bucket/bucketResponse", entity, new Server.PostResponse() {
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public WebTarget webTarget(WebTarget webTarget) {
+ // we need to include the 'bucket' and 'keyword' parameters
+ // in the POST that we are sending out
+ return webTarget
+ .queryParam("bucket", bucketNumber)
+ .queryParam("keyword", keyword);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void response(Response response) {
+ // this is the POST response --
+ // pass it back to the calling thread
+ responseQueue.put(response);
+ }
+ });
+
+ try {
+ // this is the calling thread -- wait for the POST response
+ Response response = responseQueue.poll(60, TimeUnit.SECONDS);
+ if (response == null) {
+ out.println("Timed out waiting for a response");
+ } else {
+ out.println("Received response code " + response.getStatus());
+ out.println("Entity = " + response.readEntity(String.class));
+ }
+ } catch (InterruptedException e) {
+ out.println(e);
+ throw new IOException(e);
+ }
+ }
+ }
+
+ /**
+ * Handle an incoming /bucket/bucketResponse REST message -- this runs on
+ * the destination host, and is the continuation of an operation triggered
+ * by the /cmd/bucketMessage REST message running on the originating host.
+ *
+ * @param out the 'PrintStream' to use for passing back information
+ * in a human-readable form
+ * @param bucket the bucket number, which should be owned by this host
+ * if we are in sync with the sending host, and didn't get caught
+ * in a transient state
+ * @param keyword the keyword selected on the originating end, which should
+ * hash to 'bucket'
+ * @param message the message selected on the originating end
+ */
+ public static void bucketResponse(
+ final PrintStream out, int bucket, String keyword, byte[] message) {
+
+ Server thisServer = Server.getThisServer();
+ Server server = bucketToServer(bucket);
+
+ if (server != thisServer) {
+ /*
+ * this isn't expected, and either indicates we are out-of-sync with
+ * pthe originating server, or this operation was triggered while in
+ * a transient state.
+ */
+ out.println("ERROR: " + thisServer.toString() + ": bucket " + bucket
+ + "is owned by\n " + server);
+ } else {
+ /*
+ * As expected, we are the owner of this bucket. Print out a message,
+ * which will be returned to the originating host, and displayed.
+ */
+ out.println(thisServer.toString() + ":\n"
+ + " bucket = " + bucket
+ + "\n keyword = " + keyword
+ + "\n message = " + new String(message));
+ }
+ }
+
+ /**
+ * Handle an incoming /cmd/moveBucket REST message -- this is only
+ * used for testing bucket migration. It only works on the lead server.
+ *
+ * @param out the 'PrintStream' to use for displaying information
+ * @param bucketNumber the bucket number to be moved
+ * @param newHostUuid the UUID of the destination host (if 'null', a
+ * destination host will be chosen at random)
+ */
+ public static void moveBucket(PrintStream out, int bucketNumber, String newHostUuid) {
+ Server leader = Leader.getLeader();
+ if (leader != Server.getThisServer()) {
+ out.println("This is not the lead server");
+ return;
+ }
+
+ if (bucketNumber < 0 || bucketNumber >= indexToBucket.length) {
+ out.println("Bucket number out of range");
+ return;
+ }
+
+ Rebalance rb = new Rebalance();
+ rb.copyData();
+
+ TestBucket bucket = rb.buckets[bucketNumber];
+ TestServer oldHost = bucket.owner;
+
+ if (oldHost == rb.nullServer) {
+ out.println("Bucket " + bucketNumber + " is currently unassigned");
+ return;
+ }
+
+ TestServer newHost = null;
+
+ if (newHostUuid != null) {
+ // the UUID of a destination host has been specified
+ newHost = rb.testServers.get(UUID.fromString(newHostUuid));
+ if (newHost == null) {
+ out.println("Can't locate UUID " + newHostUuid);
+ return;
+ }
+ } else {
+ /*
+ * Choose a destination host at random, other than the current owner.
+ * Step a random count in the range of 1 to (n-1) relative to the
+ * current host.
+ */
+ UUID key = oldHost.uuid;
+ for (int count = new Random().nextInt(rb.testServers.size() - 1) ;
+ count >= 0 ; count -= 1) {
+ key = rb.testServers.higherKey(key);
+ if (key == null) {
+ // wrap to the beginning of the list
+ key = rb.testServers.firstKey();
+ }
+ }
+ newHost = rb.testServers.get(key);
+ }
+ out.println("Moving bucket " + bucketNumber + " from "
+ + oldHost + " to " + newHost);
+
+ /*
+ * update the owner, and ensure that the primary and secondary backup
+ * remain different from the owner.
+ */
+ bucket.setOwner(newHost);
+ if (newHost == bucket.primaryBackup) {
+ out.println("Moving primary back from " + newHost + " to " + oldHost);
+ bucket.setPrimaryBackup(oldHost);
+ } else if (newHost == bucket.secondaryBackup) {
+ out.println("Moving secondary back from " + newHost
+ + " to " + oldHost);
+ bucket.setSecondaryBackup(oldHost);
+ }
+
+ try {
+ /*
+ * build a message containing all of the updated bucket
+ * information, process it internally in this host
+ * (lead server), and send it out to others in the
+ * "notify list".
+ */
+ rb.generateBucketMessage();
+ } catch (IOException e) {
+ logger.error("Exception in Rebalance.generateBucketMessage",
+ e);
+ }
+ }
+
+ /**
+ * This method is called when an incoming /bucket/sessionData message is
+ * received from the old owner of the bucket, which presumably means that
+ * we are the new owner of the bucket.
+ *
+ * @param bucketNumber the bucket number
+ * @param dest the UUID of the intended destination
+ * @param ttl similar to IP time-to-live -- it controls the number of hops
+ * the message may take
+ * @param data serialized data associated with this bucket, encoded using
+ * base64
+ */
+
+ static void sessionData(int bucketNumber, UUID dest, int ttl, byte[] data) {
+ logger.info("Bucket.sessionData: bucket={}, data length={}",
+ bucketNumber, data.length);
+
+ if (dest != null && !dest.equals(Server.getThisServer().getUuid())) {
+ // the message needs to be forwarded to the intended destination
+ Server server;
+ WebTarget webTarget;
+
+ if ((ttl -= 1) > 0
+ && (server = Server.getServer(dest)) != null
+ && (webTarget = server.getWebTarget("bucket/sessionData")) != null) {
+ logger.info("Forwarding 'bucket/sessionData' to uuid {}",
+ server.getUuid());
+ Entity<String> entity =
+ Entity.entity(new String(data, StandardCharsets.UTF_8),
+ MediaType.APPLICATION_OCTET_STREAM_TYPE);
+ Response response =
+ webTarget
+ .queryParam("bucket", bucketNumber)
+ .queryParam("dest", dest)
+ .queryParam("ttl", String.valueOf(ttl))
+ .request().post(entity);
+ logger.info("/bucket/sessionData response code = {}",
+ response.getStatus());
+ } else {
+ logger.error("Couldn't forward 'bucket/sessionData' to uuid {}, ttl={}",
+ dest, ttl);
+ }
+ return;
+ }
+
+ byte[] decodedData = Base64.getDecoder().decode(data);
+ Bucket bucket = indexToBucket[bucketNumber];
+
+ logger.info("Bucket.sessionData: decoded data length = {}",
+ decodedData.length);
+
+ if (bucket.state == null) {
+ /*
+ * We received the serialized data prior to being notified
+ * that we are the owner -- this happens sometimes. Behave as
+ * though we are the new owner, but intidate it is unconfirmed.
+ */
+ logger.info("Bucket {} session data received unexpectedly",
+ bucketNumber);
+ bucket.state = bucket.new NewOwner(false, bucket.getOwner());
+ }
+ bucket.state.bulkSerializedData(decodedData);
+ }
+
+ /**
+ * This method is called whenever the bucket's state has changed in a
+ * way that it should be audited.
+ */
+ private synchronized void stateChanged() {
+ if (state != null) {
+ return;
+ }
+ // the audit should be run
+ Server thisServer = Server.getThisServer();
+ boolean isOwner = (thisServer == owner);
+ boolean isBackup = (!isOwner && (thisServer == primaryBackup
+ || thisServer == secondaryBackup));
+
+ // invoke 'TargetLock' directly
+ TargetLock.auditBucket(this, isOwner, isBackup);
+ for (ServerPoolApi feature : ServerPoolApi.impl.getList()) {
+ feature.auditBucket(this, isOwner, isBackup);
+ }
+ }
+
+ /**
+ * Returns an adjunct of the specified class
+ * (it is created if it doesn't exist).
+ *
+ * @param clazz this is the class of the adjunct
+ * @return an adjunct of the specified class ('null' may be returned if
+ * the 'newInstance' method is unable to create the adjunct)
+ */
+ public <T> T getAdjunct(Class<T> clazz) {
+ synchronized (adjuncts) {
+ // look up the adjunct in the table
+ Object adj = adjuncts.get(clazz);
+ if (adj == null) {
+ // lookup failed -- create one
+ try {
+ // create the adjunct (may trigger an exception)
+ adj = clazz.newInstance();
+
+ // update the table
+ adjuncts.put(clazz, adj);
+ } catch (Exception e) {
+ logger.error("Can't create adjunct of {}", clazz, e);
+ }
+ }
+ return clazz.cast(adj);
+ }
+ }
+
+ /**
+ * Returns an adjunct of the specified class.
+ *
+ * @param clazz this is the class of the adjunct
+ * @return an adjunct of the specified class, if it exists,
+ * and 'null' if it does not
+ */
+ public <T> T getAdjunctDontCreate(Class<T> clazz) {
+ synchronized (adjuncts) {
+ // look up the adjunct in the table
+ return clazz.cast(adjuncts.get(clazz));
+ }
+ }
+
+ /**
+ * Explicitly create an adjunct -- this is useful when the adjunct
+ * initialization requires that some parameters be passed.
+ *
+ * @param adj this is the adjunct to insert into the table
+ * @return the previous adjunct of this type ('null' if none)
+ */
+ public Object putAdjunct(Object adj) {
+ synchronized (adjuncts) {
+ Class clazz = adj.getClass();
+ return adjuncts.put(clazz, adj);
+ }
+ }
+
+ /**
+ * Remove an adjunct.
+ *
+ * @param clazz this is the class of adjuncts to remove
+ * @return the object, if found, and 'null' if not
+ */
+ public <T> T removeAdjunct(Class<T> clazz) {
+ synchronized (adjuncts) {
+ // remove the adjunct in the table
+ return clazz.cast(adjuncts.remove(clazz));
+ }
+ }
+
+ /**
+ * Dump out all buckets with adjuncts.
+ *
+ * @param out the 'PrintStream' to use for displaying information
+ */
+ public static void dumpAdjuncts(PrintStream out) {
+ boolean noneFound = true;
+ String format = "%6s %s\n";
+
+ for (Bucket bucket : indexToBucket) {
+ synchronized (bucket.adjuncts) {
+ if (bucket.adjuncts.size() != 0) {
+ if (noneFound) {
+ out.printf(format, "Bucket", "Adjunct Classes");
+ out.printf(format, "------", "---------------");
+ noneFound = false;
+ }
+ boolean first = true;
+ for (Class<?> clazz : bucket.adjuncts.keySet()) {
+ if (first) {
+ out.printf(format, bucket.index, clazz.getName());
+ first = false;
+ } else {
+ out.printf(format, "", clazz.getName());
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * There is a single instance of this class (Bucket.eventHandler), which
+ * is registered to listen for notifications of state transitions. Note
+ * that all of these methods are running within the 'MainLoop' thread.
+ */
+ private static class EventHandler implements Events {
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void serverFailed(Server server) {
+ // remove this server from any bucket where it is referenced
+
+ Server thisServer = Server.getThisServer();
+ for (Bucket bucket : indexToBucket) {
+ synchronized (bucket) {
+ boolean changes = false;
+ if (bucket.getOwner() == server) {
+ // the failed server owns this bucket --
+ // move to the primary backup
+ bucket.setOwner(bucket.getPrimaryBackup());
+ bucket.primaryBackup = null;
+ changes = true;
+
+ if (bucket.getOwner() == null) {
+ // bucket owner is still null -- presumably, we had no
+ // primary backup, so use the secondary backup instead
+ bucket.setOwner(bucket.getSecondaryBackup());
+ bucket.setSecondaryBackup(null);
+ }
+ }
+ if (bucket.getPrimaryBackup() == server) {
+ // the failed server was a primary backup to this bucket --
+ // mark the entry as 'null'
+ bucket.setPrimaryBackup(null);
+ changes = true;
+ }
+ if (bucket.getSecondaryBackup() == server) {
+ // the failed server was a secondary backup to this bucket --
+ // mark the entry as 'null'
+ bucket.setSecondaryBackup(null);
+ changes = true;
+ }
+
+ if (bucket.owner == thisServer && bucket.state == null) {
+ // the current server is the new owner
+ bucket.setState(bucket.new NewOwner(false, null));
+ changes = true;
+ }
+
+ if (changes) {
+ // may give audits a chance to run
+ bucket.stateChanged();
+ }
+ }
+ }
+
+ // trigger a rebalance (only happens if we are the lead server)
+ rebalance();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void newLeader(Server server) {
+ // trigger a rebalance (only happens if we are the new lead server)
+ rebalance();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void leaderConfirmed(Server server) {
+ // trigger a rebalance (only happens if we are the lead server)
+ rebalance();
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Instances of this class are created as part of the 'rebalance'
+ * operation on the lead server, or as part of a 'dumpBuckets' operation
+ * on any server.
+ * Each instance of this class corresponds to a 'Bucket' instance.
+ */
+ private static class TestBucket implements Comparable<TestBucket> {
+ // bucket number
+ int index;
+
+ // owner of the bucket
+ TestServer owner;
+
+ // primary backup for this bucket
+
+ TestServer primaryBackup;
+
+ // secondary backup for this bucket
+ TestServer secondaryBackup;
+
+ /**
+ * Constructor -- initialize the 'TestBucket' instance.
+ *
+ * @param index the bucket number
+ */
+ TestBucket(int index) {
+ this.index = index;
+ }
+
+ /**
+ * Update the owner of a bucket, which also involves updating the
+ * backward links in the 'TestServer' instances.
+ *
+ * @param newOwner the new owner of the bucket
+ */
+ void setOwner(TestServer newOwner) {
+ if (owner != newOwner) {
+ // the 'owner' field does need to be changed
+ if (owner != null) {
+ // remove this bucket from the 'buckets' list of the old owner
+ owner.buckets.remove(this);
+ }
+ if (newOwner != null) {
+ // add this bucket to the 'buckets' list of the new owner
+ newOwner.buckets.add(this);
+ }
+ // update the 'owner' field in the bucket
+ owner = newOwner;
+ }
+ }
+
+ /**
+ * Update the primary backup of a bucket, which also involves updating
+ * the backward links in the 'TestServer' instances.
+ *
+ * @param newPrimaryBackup the new primary of the bucket
+ */
+ void setPrimaryBackup(TestServer newPrimaryBackup) {
+ if (primaryBackup != newPrimaryBackup) {
+ // the 'primaryBackup' field does need to be changed
+ if (primaryBackup != null) {
+ // remove this bucket from the 'buckets' list of the
+ // old primary backup
+ primaryBackup.primaryBackupBuckets.remove(this);
+ }
+ if (newPrimaryBackup != null) {
+ // add this bucket to the 'buckets' list of the
+ // new primary backup
+ newPrimaryBackup.primaryBackupBuckets.add(this);
+ }
+ // update the 'primaryBackup' field in the bucket
+ primaryBackup = newPrimaryBackup;
+ }
+ }
+
+ /**
+ * Update the secondary backup of a bucket, which also involves updating
+ * the backward links in the 'TestServer' instances.
+ *
+ * @param newSecondaryBackup the new secondary of the bucket
+ */
+ void setSecondaryBackup(TestServer newSecondaryBackup) {
+ if (secondaryBackup != newSecondaryBackup) {
+ // the 'secondaryBackup' field does need to be changed
+ if (secondaryBackup != null) {
+ // remove this bucket from the 'buckets' list of the
+ // old secondary backup
+ secondaryBackup.secondaryBackupBuckets.remove(this);
+ }
+ if (newSecondaryBackup != null) {
+ // add this bucket to the 'buckets' list of the
+ // new secondary backup
+ newSecondaryBackup.secondaryBackupBuckets.add(this);
+ }
+ // update the 'secondaryBackup' field in the bucket
+ secondaryBackup = newSecondaryBackup;
+ }
+ }
+
+ /*==================================*/
+ /* Comparable<TestBucket> interface */
+ /*==================================*/
+
+ /**
+ * Compare two 'TestBucket' instances, for use in a 'TreeSet'.
+ *
+ * @param other the other 'TestBucket' instance to compare to
+ */
+ @Override
+ public int compareTo(TestBucket other) {
+ return index - other.index;
+ }
+
+ /**
+ * Return a string representation of this 'TestBucket' instance.
+ *
+ * @return a string representation of this 'TestBucket' instance
+ */
+ @Override
+ public String toString() {
+ return "TestBucket[" + index + "]";
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Instances of this class are created as part of the 'rebalance'
+ * operation on the lead server, or as part of a 'dumpBuckets' operation
+ * on any server.
+ * Each instance of this class corresponds to a 'Server' instance.
+ * Unlike the actual 'Server' instances, each 'TestServer' instance
+ * contains back links to all of the buckets it is associated with.
+ */
+ private static class TestServer {
+ // unique id for this server
+ // (matches the one in the corresponding 'Server' entry)
+ final UUID uuid;
+
+ // site socket information (matches 'Server' entry)
+ final InetSocketAddress siteSocketAddress;
+
+ // the set of all 'TestBucket' instances,
+ // where this 'TestServer' is listed as 'owner'
+ final TreeSet<TestBucket> buckets = new TreeSet<>();
+
+ // the set of all 'TestBucket' instances,
+ // where this 'TestServer' is listed as 'primaryBackup'
+ final TreeSet<TestBucket> primaryBackupBuckets = new TreeSet<>();
+
+ // the set of all 'TestBucket' instances,
+ // where this 'TestServer' is listed as 'secondaryBackup'
+ final TreeSet<TestBucket> secondaryBackupBuckets = new TreeSet<>();
+
+ /**
+ * Constructor.
+ *
+ * @param uuid uuid of this 'TestServer' instance
+ * @param siteSocketAddress matches the value in the corresponding 'Server'
+ */
+ TestServer(UUID uuid, InetSocketAddress siteSocketAddress) {
+ this.uuid = uuid;
+ this.siteSocketAddress = siteSocketAddress;
+ }
+
+ /**
+ * Return a string representation of this 'TestServer' instance.
+ *
+ * @return a string representation of this 'TestServer' instance
+ */
+ @Override
+ public String toString() {
+ return "TestServer[" + uuid + "]";
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This class supports the 'rebalance' operation. Each instance is a wrapper
+ * around a 'TestServer' instance, as it would be if another specific
+ * server failed.
+ */
+ private static class AdjustedTestServer
+ implements Comparable<AdjustedTestServer> {
+ TestServer server;
+
+ // simulated fail on this server
+ TestServer failedServer;
+
+ // expected bucket count if 'failedServer' fails
+ int bucketCount;
+
+ // total number of primary backup buckets used by this host
+ int primaryBackupBucketCount;
+
+ // total number of secondary backup buckets used by this host
+ int secondaryBackupBucketCount;
+
+ /**
+ * Constructor.
+ *
+ * @param server the server this 'AdjustedTestServer' instance represents
+ * @param failedServer the server going through a simulated failure --
+ * the 'bucketCount' value is adjusted based upon this
+ */
+ AdjustedTestServer(TestServer server, TestServer failedServer) {
+ this.server = server;
+ this.failedServer = failedServer;
+
+ this.bucketCount = server.buckets.size();
+ this.primaryBackupBucketCount = server.primaryBackupBuckets.size();
+ this.secondaryBackupBucketCount = server.secondaryBackupBuckets.size();
+
+ // need to adjust 'bucketCount' for the case where the current
+ // host fails
+ for (TestBucket bucket : server.primaryBackupBuckets) {
+ if (bucket.owner == failedServer) {
+ bucketCount += 1;
+ // TBD: should 'primaryBackupBucketCount' be decremented?
+ }
+ }
+
+ // need to adjust 'bucketCount' for the case where the current
+ // host fails
+ for (TestBucket bucket : server.secondaryBackupBuckets) {
+ if (bucket.owner == failedServer) {
+ bucketCount += 1;
+ // TBD: should 'secondaryBackupBucketCount' be decremented?
+ }
+ }
+ }
+
+ /********************************************/
+ /* Comparable<AdjustedTestServer> interface */
+ /********************************************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int compareTo(AdjustedTestServer other) {
+ /*
+ * Comparison order:
+ * 1) minimal expected bucket count if current host fails
+ * (differences of 1 are treated as a match)
+ * 2) minimal backup bucket count
+ * 3) UUID order
+ */
+ int rval = bucketCount - other.bucketCount;
+ if (rval <= 1 && rval >= -1) {
+ rval = (primaryBackupBucketCount + secondaryBackupBucketCount)
+ - (other.primaryBackupBucketCount
+ + other.secondaryBackupBucketCount);
+ if (rval == 0) {
+ rval = -Util.uuidComparator.compare(server.uuid, other.server.uuid);
+ }
+ }
+ return rval;
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This class is primarily used to do a 'Rebalance' operation on the
+ * lead server, which is then distributed to all of the other servers.
+ * Part of it is also useful for implementing the /cmd/dumpBuckets
+ * REST message handler.
+ */
+ private static class Rebalance {
+ // this table resembles the 'Bucket.indexToBucket' table
+ TestBucket[] buckets = new TestBucket[indexToBucket.length];
+
+ // this table resembles the 'Server.servers' table
+ TreeMap<UUID,TestServer> testServers = new TreeMap<>(Util.uuidComparator);
+
+ /* this is a special server instance, which is allocated any
+ * owned, primary, or secondary buckets that haven't been allocated to
+ * any of the real servers
+ */
+ TestServer nullServer = new TestServer(null, null);
+
+ /**
+ * Copy all of the bucket data in the 'buckets' table, and also return
+ * a copy of the 'Server.servers' table
+ */
+ void copyData() {
+ // will contain a copy of the 'Bucket' table
+ final Bucket[] bucketSnapshot = new Bucket[indexToBucket.length];
+
+ /*
+ * This method is running within the 'MainLoop' thread,
+ * and builds a copy of the 'Bucket' table, as well as the
+ * list of active servers -- these can then be examined
+ * in a different thread, without potentially distrupting
+ * the 'MainLoop' thread.
+ *
+ * @return 0 (the return value is not significant at present)
+ */
+ Callable<Integer> callable = () -> {
+ // copy the 'Bucket' table
+ for (int i = 0 ; i < indexToBucket.length; i += 1) {
+ // makes a snapshot of the bucket information
+ Bucket bucket = indexToBucket[i];
+
+ Bucket tmpBucket = new Bucket(i);
+ tmpBucket.setOwner(bucket.getOwner());
+ tmpBucket.setPrimaryBackup(bucket.getPrimaryBackup());
+ tmpBucket.setSecondaryBackup(bucket.getSecondaryBackup());
+ bucketSnapshot[i] = tmpBucket;
+ }
+
+ /*
+ * At this point, 'bucketSnapshot' and 'servers' should be
+ * complete. The next step is to create a 'TestServer' entry
+ * that matches each 'Server' entry.
+ */
+ for (Server server : Server.getServers()) {
+ UUID uuid = server.getUuid();
+ testServers.put(uuid, new TestServer(uuid, server.getSiteSocketAddress()));
+ }
+
+ return 0;
+ };
+ FutureTask<Integer> ft = new FutureTask(callable);
+ MainLoop.queueWork(ft);
+ try {
+ ft.get(60, TimeUnit.SECONDS);
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ logger.error("Exception in Rebalance.copyData", e);
+ return;
+ }
+
+ /*
+ * Now, create a 'TestBucket' table that mirrors the 'Bucket' table.
+ * Unlike the standard 'Bucket' and 'Server' tables, the 'TestServer'
+ * entries contain links referring back to the 'TestBucket' entries.
+ * This information is useful when rebalancing.
+ */
+ for (Bucket bucket : bucketSnapshot) {
+ int index = bucket.index;
+ TestBucket testBucket = new TestBucket(index);
+
+ // populate the 'owner' field
+ if (bucket.getOwner() != null) {
+ testBucket.setOwner(testServers.get(bucket.getOwner().getUuid()));
+ } else {
+ testBucket.setOwner(nullServer);
+ }
+
+ // populate the 'primaryBackup' field
+ if (bucket.primaryBackup != null) {
+ testBucket.setPrimaryBackup(
+ testServers.get(bucket.primaryBackup.getUuid()));
+ } else {
+ testBucket.setPrimaryBackup(nullServer);
+ }
+
+ // populate the 'secondaryBackup' field
+ if (bucket.secondaryBackup != null) {
+ testBucket.setSecondaryBackup(
+ testServers.get(bucket.secondaryBackup.getUuid()));
+ } else {
+ testBucket.setSecondaryBackup(nullServer);
+ }
+ buckets[index] = testBucket;
+ }
+ }
+
+ /**
+ * Allocate unowned 'TestBucket' entries across all of the 'TestServer'
+ * entries. When every 'TestBucket' has an owner, rebalance as needed,
+ * so the 'TestServer' entry with the most buckets has at most one more
+ * bucket than the 'TestServer' entry with the least.
+ */
+ void allocateBuckets() {
+ /*
+ * the following 'Comparator' is used to control the order of the
+ * 'needBuckets' TreeSet: those with the fewest buckets allocated are
+ * at the head of the list.
+ */
+ Comparator<TestServer> bucketCount = new Comparator<TestServer>() {
+ @Override
+ public int compare(TestServer s1, TestServer s2) {
+ int rval = s1.buckets.size() - s2.buckets.size();
+ if (rval == 0) {
+ rval = Util.uuidComparator.compare(s1.uuid, s2.uuid);
+ }
+ return rval;
+ }
+ };
+
+ // sort servers according to the order in which they can
+ // take on ownership of buckets
+ TreeSet<TestServer> needBuckets = new TreeSet<>(bucketCount);
+ for (TestServer ts : testServers.values()) {
+ needBuckets.add(ts);
+ }
+
+ // go through all of the unowned buckets, and allocate them
+ for (TestBucket bucket : new LinkedList<TestBucket>(nullServer.buckets)) {
+ // take first entry off of sorted server list
+ TestServer ts = needBuckets.first();
+ needBuckets.remove(ts);
+
+ // add this bucket to the 'buckets' list
+ bucket.setOwner(ts);
+
+ // place it back in the list, possibly in a new position
+ // (it's attributes have changed)
+ needBuckets.add(ts);
+ }
+ nullServer.buckets.clear();
+
+ // there may still be rebalancing needed -- no host should contain
+ // 2 or more buckets more than any other host
+ for ( ; ; ) {
+ TestServer first = needBuckets.first();
+ TestServer last = needBuckets.last();
+
+ if (last.buckets.size() - first.buckets.size() <= 1) {
+ // no more rebalancing needed
+ break;
+ }
+
+ // remove both from sorted list
+ needBuckets.remove(first);
+ needBuckets.remove(last);
+
+ // take one bucket from 'last', and assign it to 'first'
+ last.buckets.first().setOwner(first);
+
+ // place back in sorted list
+ needBuckets.add(first);
+ needBuckets.add(last);
+ }
+ }
+
+ /**
+ * Make sure that the primary backups have the same site as the owner,
+ * and the secondary backups have a different site.
+ */
+ void checkSiteValues() {
+ for (TestBucket bucket : buckets) {
+ if (bucket.owner != null) {
+ InetSocketAddress siteSocketAddress =
+ bucket.owner.siteSocketAddress;
+ TestServer primaryBackup = bucket.primaryBackup;
+ TestServer secondaryBackup = bucket.secondaryBackup;
+
+ validateSiteOwner(bucket, siteSocketAddress,
+ primaryBackup, secondaryBackup);
+ }
+ }
+ }
+
+ /**
+ * Validate primary site owner and secondary site owner are valid.
+ * @param bucket TestBucket
+ * @param siteSocketAddress site socket address
+ * @param primaryBackup primary backups
+ * @param secondaryBackup secondary backups
+ */
+ private void validateSiteOwner(TestBucket bucket, InetSocketAddress siteSocketAddress,
+ TestServer primaryBackup, TestServer secondaryBackup) {
+ if (primaryBackup != null
+ && !Objects.equals(siteSocketAddress,
+ primaryBackup.siteSocketAddress)) {
+ /**
+ * primary backup is from the wrong site -- see if we can
+ * use the secondary.
+ */
+ if (secondaryBackup != null
+ && Objects.equals(siteSocketAddress,
+ secondaryBackup.siteSocketAddress)) {
+ // swap primary and secondary
+ bucket.setPrimaryBackup(secondaryBackup);
+ bucket.setSecondaryBackup(primaryBackup);
+ } else {
+ // just invalidate primary backup
+ bucket.setPrimaryBackup(null);
+ }
+ } else if (secondaryBackup != null
+ && Objects.equals(siteSocketAddress,
+ secondaryBackup.siteSocketAddress)) {
+ // secondary backup is from the wrong site
+ bucket.setSecondaryBackup(null);
+ if (primaryBackup == null) {
+ // we can use this as the primary
+ bucket.setPrimaryBackup(secondaryBackup);
+ }
+ }
+ }
+
+ /**
+ * Allocate and rebalance the primary backups.
+ */
+ void rebalancePrimaryBackups() {
+ for (TestServer failedServer : testServers.values()) {
+ /*
+ * to allocate primary backups for this server,
+ * simulate a failure, and balance the backup hosts
+ */
+
+ // get siteSocketAddress from server
+ InetSocketAddress siteSocketAddress = failedServer.siteSocketAddress;
+
+ // populate a 'TreeSet' of 'AdjustedTestServer' instances based
+ // the failure of 'failedServer'
+ TreeSet<AdjustedTestServer> adjustedTestServers =
+ new TreeSet<AdjustedTestServer>();
+ for (TestServer server : testServers.values()) {
+ if (server == failedServer
+ || !Objects.equals(siteSocketAddress,
+ server.siteSocketAddress)) {
+ continue;
+ }
+ adjustedTestServers.add(new AdjustedTestServer(server, failedServer));
+ }
+
+ if (adjustedTestServers.isEmpty()) {
+ // this is presumably the only server -- there is no other server
+ // to act as primary backup, and no rebalancing can occur
+ continue;
+ }
+
+ // we need a backup host for each bucket
+ for (TestBucket bucket : failedServer.buckets) {
+ if (bucket.primaryBackup == null
+ || bucket.primaryBackup == nullServer) {
+ // need a backup host for this bucket -- remove the first
+ // entry from 'adjustedTestServers', which is most favored
+ AdjustedTestServer backupHost = adjustedTestServers.first();
+ adjustedTestServers.remove(backupHost);
+
+ // update add this bucket to the list
+ bucket.setPrimaryBackup(backupHost.server);
+
+ // update counts in 'AdjustedTestServer'
+ backupHost.bucketCount += 1;
+ backupHost.primaryBackupBucketCount += 1;
+
+ // place it back in the table, possibly in a new position
+ // (it's attributes have changed)
+ adjustedTestServers.add(backupHost);
+ }
+ }
+
+ // TBD: Is additional rebalancing needed?
+ }
+ }
+
+ /**
+ * Allocate and rebalance the secondary backups.
+ */
+ void rebalanceSecondaryBackups() {
+ for (TestServer failedServer : testServers.values()) {
+ /*
+ * to allocate secondary backups for this server,
+ * simulate a failure, and balance the backup hosts
+ */
+
+ // get siteSocketAddress from server
+ InetSocketAddress siteSocketAddress = failedServer.siteSocketAddress;
+
+ // populate a 'TreeSet' of 'AdjustedTestServer' instances based
+ // the failure of 'failedServer'
+ TreeSet<AdjustedTestServer> adjustedTestServers =
+ new TreeSet<AdjustedTestServer>();
+ for (TestServer server : testServers.values()) {
+ if (server == failedServer
+ || Objects.equals(siteSocketAddress,
+ server.siteSocketAddress)) {
+ continue;
+ }
+ adjustedTestServers.add(new AdjustedTestServer(server, failedServer));
+ }
+
+ if (adjustedTestServers.isEmpty()) {
+ // this is presumably the only server -- there is no other server
+ // to act as secondary backup, and no rebalancing can occur
+ continue;
+ }
+
+ // we need a backup host for each bucket
+ for (TestBucket bucket : failedServer.buckets) {
+ if (bucket.secondaryBackup == null
+ || bucket.secondaryBackup == nullServer) {
+ // need a backup host for this bucket -- remove the first
+ // entry from 'adjustedTestServers', which is most favored
+ AdjustedTestServer backupHost = adjustedTestServers.first();
+ adjustedTestServers.remove(backupHost);
+
+ // update add this bucket to the list
+ bucket.setSecondaryBackup(backupHost.server);
+
+ // update counts in 'AdjustedTestServer'
+ backupHost.bucketCount += 1;
+ backupHost.secondaryBackupBucketCount += 1;
+
+ // place it back in the table, possibly in a new position
+ // (it's attributes have changed)
+ adjustedTestServers.add(backupHost);
+ }
+ }
+
+ // TBD: Is additional rebalancing needed?
+ }
+ }
+
+ /**
+ * Generate a message with all of the bucket updates, process it locally,
+ * and send it to all servers in the "Notify List".
+ */
+ void generateBucketMessage() throws IOException {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ DataOutputStream dos = new DataOutputStream(bos);
+
+ // go through the entire 'buckets' table
+ for (int i = 0 ; i < buckets.length ; i += 1) {
+ // fetch the 'TestBucket' associated with index 'i'
+ TestBucket testBucket = buckets[i];
+
+ /*
+ * Get the UUID of the owner, primary backup, and secondary backup
+ * for this bucket. If the associated value does not exist, 'null'
+ * is used.
+ */
+ UUID newOwner = null;
+ UUID newPrimary = null;
+ UUID newSecondary = null;
+
+ if (testBucket.owner != nullServer && testBucket.owner != null) {
+ newOwner = testBucket.owner.uuid;
+ }
+ if (testBucket.primaryBackup != nullServer
+ && testBucket.primaryBackup != null) {
+ newPrimary = testBucket.primaryBackup.uuid;
+ }
+ if (testBucket.secondaryBackup != nullServer
+ && testBucket.secondaryBackup != null) {
+ newSecondary = testBucket.secondaryBackup.uuid;
+ }
+
+ // write bucket number
+ dos.writeShort(i);
+
+ // 'owner' field
+ if (newOwner != null) {
+ dos.writeByte(OWNER_UPDATE);
+ Util.writeUuid(dos, newOwner);
+ } else {
+ dos.writeByte(OWNER_NULL);
+ }
+
+ // 'primaryBackup' field
+ if (newPrimary != null) {
+ dos.writeByte(PRIMARY_BACKUP_UPDATE);
+ Util.writeUuid(dos, newPrimary);
+ } else {
+ dos.writeByte(PRIMARY_BACKUP_NULL);
+ }
+
+ // 'secondaryBackup' field
+ if (newSecondary != null) {
+ dos.writeByte(SECONDARY_BACKUP_UPDATE);
+ Util.writeUuid(dos, newSecondary);
+ } else {
+ dos.writeByte(SECONDARY_BACKUP_NULL);
+ }
+
+ dos.writeByte(END_OF_PARAMETERS_TAG);
+ }
+
+ // get the unencoded 'packet'
+ final byte[] packet = bos.toByteArray();
+
+ // create an 'Entity' containing the encoded packet
+ final Entity<String> entity =
+ Entity.entity(new String(Base64.getEncoder().encode(packet),
+ StandardCharsets.UTF_8), MediaType.APPLICATION_OCTET_STREAM_TYPE);
+ /**
+ * This method is running within the 'MainLoop' thread.
+ */
+ Runnable task = () -> {
+ try {
+ /*
+ * update the buckets on this host,
+ * which is presumably the lead server.
+ */
+ Bucket.updateBucketInternal(packet);
+ } catch (Exception e) {
+ logger.error("Exception updating buckets", e);
+ }
+
+ // send a message to all servers on the notify list
+ for (Server server : Server.getNotifyList()) {
+ server.post("bucket/update", entity);
+ }
+ };
+ MainLoop.queueWork(task);
+ }
+
+ /**
+ * Supports the '/cmd/dumpBuckets' REST message -- this isn't part of
+ * a 'rebalance' operation, but it turned out to be a convenient way
+ * to dump out the bucket table.
+ *
+ * @param out the output stream
+ */
+ private void dumpBucketsInternal(PrintStream out) {
+ // xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx xxxxxxxxx *
+ // UUID Type Buckets
+ String format = "%-36s %-9s %5s %s\n";
+
+ int totalOwner = 0;
+ int totalPrimary = 0;
+ int totalSecondary = 0;
+
+ out.printf(format, "UUID", "Type", "Count", "Buckets");
+ out.printf(format, "----", "----", "-----", "-------");
+ for (TestServer ts : testServers.values()) {
+ // dump out 'owned' bucket information
+ if (ts.buckets.isEmpty()) {
+ // no buckets owned by this server
+ out.printf(format, ts.uuid, "Owned", 0, "");
+ } else {
+ // dump out primary buckets information
+ totalOwner +=
+ dumpBucketsSegment(out, format, ts.buckets, ts.uuid.toString(), "Owned");
+ }
+ // optionally dump out primary buckets information
+ totalPrimary +=
+ dumpBucketsSegment(out, format, ts.primaryBackupBuckets, "", "Primary");
+ // optionally dump out secondary buckets information
+ totalSecondary +=
+ dumpBucketsSegment(out, format, ts.secondaryBackupBuckets, "", "Secondary");
+ }
+
+ if (!nullServer.buckets.isEmpty()
+ || !nullServer.primaryBackupBuckets.isEmpty()
+ || !nullServer.secondaryBackupBuckets.isEmpty()) {
+ /*
+ * There are some owned, primary, or secondary buckets that are
+ * unassigned. It is displayed in a manner similar to buckets that
+ * do have a server, but the UUID field is marked as 'UNASSIGNED'
+ * in the first line of the display.
+ */
+ String uuidField = "UNASSIGNED";
+
+ // optionally dump out unassigned owned buckets information
+ if (dumpBucketsSegment(out, format, nullServer.buckets,
+ uuidField, "Owned") != 0) {
+ uuidField = "";
+ }
+ // optionally dump out unassigned primary backup buckets information
+ if (dumpBucketsSegment(out, format, nullServer.primaryBackupBuckets,
+ uuidField, "Primary") != 0) {
+ uuidField = "";
+ }
+ // optionally dump out unassigned secondary backup buckets information
+ dumpBucketsSegment(out, format, nullServer.secondaryBackupBuckets,
+ uuidField, "Secondary");
+ }
+ out.println("\nTotal assigned: owner = " + totalOwner
+ + ", primary = " + totalPrimary
+ + ", secondary = " + totalSecondary);
+ }
+
+ /**
+ * Supports the 'dumpBucketsInternal' command, and indirectly, the
+ * '/cmd/dumpBuckets' REST message. It formats one segment of bucket data
+ * (owned, primary backup, or secondary backup), and dumps out the
+ * associated bucket data in segments of 8. Note: if the size of 'buckets'
+ * is 0, nothing is displayed.
+ *
+ * @param out the output stream
+ * @param format the message format string
+ * @param buckets the entire set of buckets to be displayed
+ * @param uuid string to display under the 'UUID' header
+ * @param segmentDescription string to display under the 'Type' header
+ * @return the size of the 'buckets' set
+ */
+ private static int dumpBucketsSegment(
+ PrintStream out, String format, TreeSet<TestBucket> buckets,
+ String uuid, String segmentDescription) {
+
+ int size = buckets.size();
+ if (size != 0) {
+ // generate a linked list of the bucket data to display
+ LinkedList<String> data = new LinkedList<String>();
+ StringBuilder sb = new StringBuilder();
+ int count = 8;
+
+ for (TestBucket bucket : buckets) {
+ if (sb.length() != 0) {
+ // this is not the first bucket in the line --
+ // prepend a space
+ sb.append(' ');
+ }
+
+ // add the bucket number
+ sb.append(String.format("%4s", bucket.index));
+ if ((count -= 1) <= 0) {
+ // filled up a row --
+ // add it to the list, and start a new line
+ data.add(sb.toString());
+ sb = new StringBuilder();
+ count = 8;
+ }
+ }
+ if (sb.length() != 0) {
+ // there is a partial line remaining -- add it to the list
+ data.add(sb.toString());
+ }
+
+ /*
+ * The first line displayed includes the UUID and size information,
+ * and the first line of bucket data (owned, primary, or secondary).
+ * The remaining lines of bucket data are displayed alone,
+ * without any UUID or size information.
+ */
+ out.printf(format, uuid, segmentDescription, buckets.size(),
+ data.removeFirst());
+ while (!data.isEmpty()) {
+ out.printf(format, "", "", "", data.removeFirst());
+ }
+ }
+ return size;
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This interface is an abstraction for all messages that are routed
+ * through buckets. It exists, so that messages may be queued while
+ * bucket migration is taking place, and it makes it possible to support
+ * multiple types of messages (routed UEB/DMAAP messages, or lock messages)
+ */
+ public static interface Message {
+ /**
+ * Process the current message -- this may mean delivering it locally,
+ * or forwarding it.
+ */
+ public void process();
+
+ /**
+ * Send the message to another host for processing.
+ *
+ * @param server the destination host (although it could end up being
+ * forwarded again)
+ * @param bucketNumber the bucket number determined by extracting the
+ * current message's keyword
+ */
+ public void sendToServer(Server server, int bucketNumber);
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This interface implements a type of backup; for example, there is one
+ * for backing up Drools objects within sessions, and another for
+ * backing up lock data.
+ */
+ public static interface Backup {
+ /**
+ * This method is called to add a 'Backup' instance to the registered list.
+ *
+ * @param backup an object implementing the 'Backup' interface
+ */
+ public static void register(Backup backup) {
+ synchronized (backupList) {
+ if (!backupList.contains(backup)) {
+ backupList.add(backup);
+ }
+ }
+ }
+
+ /**
+ * Generate Serializable backup data for the specified bucket.
+ *
+ * @param bucketNumber the bucket number to back up
+ * @return a Serializable object containing backkup data
+ */
+ public Restore generate(int bucketNumber);
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Objects implementing this interface may be serialized, and restored
+ * on a different host.
+ */
+ public static interface Restore extends Serializable {
+ /**
+ * Restore from deserialized data.
+ *
+ * @param bucketNumber the bucket number being restored
+ */
+ void restore(int bucketNumber);
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This interface corresponds to a transient state within a Bucket.
+ */
+ private interface State {
+ /**
+ * This method allows state-specific handling of the
+ * 'Bucket.forward()' methods
+ *
+ * @param message the message to be forwarded/processed
+ * @return a value of 'true' indicates the message has been "handled"
+ * (forwarded or queued), and 'false' indicates it has not, and needs
+ * to be handled locally.
+ */
+ boolean forward(Message message);
+
+ /**
+ * This method indicates that the current server is the new owner
+ * of the current bucket.
+ */
+ void newOwner();
+
+ /**
+ * This method indicates that serialized data has been received,
+ * presumably from the old owner of the bucket. The data could correspond
+ * to Drools objects within sessions, as well as global locks.
+ *
+ * @param data serialized data associated with this bucket (at present,
+ * this is assumed to be complete, all within a single message)
+ */
+ void bulkSerializedData(byte[] data);
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Each state instance is associated with a bucket, and is used when
+ * that bucket is in a transient state where it is the new owner of a
+ * bucket, or is presumed to be the new owner, based upon other events
+ * that have occurred.
+ */
+ private class NewOwner extends Thread implements State {
+ /*
+ * this value is 'true' if we have explicitly received a 'newOwner'
+ * indication, and 'false' if there was another trigger for entering this
+ * transient state (e.g. receiving serialized data)
+ */
+ boolean confirmed;
+
+ // when 'System.currentTimeMillis()' reaches this value, we time out
+ long endTime;
+
+ // If not 'null', we are queueing messages for this bucket;
+ // otherwise, we are sending them through.
+ Queue<Message> messages = new ConcurrentLinkedQueue<>();
+
+ // this is used to signal the thread that we have data available
+ CountDownLatch dataAvailable = new CountDownLatch(1);
+
+ // this is the data
+ byte[] data = null;
+
+ // this is the old owner of the bucket
+ Server oldOwner;
+
+ /**
+ * Constructor - a transient state, where we are expecting to receive
+ * bulk data from the old owner.
+ *
+ * @param confirmed 'true' if we were explicitly notified that we
+ * are the new owner of the bucket, 'false' if not
+ */
+ NewOwner(boolean confirmed, Server oldOwner) {
+ super("New Owner for Bucket " + index);
+ this.confirmed = confirmed;
+ this.oldOwner = oldOwner;
+ if (oldOwner == null) {
+ // we aren't expecting any data -- this is indicated by 0-length data
+ bulkSerializedData(new byte[0]);
+ }
+ endTime = System.currentTimeMillis()
+ + (confirmed ? confirmedTimeout : unconfirmedTimeout);
+ start();
+ }
+
+ /**
+ * Return the 'confirmed' indicator.
+ *
+ * @return the 'confirmed' indicator
+ */
+ private boolean getConfirmed() {
+ synchronized (Bucket.this) {
+ return confirmed;
+ }
+ }
+
+ /**
+ * This returns the timeout delay, which will always be less than or
+ * equal to 1 second. This allows us to periodically check whether the
+ * old server is still active.
+ *
+ * @return the timeout delay, which is the difference between the
+ * 'endTime' value and the current time or 1 second
+ * (whichever is less)
+ */
+ private long getTimeout() {
+ long lclEndTime;
+ synchronized (Bucket.this) {
+ lclEndTime = endTime;
+ }
+ return Math.min(lclEndTime - System.currentTimeMillis(), 1000L);
+ }
+
+ /**
+ * Return the current value of the 'data' field.
+ *
+ * @return the current value of the 'data' field
+ */
+ private byte[] getData() {
+ synchronized (Bucket.this) {
+ return data;
+ }
+ }
+
+ /*********************/
+ /* 'State' interface */
+ /*********************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean forward(Message message) {
+ // the caller of this method is synchronized on 'Bucket.this'
+ if (messages != null && Thread.currentThread() != this) {
+ // just queue the message
+ messages.add(message);
+ return true;
+ } else {
+ /*
+ * Either:
+ *
+ * 1) We are in a grace period, where 'state' is still set, but
+ * we are no longer forwarding messages.
+ * 2) We are calling 'message.process()' from this thread
+ * in the 'finally' block of 'NewOwner.run()'.
+ *
+ * In either case, messages should be processed locally.
+ */
+ return false;
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void newOwner() {
+ // the caller of this method is synchronized on 'Bucket.this'
+ if (!confirmed) {
+ confirmed = true;
+ endTime += (confirmedTimeout - unconfirmedTimeout);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void bulkSerializedData(byte[] data) {
+ // the caller of this method is synchronized on 'Bucket.this'
+ if (this.data == null) {
+ this.data = data;
+ dataAvailable.countDown();
+ }
+ }
+
+ /**********************/
+ /* 'Thread' interface */
+ /**********************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void run() {
+ logger.info("{}: 'run' method invoked", this);
+ try {
+ byte[] lclData;
+ long delay;
+
+ while ((lclData = getData()) == null
+ && oldOwner.isActive()
+ && (delay = getTimeout()) > 0) {
+ // ignore return value -- 'data' will indicate the result
+ dataAvailable.await(delay, TimeUnit.MILLISECONDS);
+ }
+ if (lclData == null) {
+ // no data available -- log an error, and abort
+ if (getConfirmed()) {
+ // we never received the data, but we are the new owner
+ logger.error("{}: never received session data", this);
+ } else {
+ /*
+ * no data received, and it was never confirmed --
+ * assume that the forwarded message that triggered this was
+ * erroneus
+ */
+ logger.error("{}: no confirmation or data received -- aborting", this);
+ return;
+ }
+ } else {
+ logger.info("{}: {} bytes of data available",
+ this, lclData.length);
+ }
+
+ // if we reach this point, this server is the new owner
+ if (lclData == null || lclData.length == 0) {
+ // see if any features can do the restore
+ for (ServerPoolApi feature : ServerPoolApi.impl.getList()) {
+ feature.restoreBucket(Bucket.this);
+ }
+ } else {
+ // deserialize data
+ Object obj = Util.deserialize(lclData);
+ restoreBucketData(obj);
+ }
+ } catch (Exception e) {
+ logger.error("Exception in {}", this, e);
+ } finally {
+ /*
+ * cleanly leave state -- we want to make sure that messages
+ * are processed in order, so the queue needs to remain until
+ * it is empty
+ */
+ logger.info("{}: entering cleanup state", this);
+ for ( ; ; ) {
+ Message message = messages.poll();
+ if (message == null) {
+ // no messages left, but this could change
+ synchronized (Bucket.this) {
+ message = messages.poll();
+ if (message == null) {
+ // no messages left
+ if (state == this) {
+ if (owner == Server.getThisServer()) {
+ // we can now exit the state
+ state = null;
+ stateChanged();
+ } else {
+ /*
+ * We need a grace period before we can
+ * remove the 'state' value (this can happen
+ * if we receive and process the bulk data
+ * before receiving official confirmation
+ * that we are owner of the bucket.
+ */
+ messages = null;
+ }
+ }
+ break;
+ }
+ }
+ }
+ // this doesn't work -- it ends up right back in the queue
+ // if 'messages' is defined
+ message.process();
+ }
+ if (messages == null) {
+ // this indicates we need to enter a grace period before cleanup,
+ try {
+ logger.info("{}: entering grace period before terminating",
+ this);
+ Thread.sleep(unconfirmedGracePeriod);
+ } catch (InterruptedException e) {
+ // we are exiting in any case
+ Thread.currentThread().interrupt();
+ } finally {
+ synchronized (Bucket.this) {
+ // Do we need to confirm that we really are the owner?
+ // What does it mean if we are not?
+ if (state == this) {
+ state = null;
+ stateChanged();
+ }
+ }
+ }
+ }
+ logger.info("{}: exiting cleanup state", this);
+ }
+ }
+
+ /**
+ * Return a useful value to display in log messages.
+ *
+ * @return a useful value to display in log messages
+ */
+ public String toString() {
+ return "Bucket.NewOwner(" + index + ")";
+ }
+ }
+
+ /**
+ * Restore bucket data.
+ *
+ * @param obj deserialized bucket data
+ */
+ private void restoreBucketData(Object obj) {
+ if (obj instanceof List) {
+ for (Object entry : (List<?>)obj) {
+ if (entry instanceof Restore) {
+ // entry-specific 'restore' operation
+ ((Restore)entry).restore(this.index);
+ } else {
+ logger.error("{}: Expected '{}' but got '{}'",
+ this, Restore.class.getName(),
+ entry.getClass().getName());
+ }
+ }
+ } else {
+ logger.error("{}: expected 'List' but got '{}'",
+ this, obj.getClass().getName());
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Each state instance is associated with a bucket, and is used when
+ * that bucket is in a transient state where it is the old owner of
+ * a bucket, and the data is being transferred to the new owner.
+ */
+ private class OldOwner extends Thread implements State {
+ Server newOwner;
+
+ OldOwner(Server newOwner) {
+ super("Old Owner for Bucket " + index);
+ this.newOwner = newOwner;
+ start();
+ }
+
+ /*********************/
+ /* 'State' interface */
+ /*********************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean forward(Message message) {
+ // forward message to new owner
+ message.sendToServer(newOwner, index);
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void newOwner() {
+ // shouldn't happen -- just log an error
+ logger.error("{}: 'newOwner()' shouldn't be called in this state", this);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void bulkSerializedData(byte[] data) {
+ // shouldn't happen -- just log an error
+ logger.error("{}: 'bulkSerializedData()' shouldn't be called in this state", this);
+ }
+
+ /**********************/
+ /* 'Thread' interface */
+ /**********************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void run() {
+ logger.info("{}: 'run' method invoked", this);
+ try {
+ // go through all of the entries in the list, collecting restore data
+ List<Restore> restoreData = new LinkedList<>();
+ for (Backup backup : backupList) {
+ Restore restore = backup.generate(index);
+ if (restore != null) {
+ restoreData.add(restore);
+ }
+ }
+
+ // serialize all of the objects,
+ // and send what we have to the new owner
+ Entity<String> entity = Entity.entity(
+ new String(Base64.getEncoder().encode(Util.serialize(restoreData))),
+ MediaType.APPLICATION_OCTET_STREAM_TYPE);
+ newOwner.post("bucket/sessionData", entity, new Server.PostResponse() {
+ @Override
+ public WebTarget webTarget(WebTarget webTarget) {
+ return webTarget
+ .queryParam("bucket", index)
+ .queryParam("dest", newOwner.getUuid())
+ .queryParam("ttl", timeToLive);
+ }
+
+ @Override
+ public void response(Response response) {
+ logger.info("/bucket/sessionData response code = {}",
+ response.getStatus());
+ }
+ });
+ } catch (Exception e) {
+ logger.error("Exception in {}", this, e);
+ } finally {
+ synchronized (Bucket.this) {
+ // restore the state
+ if (state == this) {
+ state = null;
+ stateChanged();
+ }
+ }
+ }
+ }
+
+ /**
+ * Return a useful value to display in log messages.
+ *
+ * @return a useful value to display in log messages
+ */
+ public String toString() {
+ return "Bucket.OldOwner(" + index + ")";
+ }
+ }
+}
diff --git a/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Discovery.java b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Discovery.java
new file mode 100644
index 00000000..c507e97d
--- /dev/null
+++ b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Discovery.java
@@ -0,0 +1,354 @@
+/*
+ * ============LICENSE_START=======================================================
+ * feature-server-pool
+ * ================================================================================
+ * Copyright (C) 2020 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=========================================================
+ */
+
+package org.onap.policy.drools.serverpool;
+
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_DISCOVERY_FETCH_LIMIT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_DISCOVERY_FETCH_TIMEOUT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_DISCOVER_PUBLISHER_LOOP_CYCLE_TIME;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DISCOVERY_ALLOW_SELF_SIGNED_CERTIFICATES;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DISCOVERY_API_KEY;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DISCOVERY_API_SECRET;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DISCOVERY_FETCH_LIMIT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DISCOVERY_FETCH_TIMEOUT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DISCOVERY_HTTPS;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DISCOVERY_PASSWORD;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DISCOVERY_SERVERS;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DISCOVERY_TOPIC;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DISCOVERY_USERNAME;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DISCOVER_PUBLISHER_LOOP_CYCLE_TIME;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.getProperty;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+
+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.common.utils.coder.CoderException;
+import org.onap.policy.common.utils.coder.StandardCoder;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class makes use of UEB/DMAAP to discover other servers in the pool.
+ * The discovery processes ordinarily run only on the lead server, but they
+ * run on other servers up until the point that they determine who the
+ * leader is.
+ */
+public class Discovery implements TopicListener {
+ private static Logger logger = LoggerFactory.getLogger(Discovery.class);
+
+ // used for JSON <-> String conversion
+ private static StandardCoder coder = new StandardCoder();
+
+ private static Discovery discovery = null;
+
+ private volatile Publisher publisherThread = null;
+
+ private List<TopicSource> consumers = null;
+ private List<TopicSink> publishers = null;
+
+ private Discovery() {
+ // we want to modify the properties we send to 'TopicManager'
+ PropBuilder builder = new PropBuilder(ServerPoolProperties.getProperties());
+ builder.convert(DISCOVERY_SERVERS, null,
+ PolicyEndPointProperties.PROPERTY_TOPIC_SERVERS_SUFFIX);
+ builder.convert(DISCOVERY_USERNAME, null,
+ PolicyEndPointProperties.PROPERTY_TOPIC_AAF_MECHID_SUFFIX);
+ builder.convert(DISCOVERY_PASSWORD, null,
+ PolicyEndPointProperties.PROPERTY_TOPIC_AAF_PASSWORD_SUFFIX);
+ builder.convert(DISCOVERY_HTTPS, null,
+ PolicyEndPointProperties.PROPERTY_HTTP_HTTPS_SUFFIX);
+ builder.convert(DISCOVERY_API_KEY, null,
+ PolicyEndPointProperties.PROPERTY_TOPIC_API_KEY_SUFFIX);
+ builder.convert(DISCOVERY_API_SECRET, null,
+ PolicyEndPointProperties.PROPERTY_TOPIC_API_SECRET_SUFFIX);
+ builder.convert(DISCOVERY_FETCH_TIMEOUT,
+ String.valueOf(DEFAULT_DISCOVERY_FETCH_TIMEOUT),
+ PolicyEndPointProperties.PROPERTY_TOPIC_SOURCE_FETCH_TIMEOUT_SUFFIX);
+ builder.convert(DISCOVERY_FETCH_LIMIT,
+ String.valueOf(DEFAULT_DISCOVERY_FETCH_LIMIT),
+ PolicyEndPointProperties.PROPERTY_TOPIC_SOURCE_FETCH_LIMIT_SUFFIX);
+ builder.convert(DISCOVERY_ALLOW_SELF_SIGNED_CERTIFICATES, null,
+ PolicyEndPointProperties.PROPERTY_ALLOW_SELF_SIGNED_CERTIFICATES_SUFFIX);
+ Properties prop = builder.finish();
+ logger.debug("Discovery converted properties: {}", prop);
+
+ consumers = TopicEndpointManager.getManager().addTopicSources(prop);
+ publishers = TopicEndpointManager.getManager().addTopicSinks(prop);
+
+ if (consumers.isEmpty()) {
+ logger.error("No consumer topics");
+ }
+ if (publishers.isEmpty()) {
+ logger.error("No publisher topics");
+ }
+ logger.debug("Discovery: {} consumers, {} publishers",
+ consumers.size(), publishers.size());
+ }
+
+ /**
+ * Start all consumers and publishers, and start the publisher thread.
+ */
+ static synchronized void startDiscovery() {
+ if (discovery == null) {
+ discovery = new Discovery();
+ }
+ discovery.start();
+ }
+
+ /**
+ * Stop all consumers and publishers, and stop the publisher thread.
+ */
+ static synchronized void stopDiscovery() {
+ if (discovery != null) {
+ discovery.stop();
+ }
+ }
+
+ /**
+ * Start all consumers and publishers, and start the publisher thread.
+ */
+ private void start() {
+ for (TopicSource consumer : consumers) {
+ consumer.register(this);
+ consumer.start();
+ }
+ for (TopicSink publisher : publishers) {
+ publisher.start();
+ }
+ if (publisherThread == null) {
+ // send thread wasn't running -- start it
+ publisherThread = new Publisher();
+ publisherThread.start();
+ }
+ }
+
+ /**
+ * Stop all consumers and publishers, and stop the publisher thread.
+ */
+ private void stop() {
+ publisherThread = null;
+ for (TopicSink publisher : publishers) {
+ publisher.stop();
+ }
+ for (TopicSource consumer : consumers) {
+ consumer.unregister(this);
+ consumer.stop();
+ }
+ }
+
+ /*===========================*/
+ /* 'TopicListener' interface */
+ /*===========================*/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void onTopicEvent(CommInfrastructure infra, String topic, String event) {
+ /*
+ * a JSON message has been received -- it should contain
+ * a single string parameter 'pingData', which contains the
+ * same format base64-encoded message that 'Server'
+ * instances periodically exchange
+ */
+ LinkedHashMap<String, String> map = new LinkedHashMap<>();
+ try {
+ map = coder.decode(event, LinkedHashMap.class);
+ String message = map.get("pingData");
+ Server.adminRequest(message.getBytes(StandardCharsets.UTF_8));
+ logger.info("Received a message, server count={}", Server.getServerCount());
+ } catch (CoderException e) {
+ logger.error("Can't decode message: {}", e);
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This class is used to convert internal 'discovery.*' properties to
+ * properties that 'TopicEndpointManager' can use.
+ */
+ private static class PropBuilder {
+ // properties being incrementally modified
+ Properties prop;
+
+ // value from 'discovery.topic' parameter
+ String topic;
+
+ // 'true' only if both 'discovery.topic' and 'discovery.servers'
+ // has been defined
+ boolean doConversion = false;
+
+ // contains "ueb.source.topics" or "dmaap.source.topics"
+ String sourceTopicsName = null;
+
+ // contains "<TYPE>.source.topics.<TOPIC>" (<TYPE> = ueb|dmaap)
+ String sourcePrefix = null;
+
+ // contains "ueb.sink.topics" or "dmaap.sink.topics"
+ String sinkTopicsName = null;
+
+ // contains "<TYPE>.sink.topics.<TOPIC>" (<TYPE> = ueb|dmaap)
+ String sinkPrefix = null;
+
+ /**
+ * Constructor - decide whether we are going to do conversion or not,
+ * and initialize accordingly.
+ *
+ * @param prop the initial list of properties
+ */
+ PropBuilder(Properties prop) {
+ this.prop = new Properties(prop);
+ this.topic = prop.getProperty(DISCOVERY_TOPIC);
+ String servers = prop.getProperty(DISCOVERY_SERVERS);
+ if (topic != null && servers != null) {
+ // we do have property conversion to do
+ doConversion = true;
+ String type = topic.contains(".") ? "dmaap" : "ueb";
+ sourceTopicsName = type + ".source.topics";
+ sourcePrefix = sourceTopicsName + "." + topic;
+ sinkTopicsName = type + ".sink.topics";
+ sinkPrefix = sinkTopicsName + "." + topic;
+ }
+ }
+
+ /**
+ * If we are doing conversion, convert an internal property
+ * to something that 'TopicEndpointManager' can use.
+ *
+ * @param intName server pool property name (e.g. "discovery.servers")
+ * @param defaultValue value to use if property 'intName' is not specified
+ * @param extSuffix TopicEndpointManager suffix, including leading "."
+ */
+ void convert(String intName, String defaultValue, String extSuffix) {
+ if (doConversion) {
+ String value = prop.getProperty(intName, defaultValue);
+ if (value != null) {
+ prop.setProperty(sourcePrefix + extSuffix, value);
+ prop.setProperty(sinkPrefix + extSuffix, value);
+ }
+ }
+ }
+
+ /**
+ * Generate/update the '*.source.topics' and '*.sink.topics' parameters.
+ *
+ * @return the updated properties list
+ */
+ Properties finish() {
+ if (doConversion) {
+ String currentValue = prop.getProperty(sourceTopicsName);
+ if (currentValue == null) {
+ // '*.source.topics' is not defined -- set it
+ prop.setProperty(sourceTopicsName, topic);
+ } else {
+ // '*.source.topics' is defined -- append to it
+ prop.setProperty(sourceTopicsName, currentValue + "," + topic);
+ }
+ currentValue = prop.getProperty(sinkTopicsName);
+ if (currentValue == null) {
+ // '*.sink.topics' is not defined -- set it
+ prop.setProperty(sinkTopicsName, topic);
+ } else {
+ // '*.sink.topics' is defined -- append to it
+ prop.setProperty(sinkTopicsName, currentValue + "," + topic);
+ }
+ }
+ return prop;
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This is the sender thread, which periodically sends out 'ping' messages.
+ */
+ private class Publisher extends Thread {
+ /**
+ * Constructor -- read in the properties, and initialze 'publisher'.
+ */
+ Publisher() {
+ super("Discovery Publisher Thread");
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void run() {
+ // this loop will terminate once 'publisher' is set to 'null',
+ // or some other 'Publisher' instance replaces it
+ long cycleTime = getProperty(DISCOVER_PUBLISHER_LOOP_CYCLE_TIME,
+ DEFAULT_DISCOVER_PUBLISHER_LOOP_CYCLE_TIME);
+ while (this == publisherThread) {
+ try {
+ // wait 5 seconds (default)
+ Thread.sleep(cycleTime);
+
+ // generate a 'ping' message
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ DataOutputStream dos = new DataOutputStream(bos);
+
+ // write the 'ping' data for this server
+ Server thisServer = Server.getThisServer();
+ thisServer.writeServerData(dos);
+ String encodedData =
+ new String(Base64.getEncoder().encode(bos.toByteArray()));
+
+ // base64-encoded value is passed as JSON parameter 'pingData'
+ LinkedHashMap<String, String> map = new LinkedHashMap<>();
+ map.put("pingData", encodedData);
+ String jsonString = new Gson().toJson(map, Map.class);
+ for (TopicSink publisher : publishers) {
+ publisher.send(jsonString);
+ }
+ } catch (InterruptedException e) {
+ logger.error("Exception in Discovery.Publisher.run():", e);
+ return;
+ } catch (Exception e) {
+ logger.error("Exception in Discovery.Publisher.run():", e);
+ // grace period -- we don't want to get UEB upset at us
+ try {
+ Thread.sleep(15000);
+ } catch (InterruptedException e2) {
+ logger.error("Discovery.Publisher sleep interrupted");
+ }
+ return;
+ }
+ }
+ }
+ }
+}
diff --git a/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Events.java b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Events.java
new file mode 100644
index 00000000..176d39ac
--- /dev/null
+++ b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Events.java
@@ -0,0 +1,103 @@
+/*
+ * ============LICENSE_START=======================================================
+ * feature-server-pool
+ * ================================================================================
+ * Copyright (C) 2020 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=========================================================
+ */
+
+package org.onap.policy.drools.serverpool;
+
+import java.util.Collection;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+/**
+ * This interface is used to distribute notifications of various system
+ * events, such as new 'Server' instances, or a server failing.
+ */
+public interface Events {
+ // set of listeners receiving event notifications
+ static final Queue<Events> listeners =
+ new ConcurrentLinkedQueue<>();
+
+ /**
+ * add a listener to the set of listeners receiving events.
+ *
+ * @param handler the listener
+ */
+ public static void register(Events handler) {
+ // if it is already here, remove it first
+ listeners.remove(handler);
+
+ // add to the end of the queue
+ listeners.add(handler);
+ }
+
+ /**
+ * remove a listener from the set of listeners.
+ */
+ public static boolean unregister(Events handler) {
+ return listeners.remove(handler);
+ }
+
+ public static Collection<Events> getListeners() {
+ return listeners;
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Notification that a new server has been discovered.
+ *
+ * @param server this is the new server
+ */
+ public default void newServer(Server server) {
+ }
+
+ /**
+ * Notification that a server has failed.
+ *
+ * @param server this is the server that failed
+ */
+ public default void serverFailed(Server server) {
+ }
+
+ /**
+ * Notification that a new lead server has been selected.
+ *
+ * @param server this is the new lead server
+ */
+ public default void newLeader(Server server) {
+ }
+
+ /**
+ * Notification that the lead server has gone down.
+ *
+ * @param server the lead server that failed
+ */
+ public default void leaderFailed(Server server) {
+ }
+
+ /**
+ * Notification that a new selection just completed, but the same
+ * leader has been chosen (this may be in response to a new server
+ * joining earlier).
+ *
+ * @param server the current leader, which has been confirmed
+ */
+ public default void leaderConfirmed(Server server) {
+ }
+}
diff --git a/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/ExtendedObjectInputStream.java b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/ExtendedObjectInputStream.java
new file mode 100644
index 00000000..5ec6f341
--- /dev/null
+++ b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/ExtendedObjectInputStream.java
@@ -0,0 +1,70 @@
+/*
+ * ============LICENSE_START=======================================================
+ * feature-server-pool
+ * ================================================================================
+ * Copyright (C) 2020 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=========================================================
+ */
+
+package org.onap.policy.drools.serverpool;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectStreamClass;
+
+/**
+ * This class provides an 'ObjectInputStream' variant that uses the
+ * specified 'ClassLoader' instance.
+ */
+public class ExtendedObjectInputStream extends ObjectInputStream {
+ // the 'ClassLoader' to use when doing class lookups
+ private ClassLoader classLoader;
+
+ /**
+ * Constructor -- invoke the superclass, and save the 'ClassLoader'.
+ *
+ * @param in input stream to read from
+ * @param classLoader 'ClassLoader' to use when doing class lookups
+ */
+ public ExtendedObjectInputStream(InputStream in, ClassLoader classLoader) throws IOException {
+ super(in);
+ this.classLoader = classLoader;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
+
+ // Standard ClassLoader implementations first attempt to load classes
+ // via the parent class loader, and then attempt to load it using the
+ // current class loader if that fails. For some reason, Drools container
+ // class loaders define a different order -- in theory, this is only a
+ // problem if different versions of the same class are accessible through
+ // different class loaders, which is exactly what happens in some Junit
+ // tests.
+ //
+ // This change restores the order, at least when deserializing objects
+ // into a Drools container.
+ try {
+ // try the parent class loader first
+ return classLoader.getParent().loadClass(desc.getName());
+ } catch (ClassNotFoundException e) {
+ return classLoader.loadClass(desc.getName());
+ }
+ }
+}
diff --git a/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/FeatureServerPool.java b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/FeatureServerPool.java
new file mode 100644
index 00000000..748a38f3
--- /dev/null
+++ b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/FeatureServerPool.java
@@ -0,0 +1,986 @@
+/*
+ * ============LICENSE_START=======================================================
+ * feature-server-pool
+ * ================================================================================
+ * Copyright (C) 2020 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=========================================================
+ */
+
+package org.onap.policy.drools.serverpool;
+
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.BUCKET_DROOLS_TIMEOUT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.BUCKET_TIME_TO_LIVE;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_BUCKET_DROOLS_TIMEOUT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_BUCKET_TIME_TO_LIVE;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.getProperty;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+
+import lombok.AllArgsConstructor;
+
+import org.drools.core.definitions.InternalKnowledgePackage;
+import org.drools.core.impl.KnowledgeBaseImpl;
+import org.kie.api.runtime.KieSession;
+import org.kie.api.runtime.rule.FactHandle;
+import org.onap.policy.common.endpoints.event.comm.Topic.CommInfrastructure;
+import org.onap.policy.common.endpoints.event.comm.TopicListener;
+import org.onap.policy.common.utils.coder.CoderException;
+import org.onap.policy.common.utils.coder.StandardCoder;
+import org.onap.policy.common.utils.coder.StandardCoderObject;
+import org.onap.policy.drools.control.api.DroolsPdpStateControlApi;
+import org.onap.policy.drools.core.DroolsRunnable;
+import org.onap.policy.drools.core.PolicyContainer;
+import org.onap.policy.drools.core.PolicySession;
+import org.onap.policy.drools.core.PolicySessionFeatureApi;
+import org.onap.policy.drools.core.lock.PolicyResourceLockManager;
+import org.onap.policy.drools.features.PolicyControllerFeatureApi;
+import org.onap.policy.drools.features.PolicyEngineFeatureApi;
+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.system.PolicyEngineConstants;
+import org.onap.policy.drools.utils.Pair;
+import org.onap.policy.drools.utils.PropertyUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * </p>
+ * This class hooks the server pool implementation into DroolsPDP.
+ * <dl>
+ * <dt>PolicyEngineFeatureApi</dt><dd> - the <i>afterStart</i> hook is where we initialize.</dd>
+ * <dt>PolicyControllerFeatureApi</dt><dd> - the <i>beforeOffer</i> hook is used to look
+ * at incoming topic messages, and decide whether to process them
+ * on this host, or forward to another host.</dd>
+ * </dl>
+ */
+public class FeatureServerPool
+ implements PolicyEngineFeatureApi, PolicySessionFeatureApi,
+ PolicyControllerFeatureApi, DroolsPdpStateControlApi {
+ private static Logger logger =
+ LoggerFactory.getLogger(FeatureServerPool.class);
+
+ // used for JSON <-> String conversion
+ private static StandardCoder coder = new StandardCoder();
+
+ private static final String configFile =
+ "config/feature-server-pool.properties";
+
+ /*
+ * Properties used when searching for keyword entries
+ *
+ * The following types are supported:
+ *
+ * 1) keyword.<topic>.path=<field-list>
+ * 2) keyword.path=<field-list>
+ * 3) ueb.source.topics.<topic>.keyword=<field-list>
+ * 4) ueb.source.topics.keyword=<field-list>
+ * 5) dmaap.source.topics.<topic>.keyword=<field-list>
+ * 6) dmaap.source.topics.keyword=<field-list>
+ *
+ * 1, 3, and 5 are functionally equivalent
+ * 2, 4, and 6 are functionally equivalent
+ */
+
+ static final String KEYWORD_PROPERTY_START_1 = "keyword.";
+ static final String KEYWORD_PROPERTY_END_1 = ".path";
+ static final String KEYWORD_PROPERTY_START_2 = "ueb.source.topics.";
+ static final String KEYWORD_PROPERTY_END_2 = ".keyword";
+ static final String KEYWORD_PROPERTY_START_3 = "dmaap.source.topics.";
+ static final String KEYWORD_PROPERTY_END_3 = ".keyword";
+
+ /*
+ * maps topic names to a keyword table derived from <field-list> (above)
+ *
+ * Example <field-list>: requestID,CommonHeader.RequestID
+ *
+ * Table generated from this example has length 2:
+ * table[0] = {"requestID"}
+ * table[1] = {"CommonHeader", "RequestID"}
+ */
+ private static HashMap<String,String[][]> topicToPaths = new HashMap<>();
+
+ // this table is used for any topics that aren't in 'topicToPaths'
+ private static String[][] defaultPaths = new String[0][];
+
+ // extracted from properties
+ private static long droolsTimeoutMillis;
+ private static String timeToLiveSecond;
+
+ /******************************/
+ /* 'OrderedService' interface */
+ /******************************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int getSequenceNumber() {
+ // we need to make sure we have an early position in 'selectThreadModel'
+ // (in case there is feature that provides a thread model)
+ return -1000000;
+ }
+
+ /**************************************/
+ /* 'PolicyEngineFeatureApi' interface */
+ /**************************************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean afterStart(PolicyEngine engine) {
+ logger.info("Starting FeatureServerPool");
+ Server.startup(configFile);
+ TargetLock.startup();
+ droolsTimeoutMillis =
+ getProperty(BUCKET_DROOLS_TIMEOUT, DEFAULT_BUCKET_DROOLS_TIMEOUT);
+ int intTimeToLive =
+ getProperty(BUCKET_TIME_TO_LIVE, DEFAULT_BUCKET_TIME_TO_LIVE);
+ timeToLiveSecond = String.valueOf(intTimeToLive);
+ buildKeywordTable();
+ Bucket.Backup.register(new DroolsSessionBackup());
+ Bucket.Backup.register(new TargetLock.LockBackup());
+ return false;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public PolicyResourceLockManager beforeCreateLockManager(
+ PolicyEngine engine, Properties properties) {
+
+ return TargetLock.getLockFactory();
+ }
+
+ /*=====================================*/
+ /* 'PolicySessionFeatureApi' interface */
+ /*=====================================*/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean insertDrools(
+ final PolicySession session, final Object object) {
+
+ final String keyword = Keyword.lookupKeyword(object);
+ if (keyword == null) {
+ // no keyword was found, so we process locally
+ KieSession kieSession = session.getKieSession();
+ if (kieSession != null) {
+ kieSession.insert(object);
+ }
+ return true;
+ }
+
+ /*
+ * 'keyword' determines the destination host,
+ * which may be local or remote
+ */
+ Bucket.forwardAndProcess(keyword, new Bucket.Message() {
+ @Override
+ public void process() {
+ // if we reach this point, we process locally
+ KieSession kieSession = session.getKieSession();
+ if (kieSession != null) {
+ kieSession.insert(object);
+ }
+ }
+
+ @Override
+ public void sendToServer(Server server, int bucketNumber) {
+ // this object needs to sent to a remote host --
+ // first, serialize the object
+ byte[] data = null;
+ try {
+ data = Util.serialize(object);
+ } catch (IOException e) {
+ logger.error("insertDrools: can't serialize object of {}",
+ object.getClass(), e);
+ return;
+ }
+
+ // construct the message to insert remotely
+ Entity<String> entity = Entity.entity(
+ new String(Base64.getEncoder().encode(data), StandardCharsets.UTF_8),
+ MediaType.APPLICATION_OCTET_STREAM_TYPE);
+ server.post("session/insertDrools", entity,
+ new Server.PostResponse() {
+ @Override
+ public WebTarget webTarget(WebTarget webTarget) {
+ PolicyContainer pc = session.getPolicyContainer();
+ String encodedSessionName =
+ pc.getGroupId() + ":" + pc.getArtifactId() + ":"
+ + session.getName();
+
+ return webTarget
+ .queryParam("keyword", keyword)
+ .queryParam("session", encodedSessionName)
+ .queryParam("bucket", bucketNumber)
+ .queryParam("ttl", timeToLiveSecond);
+ }
+
+ @Override
+ public void response(Response response) {
+ logger.info("/session/insertDrools response code = {}",
+ response.getStatus());
+ }
+ });
+ }
+ });
+ return true;
+ }
+
+ /******************************************/
+ /* 'PolicyControllerFeatureApi' interface */
+ /******************************************/
+
+ /**
+ * This method is called from 'AggregatedPolicyController.onTopicEvent',
+ * and provides a way to intercept the message before it is decoded and
+ * delivered to a local Drools session.
+ *
+ * @param controller the PolicyController instance receiving the message
+ * @param protocol communication infrastructure type
+ * @param topic topic name
+ * @param event event message as a string
+ * @return 'false' if this event should be processed locally, or 'true'
+ * if the message has been forwarded to a remote host, so local
+ * processing should be bypassed
+ */
+ @Override
+ public boolean beforeOffer(final PolicyController controller,
+ final CommInfrastructure protocol,
+ final String topic,
+ final String event) {
+ // choose the table, based upon the topic
+ String[][] table = topicToPaths.getOrDefault(topic, defaultPaths);
+
+ // build a JSON object from the event
+ StandardCoderObject sco;
+
+ try {
+ sco = coder.decode(event, StandardCoderObject.class);
+ } catch (CoderException e) {
+ return false;
+ }
+ String keyword = null;
+
+ for (String[] path : table) {
+ /*
+ * Each entry in 'table' is a String[] containing an encoding
+ * of a possible keyword field. Suppose the value is 'a.b.c.d.e' --
+ * 'path' would be encoded as 'String[] {"a", "b", "c", "d", "e"}'
+ */
+ String fieldName = path[path.length - 1];
+ String conversionFunctionName = null;
+ int index = fieldName.indexOf(':');
+
+ if (index > 0) {
+ conversionFunctionName = fieldName.substring(index + 1);
+ fieldName = fieldName.substring(0, index);
+ path = Arrays.copyOf(path, path.length);
+ path[path.length - 1] = fieldName;
+ }
+ keyword = sco.getString(path);
+ }
+
+ if (keyword == null) {
+ // couldn't find any keywords -- just process this message locally
+ logger.warn("Can't locate bucket keyword within message");
+ return false;
+ }
+
+ /*
+ * build a message object implementing the 'Bucket.Message' interface --
+ * it will be processed locally, forwarded, or queued based upon the
+ * current state.
+ */
+ TopicMessage message =
+ new TopicMessage(keyword, controller, protocol, topic, event);
+ int bucketNumber = Bucket.bucketNumber(keyword);
+ if (Bucket.forward(bucketNumber, message)) {
+ // message was queued or forwarded -- abort local processing
+ return true;
+ }
+
+ /*
+ * the bucket happens to be assigned to this server, and wasn't queued --
+ * return 'false', so it will be processed locally
+ */
+ logger.info("Keyword={}, bucket={} -- owned by this server",
+ keyword, bucketNumber);
+ return false;
+ }
+
+ /**
+ * Incoming topic message has been forwarded from a remote host.
+ *
+ * @param bucketNumber the bucket number calculated on the remote host
+ * @param keyword the keyword associated with the message
+ * @param controllerName the controller the message was directed to
+ * on the remote host
+ * @param protocol String value of the 'Topic.CommInfrastructure' value
+ * (UEB, DMAAP, NOOP, or REST -- NOOP and REST shouldn't be used
+ * here)
+ * @param topic the UEB/DMAAP topic name
+ * @param event this is the JSON message
+ */
+ static void topicMessage(
+ int bucketNumber, String keyword, String controllerName,
+ String protocol, String topic, String event) {
+
+ // @formatter:off
+ logger.info("Incoming topic message: Keyword={}, bucket={}\n"
+ + " controller = {}\n"
+ + " topic = {}",
+ keyword, bucketNumber, controllerName, topic);
+ // @formatter:on
+
+ // locate the 'PolicyController'
+ PolicyController controller = PolicyControllerConstants.getFactory().get(controllerName);
+ if (controller == null) {
+ /*
+ * This controller existed on the sender's host, but doesn't exist
+ * on the destination. This is a problem -- we are counting on all
+ * hosts being configured with the same controllers.
+ */
+ logger.error("Can't locate controller '{}' for incoming topic message",
+ controllerName);
+ } else if (controller instanceof TopicListener) {
+ /*
+ * This is the destination host -- repeat the 'onTopicEvent'
+ * method (the one that invoked 'beforeOffer' on the originating host).
+ * Note that this message could be forwarded again if the sender's
+ * bucket table was somehow different from ours -- perhaps there was
+ * an update in progress.
+ *
+ * TBD: it would be nice to limit the number of hops, in case we
+ * somehow have a loop.
+ */
+ ((TopicListener)controller).onTopicEvent(
+ CommInfrastructure.valueOf(protocol), topic, event);
+ } else {
+ /*
+ * This 'PolicyController' was also a 'TopicListener' on the sender's
+ * host -- it isn't on this host, and we are counting on them being
+ * config
+ */
+ logger.error("Controller {} is not a TopicListener", controllerName);
+ }
+ }
+
+ /**
+ * An incoming '/session/insertDrools' message was received.
+ *
+ * @param keyword the keyword associated with the incoming object
+ * @param sessionName encoded session name(groupId:artifactId:droolsSession)
+ * @param bucket the bucket associated with keyword
+ * @param ttl similar to IP time-to-live -- it controls the number of hops
+ * the message may take
+ * @param data base64-encoded serialized data for the object
+ */
+ static void incomingInsertDrools(
+ String keyword, String sessionName, int bucket, int ttl, byte[] data) {
+
+ logger.info("Incoming insertDrools: keyword={}, session={}, bucket={}, ttl={}",
+ keyword, sessionName, bucket, ttl);
+
+ if (Bucket.isKeyOnThisServer(keyword)) {
+ // process locally
+
+ // [0]="<groupId>" [1]="<artifactId>", [2]="<sessionName>"
+ String[] nameSegments = sessionName.split(":");
+
+ // locate the 'PolicyContainer' and 'PolicySession'
+ PolicySession policySession = locatePolicySession(nameSegments);
+
+ if (policySession == null) {
+ logger.error("incomingInsertDrools: Can't find PolicySession={}",
+ sessionName);
+ } else {
+ KieSession kieSession = policySession.getKieSession();
+ if (kieSession != null) {
+ try {
+ // deserialization needs to use the correct class loader
+ Object obj = Util.deserialize(
+ Base64.getDecoder().decode(data),
+ policySession.getPolicyContainer().getClassLoader());
+ kieSession.insert(obj);
+ } catch (IOException | ClassNotFoundException
+ | IllegalArgumentException e) {
+ logger.error("incomingInsertDrools: failed to read data "
+ + "for session '{}'", sessionName, e);
+ }
+ }
+ }
+ } else if ((ttl -= 1) > 0) {
+ /*
+ * This host is not the intended destination -- this could happen
+ * if it was sent from another site. Forward the message in the
+ * same thread.
+ */
+ forwardInsertDroolsMessage(bucket, keyword, sessionName, ttl, data);
+ }
+ }
+
+ /**
+ * step through all 'PolicyContainer' instances looking
+ * for a matching 'artifactId' & 'groupId'.
+ * @param nameSegments name portion from sessionName
+ * @return policySession match artifactId and groupId
+ */
+ private static PolicySession locatePolicySession(String[] nameSegments) {
+ PolicySession policySession = null;
+ if (nameSegments.length == 3) {
+ for (PolicyContainer pc : PolicyContainer.getPolicyContainers()) {
+ if (nameSegments[1].equals(pc.getArtifactId())
+ && nameSegments[0].equals(pc.getGroupId())) {
+ policySession = pc.getPolicySession(nameSegments[2]);
+ break;
+ }
+ }
+ }
+ return policySession;
+ }
+
+ /**
+ * Forward the insertDrools message in the same thread.
+ */
+ private static void forwardInsertDroolsMessage(int bucket, String keyword,
+ String sessionName, int ttl, byte[] data) {
+ Server server = Bucket.bucketToServer(bucket);
+ WebTarget webTarget = server.getWebTarget("session/insertDrools");
+ if (webTarget != null) {
+ logger.info("Forwarding 'session/insertDrools' "
+ + "(key={},session={},bucket={},ttl={})",
+ keyword, sessionName, bucket, ttl);
+ Entity<String> entity =
+ Entity.entity(new String(data, StandardCharsets.UTF_8),
+ MediaType.APPLICATION_OCTET_STREAM_TYPE);
+ webTarget
+ .queryParam("keyword", keyword)
+ .queryParam("session", sessionName)
+ .queryParam("bucket", bucket)
+ .queryParam("ttl", ttl)
+ .request().post(entity);
+ }
+ }
+
+ /**
+ * This method builds the table that is used to locate the appropriate
+ * keywords within incoming JSON messages (e.g. 'requestID'). The
+ * associated values are then mapped into bucket numbers.
+ */
+ private static void buildKeywordTable() {
+ Properties prop = ServerPoolProperties.getProperties();
+
+ // iterate over all of the properties, picking out those we are
+ // interested in
+ for (String name : prop.stringPropertyNames()) {
+ String topic = null;
+ String begin;
+ String end;
+
+ if (name.startsWith(KEYWORD_PROPERTY_START_1)
+ && name.endsWith(KEYWORD_PROPERTY_END_1)) {
+ // 1) keyword.<topic>.path=<field-list>
+ // 2) keyword.path=<field-list>
+ begin = KEYWORD_PROPERTY_START_1;
+ end = KEYWORD_PROPERTY_END_1;
+ } else if (name.startsWith(KEYWORD_PROPERTY_START_2)
+ && name.endsWith(KEYWORD_PROPERTY_END_2)) {
+ // 3) ueb.source.topics.<topic>.keyword=<field-list>
+ // 4) ueb.source.topics.keyword=<field-list>
+ begin = KEYWORD_PROPERTY_START_2;
+ end = KEYWORD_PROPERTY_END_2;
+ } else if (name.startsWith(KEYWORD_PROPERTY_START_3)
+ && name.endsWith(KEYWORD_PROPERTY_END_3)) {
+ // 5) dmaap.source.topics.<topic>.keyword=<field-list>
+ // 6) dmaap.source.topics.keyword=<field-list>
+ begin = KEYWORD_PROPERTY_START_3;
+ end = KEYWORD_PROPERTY_END_3;
+ } else {
+ // we aren't interested in this property
+ continue;
+ }
+
+ int beginIndex = begin.length();
+ int endIndex = name.length() - end.length();
+ if (beginIndex < endIndex) {
+ // <topic> is specified, so this table is limited to this
+ // specific topic
+ topic = name.substring(beginIndex, endIndex);
+ }
+
+ // now, process the value
+ // Example: requestID,CommonHeader.RequestID
+ String[] commaSeparatedEntries = prop.getProperty(name).split(",");
+ String[][] paths = new String[commaSeparatedEntries.length][];
+ for (int i = 0 ; i < commaSeparatedEntries.length ; i += 1) {
+ paths[i] = commaSeparatedEntries[i].split("\\.");
+ }
+
+ if (topic == null) {
+ // these paths are used for any topics not explicitly
+ // in the 'topicToPaths' table
+ defaultPaths = paths;
+ } else {
+ // these paths are specific to 'topic'
+ topicToPaths.put(topic, paths);
+ }
+ }
+ }
+
+ /*======================================*/
+ /* 'DroolsPdpStateControlApi' interface */
+ /*======================================*/
+
+ /*
+ * Stop the processing of messages and server pool participation(non-Javadoc)
+ * Note: This is not static because it should only be used if feature-server-pool
+ * has been enabled.
+ * (non-Javadoc)
+ * @see org.onap.policy.drools.control.api.DroolsPdpStateControlApi#shutdown()
+ */
+ @Override
+ public void shutdown() {
+ PolicyEngineConstants.getManager().deactivate();
+ Server.shutdown();
+ }
+
+ /*
+ * Stop the processing of messages and server pool participation(non-Javadoc)
+ * Note: This is not static because it should only be used if feature-server-pool
+ * has been enabled.
+ * (non-Javadoc)
+ * @see org.onap.policy.drools.control.api.DroolsPdpStateControlApi#restart()
+ */
+ @Override
+ public void restart() {
+ MainLoop.startThread();
+ Discovery.startDiscovery();
+ PolicyEngineConstants.getManager().activate();
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This class implements the 'Bucket.Message' interface for UEB/DMAAP
+ * messages.
+ */
+ @AllArgsConstructor
+ private static class TopicMessage implements Bucket.Message {
+ /*
+ * the keyword associated with this message
+ * (which determines the bucket number).
+ */
+ private final String keyword;
+
+ // the controller receiving this message
+ private final PolicyController controller;
+
+ // enumeration: UEB or DMAAP
+ private final CommInfrastructure protocol;
+
+ // UEB/DMAAP topic
+ private final String topic;
+
+ // JSON message as a String
+ private final String event;
+
+ /**
+ * Process this message locally using 'TopicListener.onTopicEvent'
+ * (the 'PolicyController' instance is assumed to implement
+ * the 'TopicListener' interface as well).
+ */
+ @Override
+ public void process() {
+ if (controller instanceof TopicListener) {
+ /*
+ * This is the destination host -- repeat the 'onTopicEvent' method
+ * (the one that invoked 'beforeOffer' on the originating host).
+ * Note that this message could be forwarded again if the sender's
+ * bucket table was somehow different from ours -- perhaps there was
+ * an update in progress.
+ *
+ * TBD: it would be nice to limit the number of hops, in case we
+ * somehow have a loop.
+ */
+ ((TopicListener)controller).onTopicEvent(protocol, topic, event);
+ } else {
+ /*
+ * This 'PolicyController' was also a 'TopicListener' on the sender's
+ * host -- it isn't on this host, and we are counting on them being
+ * configured the same way.
+ */
+ logger.error("Controller {} is not a TopicListener",
+ controller.getName());
+ }
+ }
+
+ /**
+ * Send this message to a remote server for processing (presumably, it
+ * is the destination host).
+ *
+ * @param server the Server instance to send the message to
+ * @param bucketNumber the bucket number to send it to
+ */
+ @Override
+ public void sendToServer(Server server, int bucketNumber) {
+ // if we reach this point, we have determined the remote server
+ // that should process this message
+
+ // @formatter:off
+ logger.info("Outgoing topic message: Keyword={}, bucket={}\n"
+ + " controller = {}"
+ + " topic = {}"
+ + " sender = {}"
+ + " receiver = {}",
+ keyword, bucketNumber, controller.getName(), topic,
+ Server.getThisServer().getUuid(), server.getUuid());
+ // @formatter:on
+
+ Entity<String> entity = Entity.entity(event, MediaType.APPLICATION_JSON);
+ server.post("bucket/topic", entity, new Server.PostResponse() {
+ @Override
+ public WebTarget webTarget(WebTarget webTarget) {
+ return webTarget
+ .queryParam("bucket", bucketNumber)
+ .queryParam("keyword", keyword)
+ .queryParam("controller", controller.getName())
+ .queryParam("protocol", protocol.toString())
+ .queryParam("topic", topic);
+ }
+
+ @Override
+ public void response(Response response) {
+ // TODO: eventually, we will want to do something different
+ // based upon success/failure
+ }
+ });
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Backup data associated with a Drools session.
+ */
+ static class DroolsSessionBackup implements Bucket.Backup {
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Bucket.Restore generate(int bucketNumber) {
+ // Go through all of the Drools sessions, and generate backup data.
+ // If there is no data to backup for this bucket, return 'null'
+
+ DroolsSessionRestore restore = new DroolsSessionRestore();
+ return restore.backup(bucketNumber) ? restore : null;
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This class is used to generate and restore backup Drools data.
+ */
+ static class DroolsSessionRestore implements Bucket.Restore, Serializable {
+ // backup data for all Drools sessions on this host
+ private final List<SingleSession> sessions = new LinkedList<>();
+
+ /**
+ * {@inheritDoc}
+ */
+ boolean backup(int bucketNumber) {
+ /*
+ * There may be multiple Drools sessions being backed up at the same
+ * time. There is one 'Pair' in the list for each session being
+ * backed up.
+ */
+ LinkedList<Pair<CompletableFuture<List<Object>>, PolicySession>>
+ pendingData = new LinkedList<>();
+ for (PolicyContainer pc : PolicyContainer.getPolicyContainers()) {
+ for (PolicySession session : pc.getPolicySessions()) {
+ // Wraps list of objects, to be populated in the session
+ final CompletableFuture<List<Object>> droolsObjectsWrapper =
+ new CompletableFuture<>();
+
+ // 'KieSessionObject'
+ final KieSession kieSession = session.getKieSession();
+
+ logger.info("{}: about to fetch data for session {}",
+ this, session.getFullName());
+ kieSession.insert(new DroolsRunnable() {
+ @Override
+ public void run() {
+ List<Object> droolsObjects = new ArrayList<>();
+ for (FactHandle fh : kieSession.getFactHandles()) {
+ Object obj = kieSession.getObject(fh);
+ String keyword = Keyword.lookupKeyword(obj);
+ if (keyword != null
+ && Bucket.bucketNumber(keyword) == bucketNumber) {
+ // bucket matches -- include this object
+ droolsObjects.add(obj);
+ /*
+ * delete this factHandle from Drools memory
+ * this classes are used in bucket migration,
+ * so the delete is intentional.
+ */
+ kieSession.delete(fh);
+ }
+ }
+
+ // send notification that object list is complete
+ droolsObjectsWrapper.complete(droolsObjects);
+ }
+ });
+
+ // add pending operation to the list
+ pendingData.add(new Pair<>(droolsObjectsWrapper, session));
+ }
+ }
+
+ /**
+ * data copying can start as soon as we receive results
+ * from pending sessions (there may not be any)
+ */
+ copyDataFromSession(pendingData);
+ return !sessions.isEmpty();
+ }
+
+ /**
+ * Copy data from pending sessions.
+ * @param pendingData a list of policy sessions
+ */
+ private void copyDataFromSession(List<Pair<CompletableFuture<List<Object>>, PolicySession>>
+ pendingData) {
+ long endTime = System.currentTimeMillis() + droolsTimeoutMillis;
+
+ for (Pair<CompletableFuture<List<Object>>, PolicySession> pair :
+ pendingData) {
+ PolicySession session = pair.second();
+ long delay = endTime - System.currentTimeMillis();
+ if (delay < 0) {
+ /**
+ * we have already reached the time limit, so we will
+ * only process data that has already been received
+ */
+ delay = 0;
+ }
+ try {
+ List<Object> droolsObjects =
+ pair.first().get(delay, TimeUnit.MILLISECONDS);
+
+ // if we reach this point, session data read has completed
+ logger.info("{}: session={}, got {} object(s)",
+ this, session.getFullName(),
+ droolsObjects.size());
+ if (!droolsObjects.isEmpty()) {
+ sessions.add(new SingleSession(session, droolsObjects));
+ }
+ } catch (TimeoutException e) {
+ logger.error("{}: Timeout waiting for data from session {}",
+ this, session.getFullName());
+ } catch (Exception e) {
+ logger.error("{}: Exception writing output data", this, e);
+ }
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void restore(int bucketNumber) {
+ /*
+ * There may be multiple Drools sessions being restored at the same
+ * time. There is one entry in 'sessionLatches' for each session
+ * being restored.
+ */
+ LinkedList<CountDownLatch> sessionLatches = new LinkedList<>();
+ for (SingleSession session : sessions) {
+ try {
+ CountDownLatch sessionLatch = session.restore();
+ if (sessionLatch != null) {
+ // there is a restore in progress -- add it to the list
+ sessionLatches.add(sessionLatch);
+ }
+ } catch (IOException | ClassNotFoundException e) {
+ logger.error("Exception in {}", this, e);
+ }
+ }
+
+ // wait for all sessions to be updated
+ try {
+ for (CountDownLatch sessionLatch : sessionLatches) {
+ if (!sessionLatch.await(droolsTimeoutMillis, TimeUnit.MILLISECONDS)) {
+ logger.error("{}: timed out waiting for session latch", this);
+ }
+ }
+ } catch (InterruptedException e) {
+ logger.error("Exception in {}", this, e);
+ }
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Each instance of this class corresponds to a Drools session that has
+ * been backed up, or is being restored.
+ */
+ static class SingleSession implements Serializable {
+ // the group id associated with the Drools container
+ String groupId;
+
+ // the artifact id associated with the Drools container
+ String artifactId;
+
+ // the session name within the Drools container
+ String sessionName;
+
+ // serialized data associated with this session (and bucket)
+ byte[] data;
+
+ /**
+ * Constructor - initialize the 'SingleSession' instance, so it can
+ * be serialized.
+ *
+ * @param session the Drools session being backed up
+ * @param droolsObjects the Drools objects from this session associated
+ * with the bucket currently being backed up
+ */
+ SingleSession(PolicySession session, List<Object> droolsObjects) throws IOException {
+ // 'groupId' and 'artifactId' are set from the 'PolicyContainer'
+ PolicyContainer pc = session.getPolicyContainer();
+ groupId = pc.getGroupId();
+ artifactId = pc.getArtifactId();
+
+ // 'sessionName' is set from the 'PolicySession'
+ sessionName = session.getName();
+
+ /*
+ * serialize the Drools objects -- we serialize them here, because they
+ * need to be deserialized within the scope of the Drools session
+ */
+ data = Util.serialize(droolsObjects);
+ }
+
+ CountDownLatch restore() throws IOException, ClassNotFoundException {
+ PolicySession session = null;
+
+ // locate the 'PolicyContainer', and 'PolicySession'
+ for (PolicyContainer pc : PolicyContainer.getPolicyContainers()) {
+ if (artifactId.equals(pc.getArtifactId())
+ && groupId.equals(pc.getGroupId())) {
+ session = pc.getPolicySession(sessionName);
+ return insertSessionData(session, new ByteArrayInputStream(data));
+ }
+ }
+ logger.error("{}: unable to locate session name {}", this, sessionName);
+ return null;
+ }
+
+ /**
+ * Deserialize session data, and insert the objects into the session
+ * from within the Drools session thread.
+ *
+ * @param session the associated PolicySession instance
+ * @param bis the data to be deserialized
+ * @return a CountDownLatch, which will indicate when the operation has
+ * completed (null in case of failure)
+ * @throws IOException IO errors while creating or reading from
+ * the object stream
+ * @throws ClassNotFoundException class not found during deserialization
+ */
+ private CountDownLatch insertSessionData(PolicySession session, ByteArrayInputStream bis)
+ throws IOException, ClassNotFoundException {
+ ClassLoader classLoader = session.getPolicyContainer().getClassLoader();
+ ExtendedObjectInputStream ois =
+ new ExtendedObjectInputStream(bis, classLoader);
+
+ /*
+ * associate the current thread with the session,
+ * and deserialize
+ */
+ session.setPolicySession();
+ Object obj = ois.readObject();
+
+ if (obj instanceof List) {
+ final List<?> droolsObjects = (List<?>)obj;
+ logger.info("{}: session={}, got {} object(s)",
+ this, session.getFullName(), droolsObjects.size());
+
+ // signal when session update is complete
+ final CountDownLatch sessionLatch = new CountDownLatch(1);
+
+ // 'KieSession' object
+ final KieSession kieSession = session.getKieSession();
+
+ // run the following within the Drools session thread
+ kieSession.insert(new DroolsRunnable() {
+ @Override
+ public void run() {
+ try {
+ /*
+ * Insert all of the objects -- note that this is running
+ * in the session thread, so no other rules can fire
+ * until all of the objects are inserted.
+ */
+ for (Object obj : droolsObjects) {
+ kieSession.insert(obj);
+ }
+ } finally {
+ // send notification that the inserts have completed
+ sessionLatch.countDown();
+ }
+ }
+ });
+ return sessionLatch;
+ } else {
+ logger.error("{}: Invalid session data for session={}, type={}",
+ this, session.getFullName(), obj.getClass().getName());
+ }
+ return null;
+ }
+ }
+}
diff --git a/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Keyword.java b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Keyword.java
new file mode 100644
index 00000000..6c88ebd0
--- /dev/null
+++ b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Keyword.java
@@ -0,0 +1,507 @@
+/*
+ * ============LICENSE_START=======================================================
+ * feature-server-pool
+ * ================================================================================
+ * Copyright (C) 2020 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=========================================================
+ */
+
+package org.onap.policy.drools.serverpool;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Function;
+
+import lombok.AllArgsConstructor;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class supports the lookup of keywords from objects within Drools
+ * sessions. It maps the class of the object into an object implementing
+ * the 'Keyword.Lookup' interface. At present, this requires writing
+ * special code for each class that can exist in a Drools session that is
+ * assignable and relocatable via a bucket. In theory, it would be possible
+ * to populate this table through properties, which would use the reflective
+ * interface, and indicate the methods and fields to use to do this lookup.
+ */
+public class Keyword {
+ private static Logger logger = LoggerFactory.getLogger(Keyword.class);
+
+ // this table can be used to map an object class into the method
+ // to invoke to do the lookup
+ private static ConcurrentHashMap<Class, Lookup> classToLookup =
+ new ConcurrentHashMap<>();
+
+ // this is a pre-defined 'Lookup' instance that always returns 'null'
+ private static Lookup nullLookup = new Lookup() {
+ @Override
+ public String getKeyword(Object obj) {
+ return null;
+ }
+ };
+
+ /**
+ * This method takes the object's class, looks it up in the 'classToLookup'
+ * table, and then performs the lookup to get the keyword. When a direct
+ * lookup on a class fails, it will attempt to find a match using inheritance
+ * rules -- if an appropriate match is found, the 'classToLookup' table is
+ * updated, so it will be easier next time. If no match is found, the table
+ * is also updated, but the 'value' will be 'nullLookup'.
+ *
+ * @param obj object to to the lookup on
+ * @return a String keyword, if found; 'null' if not
+ */
+ public static String lookupKeyword(Object obj) {
+ Lookup lu = classToLookup.get(obj.getClass());
+ if (lu != null) {
+ return lu.getKeyword(obj);
+ }
+ // no entry for this class yet --
+ // try to locate a matching entry using 'inheritance' rules
+ Class<?> thisClass = obj.getClass();
+ Class<?> matchingClass = null;
+ for (Map.Entry<Class, Lookup> entry : classToLookup.entrySet()) {
+ if (entry.getKey().isAssignableFrom(thisClass)
+ && (matchingClass == null
+ || matchingClass.isAssignableFrom(entry.getKey()))) {
+ // we have a first match, or a more specific match
+ matchingClass = entry.getKey();
+ lu = entry.getValue();
+ }
+ }
+
+ /*
+ * whether we found a match or not, update the table accordingly
+ * no match found -- see if the 'keyword.<CLASS-NAME>.lookup'
+ * properties can provide a solution.
+ */
+ if (lu == null && (lu = buildReflectiveLookup(thisClass)) == null) {
+ lu = nullLookup;
+ }
+
+ // update table
+ classToLookup.put(thisClass, lu);
+ return lu.getKeyword(obj);
+ }
+
+ /**
+ * explicitly place an entry in the table.
+ *
+ * @param clazz the class to do the lookup on
+ * @param handler an instance implementing the 'Lookup' interface,
+ * can handle instances of type 'clazz'
+ */
+ public static void setLookupHandler(Class<?> clazz, Lookup handler) {
+ classToLookup.put(clazz, handler);
+ }
+
+ /* ============================================================ */
+
+ /**
+ * These are the interface that must be implemented by objects in the
+ * 'classToLookup' table.
+ */
+ public interface Lookup {
+ /**
+ * Map the object into a keyword string.
+ *
+ * @param obj the object to lookup, which should be an instance of the
+ * associated class in the 'classToLookup' table
+ * @return the keyword, if found; 'null' if not
+ */
+ public String getKeyword(Object obj);
+ }
+
+ /* ============================================================ */
+
+ // this table maps class name to a sequence of method calls and field
+ // references, based upon 'keyword.<CLASS-NAME>.lookup' entries found
+ // in the property list
+ private static Map<String,String> classNameToSequence = null;
+
+ static final String KEYWORD_PROPERTY_START = "keyword.";
+ static final String KEYWORD_PROPERTY_END = ".lookup";
+
+ /**
+ * Attempt to build a 'Lookup' instance for a particular class based upon
+ * properties.
+ *
+ * @param clazz the class to build an entry for
+ * @return a 'Lookup' instance to do the lookup, or 'null' if one can't
+ * be generated from the available properties
+ */
+ private static synchronized Lookup buildReflectiveLookup(Class<?> clazz) {
+ if (classNameToSequence == null) {
+ classNameToSequence = new HashMap<>();
+ Properties prop = ServerPoolProperties.getProperties();
+
+ /*
+ * iterate over all of the properties, picking out those
+ * that match the name 'keyword.<CLASS-NAME>.lookup'
+ */
+ for (String name : prop.stringPropertyNames()) {
+ if (name.startsWith(KEYWORD_PROPERTY_START)
+ && name.endsWith(KEYWORD_PROPERTY_END)) {
+ // this property matches -- locate the '<CLASS-NAME>' part
+ int beginIndex = KEYWORD_PROPERTY_START.length();
+ int endIndex = name.length()
+ - KEYWORD_PROPERTY_END.length();
+ if (beginIndex < endIndex) {
+ // add it to the table
+ classNameToSequence.put(name.substring(beginIndex, endIndex),
+ prop.getProperty(name));
+ }
+ }
+ }
+ }
+
+ return lookupClassByName(classNameToSequence, clazz);
+ }
+
+ /**
+ * Look for the "best match" for class 'clazz' in the hash table.
+ * First, look for the name of 'clazz' itself, followed by all of
+ * interfaces. If no match is found, repeat with the superclass,
+ * and all the way up the superclass chain.
+ */
+ private static Lookup lookupClassByName(Map<String, String> classNameToSequence,
+ Class<?> clazz) {
+ Class<?> keyClass = null;
+ for (Class<?> cl = clazz ; cl != null ; cl = cl.getSuperclass()) {
+ if (classNameToSequence.containsKey(cl.getName())) {
+ // matches the class
+ keyClass = cl;
+ break;
+ }
+ for (Class<?> intf : cl.getInterfaces()) {
+ if (classNameToSequence.containsKey(intf.getName())) {
+ // matches one of the interfaces
+ keyClass = intf;
+ break;
+ }
+ // interface can have superclass
+ for (Class<?> cla = clazz; cla != null; cla = intf.getSuperclass()) {
+ if (classNameToSequence.containsKey(cla.getName())) {
+ // matches the class
+ keyClass = cla;
+ break;
+ }
+ }
+ }
+ if (keyClass != null) {
+ break;
+ }
+ }
+
+ if (keyClass == null) {
+ // no matching class name found
+ return null;
+ }
+ // we found a matching key in the table -- now, process the values
+ Class<?> currentClass = keyClass;
+
+ /**
+ * there may potentially be a chain of entries if multiple
+ * field and/or method calls are in the sequence -- this is the first
+ */
+ ReflectiveLookup first = null;
+
+ // this is the last entry in the list
+ ReflectiveLookup last = null;
+
+ /**
+ * split the value into segments, where each segment has the form
+ * 'FIELD-NAME' or 'METHOD-NAME()', with an optional ':CONVERSION'
+ * at the end
+ */
+ String sequence = classNameToSequence.get(keyClass.getName());
+ ConversionFunctionLookup conversionFunctionLookup = null;
+ int index = sequence.indexOf(':');
+ if (index >= 0) {
+ // conversion function specified
+ conversionFunctionLookup =
+ new ConversionFunctionLookup(sequence.substring(index + 1));
+ sequence = sequence.substring(0, index);
+ }
+ for (String segment : sequence.split("\\.")) {
+ ReflectiveLookup current = null;
+ ReflectiveOperationException error = null;
+ try {
+ if (segment.endsWith("()")) {
+ // this segment is a method lookup
+ current = new MethodLookup(currentClass,
+ segment.substring(0, segment.length() - 2));
+ } else {
+ // this segment is a field lookup
+ current = new FieldLookup(currentClass, segment);
+ }
+ } catch (ReflectiveOperationException e) {
+ // presumably the field or method does not exist in this class
+ error = e;
+ }
+ if (current == null) {
+ logger.error("Keyword.buildReflectiveLookup: build error "
+ + "(class={},value={},segment={})",
+ clazz.getName(),
+ classNameToSequence.get(keyClass.getName()),
+ segment,
+ error);
+ return null;
+ }
+
+ // if we reach this point, we processed this segment successfully
+ currentClass = current.nextClass();
+ if (first == null) {
+ // the initial segment
+ first = current;
+ } else {
+ // link to the most recently created segment
+ last.next = current;
+ }
+ // update most recently created segment
+ last = current;
+ }
+
+ // add optional conversion function ('null' if it doesn't exist)
+ last.next = conversionFunctionLookup;
+
+ // successful - return the first 'Lookup' instance in the chain
+ return first;
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Abstract superclass of 'FieldLookup' and 'MethodLookup'.
+ */
+ private abstract static class ReflectiveLookup implements Lookup {
+ // link to the next 'Lookup' instance in the chain
+ Lookup next = null;
+
+ /**
+ * Return the next 'class' instance.
+ *
+ * @return the class associated with the return value of the
+ * field or method lookup
+ */
+ abstract Class<?> nextClass();
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This class is used to do a field lookup.
+ */
+ private static class FieldLookup extends ReflectiveLookup {
+ // the reflective 'Field' instance associated with this lookup
+ Field field;
+
+ /**
+ * Constructor.
+ *
+ * @param clazz the 'class' we are doing the field lookup on
+ * @param segment a segment from the property value, which is just the
+ * field name
+ */
+ FieldLookup(Class<?> clazz, String segment) throws NoSuchFieldException {
+ field = clazz.getField(segment);
+ }
+
+ /********************************/
+ /* 'ReflectiveLookup' interface */
+ /********************************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ Class<?> nextClass() {
+ return field.getType();
+ }
+
+ /**********************/
+ /* 'Lookup' interface */
+ /**********************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String getKeyword(Object obj) {
+ try {
+ // do the field lookup
+ Object rval = field.get(obj);
+ if (rval == null) {
+ return null;
+ }
+
+ // If there is no 'next' entry specified, this value is the
+ // keyword. Otherwise, move on to the next 'Lookup' entry in
+ // the chain.
+ return next == null ? rval.toString() : next.getKeyword(rval);
+ } catch (Exception e) {
+ logger.error("Keyword.FieldLookup error: field={}",
+ field, e);
+ return null;
+ }
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This class is used to do a method call on the target object.
+ */
+ private static class MethodLookup extends ReflectiveLookup {
+ // the reflective 'Method' instance associated with this lookup
+ Method method;
+
+ /**
+ * Constructor.
+ *
+ * @param clazz the 'class' we are doing the method lookup on
+ * @param name a method name extracted from a segment from the
+ * property value, which is the
+ */
+ MethodLookup(Class<?> clazz, String name) throws NoSuchMethodException {
+ method = clazz.getMethod(name);
+ }
+
+ /*==============================*/
+ /* 'ReflectiveLookup' interface */
+ /*==============================*/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ Class<?> nextClass() {
+ return method.getReturnType();
+ }
+
+ /*====================*/
+ /* 'Lookup' interface */
+ /*====================*/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String getKeyword(Object obj) {
+ try {
+ // do the method call
+ Object rval = method.invoke(obj);
+ if (rval == null) {
+ return null;
+ }
+
+ // If there is no 'next' entry specified, this value is the
+ // keyword. Otherwise, move on to the next 'Lookup' entry in
+ // the chain.
+ return next == null ? rval.toString() : next.getKeyword(rval);
+ } catch (Exception e) {
+ logger.error("Keyword.MethodLookup error: method={}",
+ method, e);
+ return null;
+ }
+ }
+ }
+
+ /* ============================================================ */
+
+ /*
+ * Support for named "conversion functions", which take an input keyword,
+ * and return a possibly different keyword derived from it. The initial
+ * need is to take a string which consists of a UUID and a suffix, and
+ * return the base UUID.
+ */
+
+ // used to lookup optional conversion functions
+ private static Map<String, Function<String, String>> conversionFunction =
+ new ConcurrentHashMap<>();
+
+ // conversion function 'uuid':
+ // truncate strings to 36 characters(uuid length)
+ static final int UUID_LENGTH = 36;
+
+ static {
+ conversionFunction.put("uuid", new Function<String, String>() {
+ @Override
+ public String apply(String value) {
+ // truncate strings to 36 characters
+ return value != null && value.length() > UUID_LENGTH
+ ? value.substring(0, UUID_LENGTH) : value;
+ }
+ });
+ }
+
+ /**
+ * Add a conversion function.
+ *
+ * @param name the conversion function name
+ * @param function the object that does the transformation
+ */
+ public static void addConversionFunction(String name, Function<String, String> function) {
+ conversionFunction.put(name, function);
+ }
+
+ /**
+ * Apply a named conversion function to a keyword.
+ *
+ * @param inputKeyword this is the keyword extracted from a message or object
+ * @param functionName this is the name of the conversion function to apply
+ * (if 'null', no conversion is done)
+ * @return the converted keyword
+ */
+ public static String convertKeyword(String inputKeyword, String functionName) {
+ if (functionName == null || inputKeyword == null) {
+ // don't do any conversion -- just return the input keyword
+ return inputKeyword;
+ }
+
+ // look up the function
+ Function<String, String> function = conversionFunction.get(functionName);
+ if (function == null) {
+ logger.error("{}: conversion function not found", functionName);
+ return null;
+ }
+
+ // call the conversion function, and return the value
+ return function.apply(inputKeyword);
+ }
+
+ /**
+ * This class is used to invoke a conversion function.
+ */
+ @AllArgsConstructor
+ private static class ConversionFunctionLookup implements Lookup {
+ // the conversion function name
+ private final String functionName;
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String getKeyword(Object obj) {
+ return obj == null ? null : convertKeyword(obj.toString(), functionName);
+ }
+ }
+}
diff --git a/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Leader.java b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Leader.java
new file mode 100644
index 00000000..9d864bd7
--- /dev/null
+++ b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Leader.java
@@ -0,0 +1,573 @@
+/*
+ * ============LICENSE_START=======================================================
+ * feature-server-pool
+ * ================================================================================
+ * Copyright (C) 2020 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=========================================================
+ */
+
+package org.onap.policy.drools.serverpool;
+
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_LEADER_STABLE_IDLE_CYCLES;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_LEADER_STABLE_VOTING_CYCLES;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.LEADER_STABLE_IDLE_CYCLES;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.LEADER_STABLE_VOTING_CYCLES;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.getProperty;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.HashSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.UUID;
+
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.core.MediaType;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class handles the election of the lead server. The lead server
+ * handles bucket assignments, and also is the server running the
+ * 'Discovery' procedure long-term (other servers do run the procedure
+ * until a leader is elected).
+ * Note that everything in this class is run under the 'MainLoop' thread,
+ * with the exception of the invocation and first two statements of the
+ * 'voteData' method.
+ */
+class Leader {
+ private static Logger logger = LoggerFactory.getLogger(Leader.class);
+
+ // Listener class to handle state changes that may lead to a new election
+ private static EventHandler eventHandler = new EventHandler();
+
+ static {
+ Events.register(eventHandler);
+ }
+
+ // Server currently in the leader roll
+ private static Server leader = null;
+
+ // Vote state machine -- it is null, unless a vote is in progress
+ private static VoteCycle voteCycle = null;
+
+ private static UUID emptyUUID = new UUID(0L, 0L);
+
+ /*==================================================*/
+ /* Some properties extracted at initialization time */
+ /*==================================================*/
+
+ // how many cycles of stability before voting starts
+ private static int stableIdleCycles;
+
+ // how may cycles of stability before declaring a winner
+ private static int stableVotingCycles;
+
+ /**
+ * Invoked at startup, or after some events -- immediately start a new vote.
+ */
+ static void startup() {
+ // fetch some static properties
+ stableIdleCycles = getProperty(LEADER_STABLE_IDLE_CYCLES,
+ DEFAULT_LEADER_STABLE_IDLE_CYCLES);
+ stableVotingCycles = getProperty(LEADER_STABLE_VOTING_CYCLES,
+ DEFAULT_LEADER_STABLE_VOTING_CYCLES);
+
+ startVoting();
+ }
+
+ /**
+ * start, or restart voting.
+ */
+ private static void startVoting() {
+ if (voteCycle == null) {
+ voteCycle = new VoteCycle();
+ MainLoop.addBackgroundWork(voteCycle);
+ } else {
+ voteCycle.serverChanged();
+ }
+ }
+
+ /**
+ * Return the current leader.
+ *
+ * @return the current leader ('null' if none has been selected)
+ */
+ public static Server getLeader() {
+ return leader;
+ }
+
+ /**
+ * Handle an incoming /vote REST message.
+ *
+ * @param data base64-encoded data, containing vote data
+ */
+ static void voteData(byte[] data) {
+ // decode base64 data
+ final byte[] packet = Base64.getDecoder().decode(data);
+
+ MainLoop.queueWork(new Runnable() {
+ /**
+ * This method is running within the 'MainLoop' thread.
+ */
+ @Override
+ public void run() {
+ // create the 'VoteCycle' state machine, if needed
+ if (voteCycle == null) {
+ voteCycle = new VoteCycle();
+ MainLoop.addBackgroundWork(voteCycle);
+ }
+ try {
+ // pass data to 'VoteCycle' state machine
+ voteCycle.packetReceived(packet);
+ } catch (IOException e) {
+ logger.error("Exception in 'Leader.voteData", e);
+ }
+ }
+ });
+ }
+
+ /* ============================================================ */
+
+ /**
+ * There is a single instance of this class (Leader.eventHandler), which
+ * is registered to listen for notifications of state transitions. Note
+ * that all of these methods are running within the 'MainLoop' thread.
+ */
+ private static class EventHandler implements Events {
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void newServer(Server server) {
+ // a new server has joined -- start/restart the VoteCycle state machine
+ startVoting();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void serverFailed(Server server) {
+ if (server == leader) {
+ // the lead server has failed --
+ // start/restart the VoteCycle state machine
+ leader = null;
+ startVoting();
+
+ // send out a notification that the lead server has failed
+ for (Events listener : Events.getListeners()) {
+ listener.leaderFailed(server);
+ }
+ } else if (voteCycle != null) {
+ // a vote is in progress -- restart the state machine
+ // (don't do anything if there is no vote in progress)
+ voteCycle.serverChanged();
+ }
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This is the 'VoteCycle' state machine -- it runs as background work
+ * on the 'MainLoop' thread, and goes away when a leader is elected.
+ */
+ private static class VoteCycle implements Runnable {
+ enum State {
+ // server just started up -- 5 second grace period
+ STARTUP,
+
+ // voting in progress -- changes have occurred in the last cycle
+ VOTING,
+ }
+
+ // maps UUID voted for into the associated data
+ private final TreeMap<UUID, VoteData> uuidToVoteData =
+ new TreeMap<>(Util.uuidComparator);
+
+ // maps voter UUID into the associated data
+ private final TreeMap<UUID, VoterData> uuidToVoterData =
+ new TreeMap<>(Util.uuidComparator);
+
+ // sorted list of 'VoteData' (most preferable to least)
+ private final TreeSet<VoteData> voteData = new TreeSet<>();
+
+ // data to send out next cycle
+ private final HashSet<VoterData> updatedVotes = new HashSet<>();
+
+ private State state = State.STARTUP;
+ private int cycleCount = stableIdleCycles;
+
+ /**
+ * Constructor - if there is no leader, or this server is the leader,
+ * start the 'Discovery' thread.
+ */
+ VoteCycle() {
+ if (leader == null || leader == Server.getThisServer()) {
+ Discovery.startDiscovery();
+ }
+ }
+
+ /**
+ * A state change has occurred that invalidates any vote in progress --
+ * restart the VoteCycle state machine.
+ */
+ void serverChanged() {
+ // clear all of the tables
+ uuidToVoteData.clear();
+ uuidToVoterData.clear();
+ voteData.clear();
+ updatedVotes.clear();
+
+ // wait for things to stabilize before continuing
+ state = State.STARTUP;
+ cycleCount = stableIdleCycles;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void run() {
+ switch (state) {
+ case STARTUP: {
+ // 5-second grace period -- wait for things to stablize before
+ // starting the vote
+ if ((cycleCount -= 1) <= 0) {
+ logger.info("VoteCycle: {} seconds have passed",
+ stableIdleCycles);
+ //MainLoop.removeBackgroundWork(this);
+ updateMyVote();
+ sendOutUpdates();
+ state = State.VOTING;
+ cycleCount = stableVotingCycles;
+ }
+ break;
+ }
+
+ case VOTING: {
+ // need to be in the VOTING state without any vote changes
+ // for 5 seconds -- once this happens, the leader is chosen
+ if (sendOutUpdates()) {
+ // changes have occurred -- set the grace period to 5 seconds
+ cycleCount = stableVotingCycles;
+ } else if ((cycleCount -= 1) <= 0) {
+ // 5 second grace period has passed -- the leader is one with
+ // the most votes, which is the first entry in 'voteData'
+ Server oldLeader = leader;
+ leader = Server.getServer(voteData.first().uuid);
+ if (leader != oldLeader) {
+ // the leader has changed -- send out notifications
+ for (Events listener : Events.getListeners()) {
+ listener.newLeader(leader);
+ }
+ } else {
+ // the election is over, and the leader has been confirmed
+ for (Events listener : Events.getListeners()) {
+ listener.leaderConfirmed(leader);
+ }
+ }
+ if (leader == Server.getThisServer()) {
+ // this is the lead server --
+ // make sure the 'Discovery' threads are running
+ Discovery.startDiscovery();
+ } else {
+ // this is not the lead server -- stop 'Discovery' threads
+ Discovery.stopDiscovery();
+ }
+
+ // we are done with voting -- clean up, and report results
+ MainLoop.removeBackgroundWork(this);
+ voteCycle = null;
+
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ PrintStream out = new PrintStream(bos);
+
+ out.println("Voting results:");
+
+ // x(36) xxxxx x(36)
+ // UUID Votes Voter
+ String format = "%-36s %5s %-36s\n";
+ out.format(format, "UUID", "Votes", "Voter(s)");
+ out.format(format, "----", "-----", "--------");
+
+ for (VoteData vote : voteData) {
+ if (vote.voters.isEmpty()) {
+ out.format(format, vote.uuid, 0, "");
+ } else {
+ boolean headerNeeded = true;
+ for (VoterData voter : vote.voters) {
+ if (headerNeeded) {
+ out.format(format, vote.uuid,
+ vote.voters.size(), voter.uuid);
+ headerNeeded = false;
+ } else {
+ out.format(format, "", "", voter.uuid);
+ }
+ }
+ }
+ }
+
+ logger.info(bos.toString());
+ }
+ break;
+ }
+ default:
+ logger.error("Unknown state: {}", state);
+ break;
+ }
+ }
+
+ /**
+ * Process an incoming /vote REST message.
+ *
+ * @param packet vote data, containing one or more votes
+ */
+ private void packetReceived(byte[] packet) throws IOException {
+ DataInputStream dis =
+ new DataInputStream(new ByteArrayInputStream(packet));
+
+ while (dis.available() != 0) {
+ // message is a series of:
+ // 16-bytes voter UUID
+ // 16-bytes vote UUID
+ // 8-bytes timestamp
+ long tmp = dis.readLong(); // most significant bits
+ UUID voter = new UUID(tmp, dis.readLong());
+
+ tmp = dis.readLong();
+ UUID vote = new UUID(tmp, dis.readLong());
+
+ long timestamp = dis.readLong();
+
+ // process the single vote
+ processVote(voter, vote, timestamp);
+ }
+ }
+
+ /**
+ * Process a single incoming vote.
+ *
+ * @param UUID voter the UUID of the Server making this vote
+ * @param UUID vote the UUID of the Server that 'voter' voted for
+ * @param timestamp the time when the vote was made
+ */
+ private void processVote(UUID voter, UUID vote, long timestamp) {
+ // fetch old data for this voter
+ VoterData voterData = uuidToVoterData.computeIfAbsent(voter,
+ (key) -> new VoterData(voter, timestamp));
+ if (timestamp >= voterData.timestamp) {
+ // this is a new vote for this voter -- update the timestamp
+ voterData.timestamp = timestamp;
+ } else {
+ // already processed vote, and it may even be obsolete
+ return;
+ }
+
+ // fetch the old vote, if any, for this voter
+ VoteData oldVoteData = voterData.vote;
+ VoteData newVoteData = null;
+
+ if (vote != null) {
+ newVoteData = uuidToVoteData.computeIfAbsent(vote, (key) -> new VoteData(vote));
+ }
+
+ if (oldVoteData != newVoteData) {
+ // the vote has changed -- update the 'voterData' entry,
+ // and include this in the next set of outgoing messages
+ logger.info("{} voting for {}", voter, vote);
+ voterData.vote = newVoteData;
+ updatedVotes.add(voterData);
+
+ if (oldVoteData != null) {
+ // remove the old vote data
+ voteData.remove(oldVoteData);
+ oldVoteData.voters.remove(voterData);
+ if (oldVoteData.voters.isEmpty()) {
+ // no voters left -- remove the entry
+ uuidToVoteData.remove(oldVoteData.uuid);
+ } else {
+ // reinsert in a new position
+ voteData.add(oldVoteData);
+ }
+ }
+
+ if (newVoteData != null) {
+ // update the new vote data
+ voteData.remove(newVoteData);
+ newVoteData.voters.add(voterData);
+ voteData.add(newVoteData);
+ }
+ }
+ }
+
+ /**
+ * If any updates have occurred, send then out to all servers on
+ * the "notify list".
+ *
+ * @return 'true' if one or more votes have changed, 'false' if not
+ */
+ private boolean sendOutUpdates() {
+ try {
+ if (updatedVotes.isEmpty()) {
+ // no changes to send out
+ return false;
+ }
+
+ // possibly change vote based on current information
+ updateMyVote();
+
+ // generate message to send out
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ DataOutputStream dos = new DataOutputStream(bos);
+
+ // go through all of the updated votes
+ for (VoterData voterData : updatedVotes) {
+ // voter UUID
+ dos.writeLong(voterData.uuid.getMostSignificantBits());
+ dos.writeLong(voterData.uuid.getLeastSignificantBits());
+
+ // vote UUID
+ UUID vote =
+ (voterData.vote == null ? emptyUUID : voterData.vote.uuid);
+ dos.writeLong(vote.getMostSignificantBits());
+ dos.writeLong(vote.getLeastSignificantBits());
+
+ // timestamp
+ dos.writeLong(voterData.timestamp);
+ }
+ updatedVotes.clear();
+
+ // create an 'Entity' that can be sent out to all hosts
+ Entity<String> entity = Entity.entity(
+ new String(Base64.getEncoder().encode(bos.toByteArray()), StandardCharsets.UTF_8),
+ MediaType.APPLICATION_OCTET_STREAM_TYPE);
+
+ // send out to all servers on the notify list
+ for (Server server : Server.getNotifyList()) {
+ server.post("vote", entity);
+ }
+ return true;
+ } catch (IOException e) {
+ logger.error("Exception in VoteCycle.sendOutUpdates", e);
+ return false;
+ }
+ }
+
+ /**
+ * (Possibly) change this servers vote, based upon votes of other voters.
+ */
+ private void updateMyVote() {
+ UUID myVote = null;
+
+ if (uuidToVoterData.size() * 2 < Server.getServerCount()) {
+ // fewer than half of the nodes have voted
+ if (leader != null) {
+ // choose the current leader
+ myVote = leader.getUuid();
+ } else {
+ // choose the first entry in the servers list
+ myVote = Server.getFirstServer().getUuid();
+ }
+ } else {
+ // choose the first entry we know about
+ for (VoteData vote : voteData) {
+ if (Server.getServer(vote.uuid) != null) {
+ myVote = vote.uuid;
+ break;
+ }
+ }
+ }
+ if (myVote != null) {
+ // update the vote for this host, and include it in the list
+ processVote(Server.getThisServer().getUuid(), myVote,
+ System.currentTimeMillis());
+ }
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This class corresponds to a single vote recipient --
+ * the Server being voted for.
+ */
+ private static class VoteData implements Comparable<VoteData> {
+ // uuid voted for
+ private UUID uuid;
+
+ // the set of all voters that voted for this server
+ private HashSet<VoterData> voters = new HashSet<>();
+
+ /**
+ * Constructor -- set the UUID.
+ */
+ VoteData(UUID uuid) {
+ this.uuid = uuid;
+ }
+
+ /*================================*/
+ /* Comparable<VoteData> interface */
+ /*================================*/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int compareTo(VoteData other) {
+ // favor highest vote count
+ // in case of a tie, compare UUIDs (favor smallest)
+
+ int rval = other.voters.size() - voters.size();
+ if (rval == 0) {
+ // vote counts equal -- favor the smaller UUID
+ rval = Util.uuidComparator.compare(uuid, other.uuid);
+ }
+ return rval;
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This class corresponds to the vote of a single server.
+ */
+ private static class VoterData {
+ // voter UUID
+ private UUID uuid;
+
+ // most recently cast vote from this voter
+ private VoteData vote = null;
+
+ // time when the vote was cast
+ private long timestamp = 0;
+
+ /**
+ * Constructor - store the UUID and timestamp.
+ */
+ private VoterData(UUID uuid, long timestamp) {
+ this.uuid = uuid;
+ this.timestamp = timestamp;
+ }
+ }
+}
diff --git a/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/MainLoop.java b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/MainLoop.java
new file mode 100644
index 00000000..1ed7ecb2
--- /dev/null
+++ b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/MainLoop.java
@@ -0,0 +1,186 @@
+/*
+ * ============LICENSE_START=======================================================
+ * feature-server-pool
+ * ================================================================================
+ * Copyright (C) 2020 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=========================================================
+ */
+
+package org.onap.policy.drools.serverpool;
+
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_MAINLOOP_CYCLE;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.MAINLOOP_CYCLE;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.getProperty;
+
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.LinkedTransferQueue;
+import java.util.concurrent.TimeUnit;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class provides a single thread that is used for 'Server' and 'Bucket'
+ * updates. This simplifies things because it greatly reduces the need for
+ * synchronization within these classes.
+ */
+class MainLoop extends Thread {
+ private static Logger logger = LoggerFactory.getLogger(MainLoop.class);
+
+ // this queue is used to send work to the 'MainLoop' thread, for things
+ // like processing incoming messages
+ private static LinkedTransferQueue<Runnable> incomingWork =
+ new LinkedTransferQueue<>();
+
+ // this is used for work that should be invoked every cycle
+ private static ConcurrentLinkedQueue<Runnable> backgroundWork =
+ new ConcurrentLinkedQueue<>();
+
+ // this is the 'MainLoop' thread
+ private static volatile MainLoop mainLoop = null;
+
+ // main loop cycle time
+ private static long cycleTime;
+
+ /**
+ * If it isn't already running, start the 'MainLoop' thread.
+ */
+ public static synchronized void startThread() {
+ cycleTime = getProperty(MAINLOOP_CYCLE, DEFAULT_MAINLOOP_CYCLE);
+ if (mainLoop == null) {
+ mainLoop = new MainLoop();
+ mainLoop.start();
+ }
+ }
+
+ /**
+ * If it is currently running, stop the 'MainLoop' thread.
+ */
+ public static synchronized void stopThread() {
+ // this won't be immediate, but the thread should discover it shortly
+ MainLoop saveMainLoop = mainLoop;
+
+ mainLoop = null;
+ if (saveMainLoop != null) {
+ saveMainLoop.interrupt();
+ }
+ }
+
+ /**
+ * Add some work to the 'incomingWork' queue -- this runs once, and is
+ * automatically removed from the queue.
+ *
+ * @param work this is the Runnable to invoke
+ */
+ public static void queueWork(Runnable work) {
+ incomingWork.offer(work);
+ }
+
+ /**
+ * Add some work to the 'backgroundWork' queue -- this runs every cycle,
+ * until it is manually removed.
+ *
+ * @param work this is the Runnable to invoke every cycle
+ */
+ public static void addBackgroundWork(Runnable work) {
+ // if it is already here, remove it first
+ backgroundWork.remove(work);
+
+ // add to the end of the queue
+ backgroundWork.add(work);
+ }
+
+ /**
+ * Remove work from the 'backgroundWork' queue.
+ *
+ * @param work this is the Runnable to remove from the queue
+ * @return true if the background work was found, and removed
+ */
+ public static boolean removeBackgroundWork(Runnable work) {
+ return backgroundWork.remove(work);
+ }
+
+ /**
+ * Constructor.
+ */
+ private MainLoop() {
+ super("Main Administrative Loop");
+ }
+
+ /**
+ * This is the main processing loop for "administrative" messages, which
+ * manage 'Server' states.
+ * 1) Process incoming messages (other threads are reading in and queueing
+ * the messages), making note of information that should forwarded to
+ * other servers.
+ * 2) Send out updates to all servers on the 'notify' list
+ * 3) Go through list of all 'Server' entries, and see which ones have
+ * taken too long to respond -- those are treated as 'failed'
+ */
+ @Override
+ public void run() {
+ while (this == mainLoop) {
+ try {
+ // the following reads in messages over a period of 1 second
+ handleIncomingWork();
+
+ // send out notifications to other hosts
+ Server.sendOutData();
+
+ // search for hosts which have taken too long to respond
+ Server.searchForFailedServers();
+
+ // work that runs every cycle
+ for (Runnable work : backgroundWork) {
+ try {
+ work.run();
+ } catch (Exception e) {
+ logger.error("Exception in MainLoop background work", e);
+ }
+ }
+ } catch (Exception e) {
+ logger.error("Exception in MainLoop", e);
+ }
+ }
+ }
+
+ /**
+ * Poll for and process incoming messages for up to 1 second.
+ */
+ static void handleIncomingWork() throws InterruptedException {
+ long currentTime = System.currentTimeMillis();;
+ long wakeUpTime = currentTime + cycleTime;
+ long timeDiff;
+
+ // receive incoming messages
+ while ((timeDiff = wakeUpTime - currentTime) > 0) {
+ try {
+ Runnable work =
+ incomingWork.poll(timeDiff, TimeUnit.MILLISECONDS);
+ if (work == null) {
+ // timeout -- we are done processing messages for now
+ return;
+ }
+ work.run();
+ } catch (InterruptedException e) {
+ logger.error("Interrupted in MainLoop");
+ throw(e);
+ } catch (Exception e) {
+ logger.error("Exception in MainLoop incoming work", e);
+ }
+ currentTime = System.currentTimeMillis();
+ }
+ }
+}
diff --git a/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/RestServerPool.java b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/RestServerPool.java
new file mode 100644
index 00000000..1c4cc7ba
--- /dev/null
+++ b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/RestServerPool.java
@@ -0,0 +1,447 @@
+/*
+ * ============LICENSE_START=======================================================
+ * feature-server-pool
+ * ================================================================================
+ * Copyright (C) 2020 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=========================================================
+ */
+
+package org.onap.policy.drools.serverpool;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.Info;
+import io.swagger.annotations.SwaggerDefinition;
+import io.swagger.annotations.Tag;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+import javax.ws.rs.Consumes;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+
+import org.onap.policy.drools.serverpool.Bucket;
+import org.onap.policy.drools.serverpool.FeatureServerPool;
+import org.onap.policy.drools.serverpool.Leader;
+import org.onap.policy.drools.serverpool.Server;
+import org.onap.policy.drools.serverpool.TargetLock;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class contains methods for processing incoming REST messages.
+ */
+
+@Path("/")
+@Api
+@SwaggerDefinition(
+ info = @Info(
+ description = "PDP-D Server Pool Telemetry Service",
+ version = "v1.0",
+ title = "PDP-D Server Pool Telemetry"
+ ),
+ consumes = {MediaType.APPLICATION_JSON},
+ produces = {MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN},
+ schemes = {SwaggerDefinition.Scheme.HTTP},
+ tags = {
+ @Tag(name = "pdp-d-server-pool-telemetry", description = "Drools PDP Server Pool Telemetry Operations")
+ }
+ )
+public class RestServerPool {
+ private static Logger logger = LoggerFactory.getLogger(RestServerPool.class);
+
+ /**
+ * Handle the '/test' REST call.
+ */
+ @GET
+ @Path("/test")
+ @ApiOperation(
+ value = "Perform an incoming /test request",
+ notes = "Provides an acknowledge message back to requestor",
+ response = String.class
+ )
+ @Produces(MediaType.TEXT_PLAIN)
+ public String test() {
+ return "RestServerPool.test()";
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Handle the '/admin' REST call.
+ */
+ @POST
+ @Path("/admin")
+ @ApiOperation(
+ value = "Perform an incoming /admin request",
+ notes = "This rest call decodes incoming admin message (base-64-encoded) and "
+ + "send to main thread for processing"
+ )
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ public void adminRequest(byte[] data) {
+ Server.adminRequest(data);
+ }
+
+ /**
+ * Handle the '/vote' REST call.
+ */
+ @POST
+ @Path("/vote")
+ @ApiOperation(
+ value = "Perform an incoming /vote request",
+ notes = "The request data containing voter and vote data to be processed"
+ )
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ public void voteData(byte[] data) {
+ Leader.voteData(data);
+ }
+
+ /**
+ * Handle the '/bucket/update' REST call.
+ */
+ @POST
+ @Path("/bucket/update")
+ @ApiOperation(
+ value = "Perform an incoming /bucket/update request",
+ notes = "The request data include owner, state, primaryBackup and secondaryBackup"
+ )
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ public void updateBucket(byte[] data) {
+ Bucket.updateBucket(data);
+ }
+
+ /**
+ * Handle the '/bucket/topic' REST call.
+ */
+ @POST
+ @Path("/bucket/topic")
+ @ApiOperation(
+ value = "Perform an incoming /bucket/topic request",
+ notes = "Forward an incoming topic message from a remote host, the request data include "
+ + "bucketNumber the bucket number calculated on the remote host, keyword the keyword "
+ + "associated with the message, controllerName the controller the message was directed to "
+ + "on the remote host, protocol String value of the topic value (UEB, DMAAP, NOOP, or REST "
+ + "-- NOOP and REST shouldn't be used here), topic the UEB/DMAAP topic name, event this is "
+ + "the JSON message"
+ )
+ @Consumes(MediaType.APPLICATION_JSON)
+ public void topicMessage(@QueryParam("bucket") Integer bucket,
+ @QueryParam("keyword") String keyword,
+ @QueryParam("controller") String controllerName,
+ @QueryParam("protocol") String protocol,
+ @QueryParam("topic") String topic,
+ String event) {
+ FeatureServerPool.topicMessage(bucket, keyword, controllerName, protocol, topic, event);
+ }
+
+ /**
+ * Handle the '/bucket/sessionData' REST call.
+ */
+ @POST
+ @Path("/bucket/sessionData")
+ @ApiOperation(
+ value = "Perform an incoming /bucket/sessionData request",
+ notes = "A message is received from the old owner of the bucket and send to new owner, "
+ + "the request data include bucketNumber the bucket number, dest the UUID of the intended "
+ + "destination, ttl controls the number of hops the message may take, data serialized data "
+ + "associated with this bucket, encoded using base64"
+ )
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ public void sessionData(@QueryParam("bucket") Integer bucket,
+ @QueryParam("dest") UUID dest,
+ @QueryParam("ttl") int ttl,
+ byte[] data) {
+ Bucket.sessionData(bucket, dest, ttl, data);
+ }
+
+ /**
+ * Handle the '/session/insertDrools' REST call.
+ */
+ @POST
+ @Path("/session/insertDrools")
+ @ApiOperation(
+ value = "Perform an incoming /session/insertDrools request",
+ notes = "An incoming /session/insertDrools message was received, the request data include "
+ + "keyword the keyword associated with the incoming object, sessionName encoded session name "
+ + "(groupId:artifactId:droolsSession), bucket the bucket associated with keyword, "
+ + "ttl controls the number of hops the message may take, data base64-encoded serialized data "
+ + "for the object"
+ )
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ public void insertDrools(@QueryParam("keyword") String keyword,
+ @QueryParam("session") String sessionName,
+ @QueryParam("bucket") int bucket,
+ @QueryParam("ttl") int ttl,
+ byte[] data) {
+ FeatureServerPool.incomingInsertDrools(keyword, sessionName, bucket, ttl, data);
+ }
+
+ /**
+ * Handle the '/lock/lock' REST call.
+ */
+ @GET
+ @Path("/lock/lock")
+ @ApiOperation(
+ value = "Perform an incoming /lock/lock request",
+ notes = "An incoming /lock/lock REST message is received, the request data include "
+ + "key string identifying the lock, which must hash to a bucket owned by the current host, "
+ + "ownerKey string key identifying the owner, uuid the UUID that uniquely identifies "
+ + "the original 'TargetLock', waitForLock this controls the behavior when 'key' is already "
+ + "locked - 'true' means wait for it to be freed, 'false' means fail, ttl controls the number "
+ + "of hops the message may take, the response is the message should be passed back to the "
+ + "requestor"
+ )
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ @Produces(MediaType.APPLICATION_OCTET_STREAM)
+ public Response lock(@QueryParam("key") String key,
+ @QueryParam("owner") String keyOwner,
+ @QueryParam("uuid") UUID uuid,
+ @QueryParam("wait") boolean waitForLock,
+ @QueryParam("ttl") int ttl) {
+ return TargetLock.incomingLock(key, keyOwner, uuid, waitForLock, ttl);
+ }
+
+ /**
+ * Handle the '/lock/free' REST call.
+ */
+ @GET
+ @Path("/lock/free")
+ @ApiOperation(
+ value = "Perform an incoming /lock/free request",
+ notes = "An incoming /lock/free REST message is received, the request data include "
+ + "key string identifying the lock, which must hash to a bucket owned by the current host, "
+ + "ownerKey string key identifying the owner, uuid the UUID that uniquely identifies "
+ + "the original 'TargetLock', ttl controls the number of hops the message may take, "
+ + "the response is the message should be passed back to the requestor"
+ )
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ @Produces(MediaType.APPLICATION_OCTET_STREAM)
+ public Response free(@QueryParam("key") String key,
+ @QueryParam("owner") String keyOwner,
+ @QueryParam("uuid") UUID uuid,
+ @QueryParam("ttl") int ttl) {
+ return TargetLock.incomingFree(key, keyOwner, uuid, ttl);
+ }
+
+ /**
+ * Handle the '/lock/locked' REST call.
+ */
+ @GET
+ @Path("/lock/locked")
+ @ApiOperation(
+ value = "Perform an incoming /lock/locked request, (this is a callback to an earlier "
+ + "requestor that the lock is now available)",
+ notes = "An incoming /lock/locked REST message is received, the request data include "
+ + "key string key identifying the lock, ownerKey string key identifying the owner "
+ + "which must hash to a bucket owned by the current host (it is typically a 'RequestID') "
+ + "uuid the UUID that uniquely identifies the original 'TargetLock', ttl controls the "
+ + "number of hops the message may take, the response is the message should be passed back "
+ + "to the requestor"
+ )
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ @Produces(MediaType.APPLICATION_OCTET_STREAM)
+ public Response locked(@QueryParam("key") String key,
+ @QueryParam("owner") String keyOwner,
+ @QueryParam("uuid") UUID uuid,
+ @QueryParam("ttl") int ttl) {
+ return TargetLock.incomingLocked(key, keyOwner, uuid, ttl);
+ }
+
+ /**
+ * Handle the '/lock/audit' REST call.
+ */
+ @POST
+ @Path("/lock/audit")
+ @ApiOperation(
+ value = "Perform an incoming /lock/audit request",
+ notes = "An incoming /lock/audit REST message is received, the request data include "
+ + "serverUuid the UUID of the intended destination server, ttl controls the number of hops, "
+ + "encodedData base64-encoded data, containing a serialized 'AuditData' instance "
+ + "the response is a serialized and base64-encoded 'AuditData'"
+ )
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ @Produces(MediaType.APPLICATION_OCTET_STREAM)
+ public byte[] lockAudit(@QueryParam("server") UUID server,
+ @QueryParam("ttl") int ttl,
+ byte[] data) {
+ return TargetLock.Audit.incomingAudit(server, ttl, data);
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Handle the '/cmd/dumpHosts' REST call.
+ */
+ @GET
+ @Path("/cmd/dumpHosts")
+ @ApiOperation(
+ value = "Perform an incoming /cmd/dumpHosts request",
+ notes = "Dump out the current 'servers' table in a human-readable table form"
+ )
+ @Produces(MediaType.TEXT_PLAIN)
+ public String dumpHosts() {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ Server.dumpHosts(new PrintStream(bos, true));
+ return bos.toString(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Handle the '/cmd/dumpBuckets' REST call.
+ */
+ @GET
+ @Path("/cmd/dumpBuckets")
+ @ApiOperation(
+ value = "Perform an incoming /cmd/dumpBuckets request",
+ notes = "Dump out buckets information in a human-readable form"
+ )
+ @Produces(MediaType.TEXT_PLAIN)
+ public String dumpBuckets() {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ Bucket.dumpBuckets(new PrintStream(bos, true));
+ return bos.toString(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Handle the '/cmd/ping' REST call.
+ */
+ @GET
+ @Path("/cmd/ping")
+ @ApiOperation(
+ value = "Perform an incoming /cmd/ping request",
+ notes = "Send information about 'thisServer' to the list of hosts"
+ )
+ @Produces(MediaType.TEXT_PLAIN)
+ public String ping(@QueryParam("hosts") String hosts) {
+ logger.info("Running '/cmd/ping', hosts={}", hosts);
+
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ Server.pingHosts(new PrintStream(bos, true), hosts);
+ return bos.toString(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Handle the '/cmd/bucketMessage' REST call.
+ */
+ @GET
+ @Path("/cmd/bucketMessage")
+ @ApiOperation(
+ value = "Perform an incoming /cmd/bucketMessage request",
+ notes = "This is only used for testing the routing of messages between servers"
+ )
+ @Produces(MediaType.TEXT_PLAIN)
+ public String bucketMessage(@QueryParam("keyword") String keyword,
+ @QueryParam("message") String message)
+ throws IOException {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ Bucket.bucketMessage(new PrintStream(bos, true), keyword, message);
+ return bos.toString(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Handle the '/bucket/bucketResponse' REST call.
+ */
+ @POST
+ @Path("/bucket/bucketResponse")
+ @ApiOperation(
+ value = "Perform an incoming /cmd/bucketResponse request",
+ notes = "This runs on the destination host, and is the continuation of an operation "
+ + "triggered by the /cmd/bucketMessage REST message running on the originating host"
+ )
+ @Consumes(MediaType.TEXT_PLAIN)
+ @Produces(MediaType.TEXT_PLAIN)
+ public String bucketResponse(@QueryParam("bucket") Integer bucket,
+ @QueryParam("keyword") String keyword,
+ byte[] data) {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ Bucket.bucketResponse(new PrintStream(bos, true), bucket, keyword, data);
+ return bos.toString(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Handle the '/lock/moveBucket' REST call.
+ */
+ @GET
+ @Path("/cmd/moveBucket")
+ @ApiOperation(
+ value = "Perform an incoming /cmd/moveBucket request",
+ notes = "This is only used for testing bucket migration. It only works on the lead server"
+ )
+ @Produces(MediaType.TEXT_PLAIN)
+ public String moveBucket(@QueryParam("bucket") Integer bucketNumber,
+ @QueryParam("host") String newHost) {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ Bucket.moveBucket(new PrintStream(bos, true), bucketNumber, newHost);
+ return bos.toString(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Handle the '/lock/dumpBucketAdjuncts' REST call.
+ */
+ @GET
+ @Path("/cmd/dumpBucketAdjuncts")
+ @ApiOperation(
+ value = "Perform an incoming /cmd/dumpBucketAdjuncts request",
+ notes = "Dump out all buckets with adjuncts"
+ )
+ @Produces(MediaType.TEXT_PLAIN)
+ public String dumpBucketAdjuncts() {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ Bucket.dumpAdjuncts(new PrintStream(bos, true));
+ return bos.toString(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Handle the '/lock/dumpLocks' REST call.
+ */
+ @GET
+ @Path("/cmd/dumpLocks")
+ @ApiOperation(
+ value = "Perform an incoming /cmd/dumpLocks request",
+ notes = "Dump out locks info, detail 'true' provides additional bucket and host information"
+ )
+ @Produces(MediaType.TEXT_PLAIN)
+ public String dumpLocks(@QueryParam("detail") boolean detail)
+ throws IOException, InterruptedException, ClassNotFoundException {
+
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ TargetLock.DumpLocks.dumpLocks(new PrintStream(bos, true), detail);
+ return bos.toString(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Handle the '/lock/dumpLocksData' REST call.
+ */
+ @GET
+ @Path("/lock/dumpLocksData")
+ @ApiOperation(
+ value = "Perform an incoming /cmd/dumpLocksData request",
+ notes = "Generate a byte stream containing serialized 'HostData'"
+ )
+ @Produces(MediaType.APPLICATION_OCTET_STREAM)
+ public String dumpLocksData(@QueryParam("server") UUID server,
+ @QueryParam("ttl") int ttl) throws IOException {
+ return new String(TargetLock.DumpLocks.dumpLocksData(server, ttl), StandardCharsets.UTF_8);
+ }
+}
diff --git a/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Server.java b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Server.java
new file mode 100644
index 00000000..52e3d2dc
--- /dev/null
+++ b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Server.java
@@ -0,0 +1,1352 @@
+/*
+ * ============LICENSE_START=======================================================
+ * feature-server-pool
+ * ================================================================================
+ * Copyright (C) 2020 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=========================================================
+ */
+
+package org.onap.policy.drools.serverpool;
+
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_HTTPS;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_SELF_SIGNED_CERTIFICATES;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_SERVER_ADAPTIVE_GAP_ADJUST;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_SERVER_CONNECT_TIMEOUT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_SERVER_INITIAL_ALLOWED_GAP;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_SERVER_IP_ADDRESS;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_SERVER_PORT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_SERVER_READ_TIMEOUT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_SERVER_THREADS_CORE_POOL_SIZE;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_SERVER_THREADS_KEEP_ALIVE_TIME;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_SERVER_THREADS_MAXIMUM_POOL_SIZE;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.HOST_LIST;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.SERVER_ADAPTIVE_GAP_ADJUST;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.SERVER_CONNECT_TIMEOUT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.SERVER_HTTPS;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.SERVER_INITIAL_ALLOWED_GAP;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.SERVER_IP_ADDRESS;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.SERVER_PORT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.SERVER_READ_TIMEOUT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.SERVER_SELF_SIGNED_CERTIFICATES;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.SERVER_THREADS_CORE_POOL_SIZE;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.SERVER_THREADS_KEEP_ALIVE_TIME;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.SERVER_THREADS_MAXIMUM_POOL_SIZE;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.SITE_IP_ADDRESS;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.SITE_PORT;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.getProperty;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.io.StringReader;
+import java.lang.reflect.Field;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.UnknownHostException;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyManagementException;
+import java.security.NoSuchAlgorithmException;
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.Objects;
+import java.util.Properties;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.UUID;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.LinkedTransferQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import javax.servlet.ServletException;
+import javax.ws.rs.ProcessingException;
+import javax.ws.rs.client.Client;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+
+import org.eclipse.jetty.server.ServerConnector;
+import org.glassfish.jersey.client.ClientProperties;
+import org.onap.policy.common.endpoints.event.comm.bus.internal.BusTopicParams;
+import org.onap.policy.common.endpoints.http.client.HttpClient;
+import org.onap.policy.common.endpoints.http.client.HttpClientConfigException;
+import org.onap.policy.common.endpoints.http.client.HttpClientFactoryInstance;
+import org.onap.policy.common.endpoints.http.server.HttpServletServer;
+import org.onap.policy.common.endpoints.http.server.HttpServletServerFactoryInstance;
+import org.onap.policy.drools.system.PolicyEngineConstants;
+import org.onap.policy.drools.utils.PropertyUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class Server implements Comparable<Server> {
+ private static Logger logger = LoggerFactory.getLogger(Server.class);
+
+ // maps UUID to Server object for all known servers
+ private static TreeMap<UUID, Server> servers =
+ new TreeMap<>(Util.uuidComparator);
+
+ // maps UUID to Server object for all failed servers
+ // (so they aren't accidentally restored, due to updates from other hosts)
+ private static TreeMap<UUID, Server> failedServers =
+ new TreeMap<>(Util.uuidComparator);
+
+ // subset of servers to be notified (null means it needs to be rebuilt)
+ private static LinkedList<Server> notifyList = null;
+
+ // data to be sent out to notify list
+ private static TreeSet<Server> updatedList = new TreeSet<>();
+
+ // the server associated with the current host
+ private static Server thisServer = null;
+
+ // the current REST server
+ private static HttpServletServer restServer;
+
+ // incoming packets from HTTP
+ private static LinkedTransferQueue<byte[]> incomingPackets =
+ new LinkedTransferQueue<>();
+
+ /*==================================================*/
+ /* Some properties extracted at initialization time */
+ /*==================================================*/
+
+ // initial value of gap to allow between pings
+ private static long initialAllowedGap;
+
+ // used in adaptive calculation of allowed gap between pings
+ private static long adaptiveGapAdjust;
+
+ // time to allow for TCP connect (long)
+ private static String connectTimeout;
+
+ // time to allow before TCP read timeout (long)
+ private static String readTimeout;
+
+ // outgoing per-server thread pool parameters
+ private static int corePoolSize;
+ private static int maximumPoolSize;
+ private static long keepAliveTime;
+
+ // https-related parameters
+ private static boolean useHttps;
+ private static boolean useSelfSignedCertificates;
+
+ // list of remote host names
+ private static String[] hostList = new String[0];
+
+ /*=========================================================*/
+ /* Fields included in every 'ping' message between servers */
+ /*=========================================================*/
+
+ // unique id for this server
+ private UUID uuid;
+
+ // counter periodically incremented to indicate the server is "alive"
+ private int count;
+
+ // 16 byte MD5 checksum over additional data that is NOT included in
+ // every 'ping' message -- used to determine whether the data is up-to-date
+ private byte[] checksum;
+
+ /*========================================================================*/
+ /* The following data is included in the checksum, and doesn't change too */
+ /* frequently (some fields may change as servers go up and down) */
+ /*========================================================================*/
+
+ // IP address and port of listener
+ private InetSocketAddress socketAddress;
+
+ // site IP address and port
+ private InetSocketAddress siteSocketAddress = null;
+
+ /*============================================*/
+ /* Local information not included in checksum */
+ /*============================================*/
+
+ // destination socket information
+ private InetSocketAddress destSocketAddress;
+ private String destName;
+
+ // REST client fields
+ private HttpClient client;
+ private WebTarget target;
+ private ThreadPoolExecutor sendThreadPool = null;
+
+ // time when the 'count' field was last updated
+ private long lastUpdateTime;
+
+ // calculated field indicating the maximum time between updates
+ private long allowedGap = initialAllowedGap;
+
+ // indicates whether the 'Server' instance is active or not (synchronized)
+ private boolean active = true;
+
+ /*
+ * Tags for encoding of server data
+ */
+ static final int END_OF_PARAMETERS_TAG = 0;
+ static final int SOCKET_ADDRESS_TAG = 1;
+ static final int SITE_SOCKET_ADDRESS_TAG = 2;
+
+ /*==============================*/
+ /* Comparable<Server> interface */
+ /*==============================*/
+
+ /**
+ * Compare this instance to another one by comparing the 'uuid' field.
+ */
+ @Override
+ public int compareTo(Server other) {
+ return Util.uuidComparator.compare(uuid, other.uuid);
+ }
+
+ /**
+ * This method may be invoked from any thread, and is used as the main
+ * entry point when testing.
+ *
+ * @param args arguments contaning an '=' character are intepreted as
+ * a property, other arguments are presumed to be a property file.
+ */
+ public static void main(String[] args) throws IOException {
+ Properties prop = new Properties();
+
+ for (String arg : args) {
+ // arguments with an '=' in them are a property definition;
+ // otherwise, they are a properties file name
+
+ if (arg.contains("=")) {
+ prop.load(new StringReader(arg));
+ } else {
+ prop.putAll(PropertyUtil.getProperties(arg));
+ }
+ }
+
+ String rval = startup(prop);
+ if (rval != null) {
+ logger.error("Server.startup failed: {}", rval);
+ }
+ }
+
+ /**
+ * This method may be invoked from any thread, and performs initialization.
+ *
+ * @param propertiesFile the name of a property file
+ */
+ public static String startup(String propertiesFile) {
+ Properties properties;
+ try {
+ properties = PropertyUtil.getProperties(propertiesFile);
+ } catch (IOException e) {
+ logger.error("Server.startup: exception reading properties", e);
+ properties = new Properties();
+ }
+ return startup(properties);
+ }
+
+ /**
+ * This method may be invoked from any thread, and performs initialization.
+ *
+ * @param properties contains properties used by the server
+ */
+ public static String startup(Properties properties) {
+ ServerPoolProperties.setProperties(properties);
+ logger.info("startup: properties={}", properties);
+
+ // fetch some static properties
+ initialAllowedGap = getProperty(SERVER_INITIAL_ALLOWED_GAP,
+ DEFAULT_SERVER_INITIAL_ALLOWED_GAP);
+ adaptiveGapAdjust = getProperty(SERVER_ADAPTIVE_GAP_ADJUST,
+ DEFAULT_SERVER_ADAPTIVE_GAP_ADJUST);
+ connectTimeout =
+ String.valueOf(getProperty(SERVER_CONNECT_TIMEOUT,
+ DEFAULT_SERVER_CONNECT_TIMEOUT));
+ readTimeout = String.valueOf(getProperty(SERVER_READ_TIMEOUT,
+ DEFAULT_SERVER_READ_TIMEOUT));
+ corePoolSize = getProperty(SERVER_THREADS_CORE_POOL_SIZE,
+ DEFAULT_SERVER_THREADS_CORE_POOL_SIZE);
+ maximumPoolSize = getProperty(SERVER_THREADS_MAXIMUM_POOL_SIZE,
+ DEFAULT_SERVER_THREADS_MAXIMUM_POOL_SIZE);
+ keepAliveTime = getProperty(SERVER_THREADS_KEEP_ALIVE_TIME,
+ DEFAULT_SERVER_THREADS_KEEP_ALIVE_TIME);
+ useHttps = getProperty(SERVER_HTTPS, DEFAULT_HTTPS);
+ useSelfSignedCertificates = getProperty(SERVER_SELF_SIGNED_CERTIFICATES,
+ DEFAULT_SELF_SIGNED_CERTIFICATES);
+ String hostListNames = getProperty(HOST_LIST, null);
+ if (hostListNames != null) {
+ hostList = hostListNames.split(",");
+ }
+
+ String possibleError = null;
+ try {
+ // fetch server information
+ String ipAddressString =
+ getProperty(SERVER_IP_ADDRESS, DEFAULT_SERVER_IP_ADDRESS);
+ int port = getProperty(SERVER_PORT, DEFAULT_SERVER_PORT);
+
+ possibleError = "Unknown Host: " + ipAddressString;
+ InetAddress address = InetAddress.getByName(ipAddressString);
+ InetSocketAddress socketAddress = new InetSocketAddress(address, port);
+
+ restServer = HttpServletServerFactoryInstance.getServerFactory().build(
+ "SERVER-POOL", // name
+ useHttps, // https
+ socketAddress.getAddress().getHostAddress(),// host (maybe 0.0.0.0)
+ port, // port (can no longer be 0)
+ null, // contextPath
+ false, // swagger
+ false); // managed
+ restServer.addServletClass(null, RestServerPool.class.getName());
+
+ // add any additional servlets
+ for (ServerPoolApi feature : ServerPoolApi.impl.getList()) {
+ Collection<Class<?>> classes = feature.servletClasses();
+ if (classes != null) {
+ for (Class<?> clazz : classes) {
+ restServer.addServletClass(null, clazz.getName());
+ }
+ }
+ }
+
+ // we may not know the port until after the server is started
+ restServer.start();
+
+ // determine the address to use
+ if (DEFAULT_SERVER_IP_ADDRESS.contentEquals(address.getHostAddress())) {
+ address = InetAddress.getLocalHost();
+ }
+
+ thisServer = new Server(new InetSocketAddress(address, port));
+
+ // TBD: is this really appropriate?
+ thisServer.newServer();
+
+ // start background thread
+ MainLoop.startThread();
+ MainLoop.queueWork(new Runnable() {
+ @Override
+ public void run() {
+ // run this in the 'MainLoop' thread
+ Leader.startup();
+ Bucket.startup();
+ }
+ });
+ logger.info("Listening on port {}", port);
+
+ return null;
+ } catch (UnknownHostException e) {
+ logger.error("Server.startup: exception start server", e);
+ if (possibleError == null) {
+ possibleError = e.toString();
+ }
+ return possibleError;
+ }
+ }
+
+ /**
+ * Shut down all threads associate with server pool.
+ */
+ public static void shutdown() {
+ Discovery.stopDiscovery();
+ MainLoop.stopThread();
+ TargetLock.shutdown();
+ Util.shutdown();
+
+ HashSet<Server> allServers = new HashSet<>();
+ allServers.addAll(servers.values());
+ allServers.addAll(failedServers.values());
+
+ for (Server server : allServers) {
+ if (server.sendThreadPool != null) {
+ server.sendThreadPool.shutdown();
+ }
+ }
+ if (restServer != null) {
+ restServer.shutdown();
+ }
+ }
+
+ /**
+ * Return the Server instance associated with the current host.
+ *
+ * @return the Server instance associated with the current host
+ */
+ public static Server getThisServer() {
+ return thisServer;
+ }
+
+ /**
+ * Return the first Server instance in the 'servers' list.
+ *
+ * @return the first Server instance in the 'servers' list
+ * (the one with the lowest UUID)
+ */
+ public static Server getFirstServer() {
+ return servers.firstEntry().getValue();
+ }
+
+ /**
+ * Lookup a Server instance associated with a UUID.
+ *
+ * @param uuid the key to the lookup
+ @ @return the associated 'Server' instance, or 'null' if none
+ */
+ public static Server getServer(UUID uuid) {
+ return servers.get(uuid);
+ }
+
+ /**
+ * Return a count of the number of servers.
+ *
+ * @return a count of the number of servers
+ */
+ public static int getServerCount() {
+ return servers.size();
+ }
+
+ /**
+ * Return the complete list of servers.
+ *
+ * @return the complete list of servers
+ */
+ public static Collection<Server> getServers() {
+ return servers.values();
+ }
+
+ /**
+ * This method is invoked from the 'startup' thread, and creates a new
+ * 'Server' instance for the current server.
+ *
+ * @param socketAddress the IP address and port the listener is bound to
+ */
+ private Server(InetSocketAddress socketAddress) {
+ this.uuid = UUID.randomUUID();
+ this.count = 1;
+ this.socketAddress = socketAddress;
+ this.lastUpdateTime = System.currentTimeMillis();
+
+ // site information
+
+ String siteIp = getProperty(SITE_IP_ADDRESS, null);
+ int sitePort = getProperty(SITE_PORT, 0);
+ if (siteIp != null && sitePort != 0) {
+ // we do have site information specified
+ try {
+ siteSocketAddress = new InetSocketAddress(siteIp, sitePort);
+ if (siteSocketAddress.getAddress() == null) {
+ logger.error("Couldn't resolve site address: {}", siteIp);
+ siteSocketAddress = null;
+ }
+ } catch (IllegalArgumentException e) {
+ logger.error("Illegal 'siteSocketAddress'", e);
+ siteSocketAddress = null;
+ }
+ }
+
+ // TBD: calculate checksum
+ }
+
+ /**
+ * Initialize a 'Server' instance from a 'DataInputStream'. If it is new,
+ * it may get inserted in the table. If it is an update, fields in an
+ * existing 'Server' may be updated.
+ *
+ * @param is the 'DataInputStream'
+ */
+ Server(DataInputStream is) throws IOException {
+ // read in 16 byte UUID
+ uuid = Util.readUuid(is);
+
+ // read in 4 byte counter value
+ count = is.readInt();
+
+ // read in 16 byte MD5 checksum
+ checksum = new byte[16];
+ is.readFully(checksum);
+
+ // optional parameters
+ int tag;
+ while ((tag = is.readUnsignedByte()) != END_OF_PARAMETERS_TAG) {
+ switch (tag) {
+ case SOCKET_ADDRESS_TAG: {
+ socketAddress = readSocketAddress(is);
+ break;
+ }
+ case SITE_SOCKET_ADDRESS_TAG: {
+ siteSocketAddress = readSocketAddress(is);
+ break;
+ }
+ default:
+ // ignore tag
+ logger.error("Illegal tag: {}", tag);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Read an 'InetSocketAddress' from a 'DataInputStream'.
+ *
+ * @param is the 'DataInputStream'
+ * @return the 'InetSocketAddress'
+ */
+ private static InetSocketAddress readSocketAddress(DataInputStream is)
+ throws IOException, UnknownHostException {
+
+ byte[] ipAddress = new byte[4];
+ is.read(ipAddress, 0, 4);
+ int port = is.readUnsignedShort();
+ return new InetSocketAddress(InetAddress.getByAddress(ipAddress), port);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String toString() {
+ return "Server[" + uuid + "]";
+ }
+
+ /**
+ * Return the UUID associated with this Server.
+ *
+ * @return the UUID associated with this Server
+ */
+ public UUID getUuid() {
+ return uuid;
+ }
+
+ /**
+ * Return the external InetSocketAddress of the site.
+ *
+ * @return the external InetSocketAddress of the site
+ * ('null' if it doesn't exist)
+ */
+ public InetSocketAddress getSiteSocketAddress() {
+ return siteSocketAddress;
+ }
+
+ /**
+ * This method may be called from any thread.
+ *
+ * @return 'true' if the this server is active, and 'false' if not
+ */
+ public synchronized boolean isActive() {
+ return active;
+ }
+
+ /**
+ * This method writes out the data associated with the current Server
+ * instance.
+ *
+ * @param os outout stream that should receive the data
+ */
+ void writeServerData(DataOutputStream os) throws IOException {
+ // write out 16 byte UUID
+ Util.writeUuid(os, uuid);
+
+ // write out 4 byte counter value
+ os.writeInt(count);
+
+ // write out 16 byte MD5 checksum
+ // TBD: should this be implemented?
+ os.write(checksum == null ? new byte[16] : checksum);
+
+ if (socketAddress != null) {
+ // write out socket address
+ os.writeByte(SOCKET_ADDRESS_TAG);
+ os.write(socketAddress.getAddress().getAddress(), 0, 4);
+ os.writeShort(socketAddress.getPort());
+ }
+
+ if (siteSocketAddress != null) {
+ // write out socket address
+ os.writeByte(SITE_SOCKET_ADDRESS_TAG);
+ os.write(siteSocketAddress.getAddress().getAddress(), 0, 4);
+ os.writeShort(siteSocketAddress.getPort());
+ }
+
+ os.writeByte(END_OF_PARAMETERS_TAG);
+ }
+
+ /**
+ * Do any processing needed to create a new server. This method is invoked
+ * from the 'MainLoop' thread in every case except for the current server,
+ * in which case it is invoked in 'startup' prior to creating 'MainLoop'.
+ */
+ private void newServer() {
+ Server failed = failedServers.get(uuid);
+ if (failed != null) {
+ // this one is on the failed list -- see if the counter has advanced
+ if ((count - failed.count) <= 0) {
+ // the counter has not advanced -- ignore
+ return;
+ }
+
+ // the counter has advanced -- somehow, this server has returned
+ failedServers.remove(uuid);
+ synchronized (this) {
+ active = true;
+ }
+ logger.error("Server reawakened: {} ({})", uuid, socketAddress);
+ }
+
+ lastUpdateTime = System.currentTimeMillis();
+ servers.put(uuid, this);
+ updatedList.add(this);
+
+ // notify list will need to be rebuilt
+ notifyList = null;
+
+ if (socketAddress != null && this != thisServer) {
+ // initialize 'client' and 'target' fields
+ if (siteSocketAddress != null
+ && !siteSocketAddress.equals(thisServer.siteSocketAddress)) {
+ // destination is on a remote site
+ destSocketAddress = siteSocketAddress;
+ } else {
+ // destination is on the local site -- use direct addressing
+ destSocketAddress = socketAddress;
+ }
+ destName = socketAddressToName(destSocketAddress);
+ try {
+ // 'client' is used for REST messages to the destination
+ client = buildClient(uuid.toString(), destSocketAddress, destName);
+
+ // initialize the 'target' field
+ target = getTarget(client);
+ } catch (KeyManagementException | NoSuchAlgorithmException
+ | NoSuchFieldException | IllegalAccessException
+ | ClassNotFoundException | HttpClientConfigException e) {
+ logger.error("Server.newServer: problems creating 'client'", e);
+ }
+ }
+ logger.info("New server: {} ({})", uuid, socketAddress);
+ for (Events listener : Events.getListeners()) {
+ listener.newServer(this);
+ }
+ }
+
+ /**
+ * Check the server state in response to some issue. At present, only the
+ * 'destName' information is checked.
+ */
+ private void checkServer() {
+ // recalculate 'destName' (we have seen DNS issues)
+ String newDestName = socketAddressToName(destSocketAddress);
+ if (newDestName.equals(destName)) {
+ return;
+ }
+ logger.warn("Remote host name for {} has changed from {} to {}",
+ destSocketAddress, destName, newDestName);
+
+ // shut down old client, and rebuild
+ client.shutdown();
+ client = null;
+ target = null;
+
+ // update 'destName', and rebuild the client
+ destName = newDestName;
+ try {
+ // 'client' is used for REST messages to the destination
+ client = buildClient(uuid.toString(), destSocketAddress, destName);
+
+ // initialize the 'target' field
+ target = getTarget(client);
+ } catch (KeyManagementException | NoSuchAlgorithmException
+ | NoSuchFieldException | IllegalAccessException
+ | ClassNotFoundException | HttpClientConfigException e) {
+ logger.error("Server.checkServer: problems recreating 'client'", e);
+ }
+ }
+
+ /**
+ * Update server data.
+ *
+ * @param serverData this is a temporary 'Server' instance created from
+ * an incoming message, which is used to update fields within the
+ * 'Server' instance identified by 'this'
+ */
+ private void updateServer(Server serverData) {
+ if (serverData.count > count) {
+ // an update has occurred
+ count = serverData.count;
+
+ // TBD: calculate and verify checksum, more fields may be updated
+
+ // adjust 'allowedGap' accordingly
+ long currentTime = System.currentTimeMillis();
+ long gap = currentTime - lastUpdateTime;
+
+ // adjust 'allowedGap' accordingly
+ // TBD: need properties to support overrides
+ gap = gap * 3 / 2 + adaptiveGapAdjust;
+ if (gap > allowedGap) {
+ // update 'allowedGap' immediately
+ allowedGap = gap;
+ } else {
+ // gradually pull the allowed gap down
+ // TBD: need properties to support overrides
+ allowedGap = (allowedGap * 15 + gap) / 16;
+ }
+ lastUpdateTime = currentTime;
+
+ updatedList.add(this);
+ }
+ }
+
+ /**
+ * a server has failed.
+ */
+ private void serverFailed() {
+ // mark as inactive
+ synchronized (this) {
+ active = false;
+ }
+
+ // remove it from the table
+ servers.remove(uuid);
+
+ // add it to the failed servers table
+ failedServers.put(uuid, this);
+
+ // clean up client information
+ if (client != null) {
+ client.shutdown();
+ client = null;
+ target = null;
+ }
+
+ // log an error message
+ logger.error("Server failure: {} ({})", uuid, socketAddress);
+ for (Events listener : Events.getListeners()) {
+ listener.serverFailed(this);
+ }
+ }
+
+ /**
+ * Fetch, and possibily calculate, the "notify list" associated with this
+ * server. This is the list of servers to forward a server and bucket
+ * information to, and is approximately log2(n) in length, where 'n' is
+ * the total number of servers.
+ * It is calculated by starting with all of the servers sorted by UUID --
+ * let's say the current server is at position 's'. The notify list will
+ * contain the server at positions:
+ * (s + 1) % n
+ * (s + 2) % n
+ * (s + 4) % n
+ * ...
+ * Using all powers of 2 less than 'n'. If the total server count is 50,
+ * this list has 6 entries.
+ * @return the notify list
+ */
+ static Collection<Server> getNotifyList() {
+ // The 'notifyList' value is initially 'null', and it is reset to 'null'
+ // every time a new host joins, or one leaves. That way, it is marked for
+ // recalculation, but only when needed.
+ if (notifyList == null) {
+ // next index we are looking for
+ int dest = 1;
+
+ // our current position in the Server table -- starting at 'thisServer'
+ UUID current = thisServer.uuid;
+
+ // site socket address of 'current'
+ InetSocketAddress thisSiteSocketAddress = thisServer.siteSocketAddress;
+
+ // hash set of all site socket addresses located
+ HashSet<InetSocketAddress> siteSocketAddresses = new HashSet<>();
+ siteSocketAddresses.add(thisSiteSocketAddress);
+
+ // the list we are building
+ notifyList = new LinkedList<Server>();
+
+ int index = 1;
+ for ( ; ; ) {
+ // move to the next key (UUID) -- if we hit the end of the table,
+ // wrap to the beginning
+ current = servers.higherKey(current);
+ if (current == null) {
+ current = servers.firstKey();
+ }
+ if (current.equals(thisServer.uuid)) {
+ // we have looped through the entire list
+ break;
+ }
+
+ // fetch associated server & site socket address
+ Server server = servers.get(current);
+ InetSocketAddress currentSiteSocketAddress =
+ server.siteSocketAddress;
+
+ if (Objects.equals(thisSiteSocketAddress,
+ currentSiteSocketAddress)) {
+ // same site -- see if we should add this one
+ if (index == dest) {
+ // this is the next index we are looking for --
+ // add the server
+ notifyList.add(server);
+
+ // advance to the next offset (current-offset * 2)
+ dest = dest << 1;
+ }
+ index += 1;
+ } else if (!siteSocketAddresses.contains(currentSiteSocketAddress)) {
+ // we need at least one member from each site
+ notifyList.add(server);
+ siteSocketAddresses.add(currentSiteSocketAddress);
+ }
+ }
+ }
+ return notifyList;
+ }
+
+ /**
+ * See if there is a host name associated with a destination socket address.
+ *
+ * @param dest the socket address of the destination
+ * @return the host name associated with the IP address, or the IP address
+ * if no associated host name is found.
+ */
+ private static String socketAddressToName(InetSocketAddress dest) {
+ // destination IP address
+ InetAddress inetAddress = dest.getAddress();
+ String destName = null;
+
+ // go through the 'hostList' to see if there is a matching name
+ for (String hostName : hostList) {
+ try {
+ if (inetAddress.equals(InetAddress.getByName(hostName))) {
+ // this one matches -- use the name instead of the IP address
+ destName = hostName;
+ break;
+ }
+ } catch (UnknownHostException e) {
+ logger.debug("Server.socketAddressToName error", e);
+ }
+ }
+
+ // default name = string value of IP address
+ return destName == null ? inetAddress.getHostAddress() : destName;
+ }
+
+ /**
+ * Create an 'HttpClient' instance for a particular host.
+ *
+ * @param name of the host (currently a UUID or host:port string)
+ * @param dest the socket address of the destination
+ * @param destName the string name to use for the destination
+ */
+ static HttpClient buildClient(String name, InetSocketAddress dest, String destName)
+ throws KeyManagementException, NoSuchAlgorithmException,
+ ClassNotFoundException, HttpClientConfigException {
+
+ return HttpClientFactoryInstance.getClientFactory().build(
+ BusTopicParams.builder()
+ .clientName(name) // name
+ .useHttps(useHttps) // https
+ .allowSelfSignedCerts(useSelfSignedCertificates)// selfSignedCerts
+ .hostname(destName) // host
+ .port(dest.getPort()) // port
+ .managed(false) // managed
+ .build());
+ }
+
+ /**
+ * Extract the 'WebTarget' information from the 'HttpClient'.
+ *
+ * @param client the associated HttpClient instance
+ * @return a WebTarget referring to the previously-specified 'baseUrl'
+ */
+ static WebTarget getTarget(HttpClient client)
+ throws NoSuchFieldException, IllegalAccessException {
+ // need access to the internal field 'client'
+ // TBD: We need a way to get this information without reflection
+ Field field = client.getClass().getDeclaredField("client");
+ field.setAccessible(true);
+ Client rsClient = (Client)field.get(client);
+ field.setAccessible(false);
+
+ rsClient.property(ClientProperties.CONNECT_TIMEOUT, connectTimeout);
+ rsClient.property(ClientProperties.READ_TIMEOUT, readTimeout);
+
+ // For performance reasons, the root 'WebTarget' is generated only once
+ // at initialization time for each remote host.
+ return rsClient.target(client.getBaseUrl());
+ }
+
+ /**
+ * This method may be invoked from any thread, and is used to send a
+ * message to the destination server associated with this 'Server' instance.
+ *
+ * @param path the path relative to the base URL
+ * @param entity the "request entity" containing the body of the
+ * HTTP POST request
+ */
+ public void post(final String path, final Entity<?> entity) {
+ post(path, entity, null);
+ }
+
+ /**
+ * This method may be invoked from any thread, and is used to send a
+ * message to the destination server associated with this 'Server' instance.
+ *
+ * @param path the path relative to the base URL
+ * @param entity the "request entity" containing the body of the
+ * HTTP POST request (if 'null', an HTTP GET is used instead)
+ * @param responseCallback if non-null, this callback may be used to
+ * modify the WebTarget, and/or receive the POST response message
+ */
+ public void post(final String path, final Entity<?> entity,
+ PostResponse responseCallback) {
+ if (target == null) {
+ return;
+ }
+
+ getThreadPool().execute(new Runnable() {
+ /**
+ * This method is running within the 'MainLoop' thread.
+ */
+ @Override
+ public void run() {
+ try {
+ WebTarget webTarget = target.path(path);
+ if (responseCallback != null) {
+ // give callback a chance to modify 'WebTarget'
+ webTarget = responseCallback.webTarget(webTarget);
+
+ // send the response to the callback
+ Response response;
+ if (entity == null) {
+ response = webTarget.request().get();
+ } else {
+ response = webTarget.request().post(entity);
+ }
+ responseCallback.response(response);
+ } else {
+ // just do the invoke, and ignore the response
+ if (entity == null) {
+ webTarget.request().get();
+ } else {
+ webTarget.request().post(entity);
+ }
+ }
+ } catch (Exception e) {
+ logger.error("Failed to send to {} ({}, {})",
+ uuid, destSocketAddress, destName);
+ responseCallback.exceptionResponse(e);
+ MainLoop.queueWork(new Runnable() {
+ @Override
+ public void run() {
+ // the DNS cache may have been out-of-date when this server
+ // was first contacted -- fix the problem, if needed
+ checkServer();
+ }
+ });
+ }
+ }
+ });
+ }
+
+ /**
+ * This method may be invoked from any thread.
+ *
+ * @return the 'ThreadPoolExecutor' associated with this server
+ */
+ public synchronized ThreadPoolExecutor getThreadPool() {
+ if (sendThreadPool == null) {
+ // build a thread pool for this Server
+ sendThreadPool =
+ new ThreadPoolExecutor(corePoolSize, maximumPoolSize,
+ keepAliveTime, TimeUnit.MILLISECONDS,
+ new LinkedTransferQueue<Runnable>());
+ sendThreadPool.allowCoreThreadTimeOut(true);
+ }
+ return sendThreadPool;
+ }
+
+ /**
+ * Lower-level method supporting HTTP, which requires that the caller's
+ * thread tolerate blocking. This method may be called from any thread.
+ *
+ * @param path the path relative to the base URL
+ * @return a 'WebTarget' instance pointing to this path
+ */
+ public WebTarget getWebTarget(String path) {
+ return target == null ? null : target.path(path);
+ }
+
+ /**
+ * This method may be invoked from any thread, but its real intent is
+ * to decode an incoming 'admin' message (which is Base-64-encoded),
+ * and send it to the 'MainLoop' thread for processing.
+ *
+ * @param data the base-64-encoded data
+ */
+ static void adminRequest(byte[] data) {
+ final byte[] packet = Base64.getDecoder().decode(data);
+ Runnable task = () -> {
+ try {
+ ByteArrayInputStream bis = new ByteArrayInputStream(packet);
+ DataInputStream dis = new DataInputStream(bis);
+
+ while (dis.available() != 0) {
+ Server serverData = new Server(dis);
+
+ // TBD: Compare with current server
+
+ Server server = servers.get(serverData.uuid);
+ if (server == null) {
+ serverData.newServer();
+ } else {
+ server.updateServer(serverData);
+ }
+ }
+ } catch (Exception e) {
+ logger.error("Server.adminRequest: can't decode packet", e);
+ }
+ };
+ MainLoop.queueWork(task);
+ }
+
+ /**
+ * Send out information about servers 'updatedList' to all servers
+ * in 'notifyList' (may need to build or rebuild 'notifyList').
+ */
+ static void sendOutData() throws IOException {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ DataOutputStream dos = new DataOutputStream(bos);
+
+ // include 'thisServer' in the data -- first, advance the count
+ if ((thisServer.count += 1) == 0) {
+ /*
+ * counter wrapped (0 is a special case);
+ * actually, we could probably leave this out, because it would take
+ * more than a century to wrap if the increment is 1 second
+ */
+ thisServer.count = 1;
+ }
+ thisServer.lastUpdateTime = System.currentTimeMillis();
+ thisServer.writeServerData(dos);
+
+ // include all hosts in the updated list
+ for (Server server : updatedList) {
+ server.writeServerData(dos);
+ }
+ updatedList.clear();
+
+ // create an 'Entity' that can be sent out to all hosts in the notify list
+ Entity<String> entity = Entity.entity(
+ new String(Base64.getEncoder().encode(bos.toByteArray()), StandardCharsets.UTF_8),
+ MediaType.APPLICATION_OCTET_STREAM_TYPE);
+ for (Server server : getNotifyList()) {
+ server.post("admin", entity);
+ }
+ }
+
+ /**
+ * Search for servers which have taken too long to respond.
+ */
+ static void searchForFailedServers() {
+ long currentTime = System.currentTimeMillis();
+
+ // used to build a list of newly-failed servers
+ LinkedList<Server> failed = new LinkedList<>();
+ for (Server server : servers.values()) {
+ if (server == thisServer) {
+ continue;
+ }
+ long gap = currentTime - server.lastUpdateTime;
+ if (gap > server.allowedGap) {
+ // add it to the failed list -- we don't call 'serverFailed' yet,
+ // because this updates the server list, and leads to a
+ // 'ConcurrentModificationException'
+ failed.add(server);
+ }
+ }
+
+ // remove servers from our list
+ if (!failed.isEmpty()) {
+ for (Server server : failed) {
+ server.serverFailed();
+ }
+ notifyList = null;
+ }
+ }
+
+ /**
+ * This method may be invoked from any thread:
+ * Send information about 'thisServer' to the list of hosts.
+ *
+ * @param out the 'PrintStream' to use for displaying information
+ * @param hosts a comma-separated list of entries containing
+ * 'host:port' or just 'port' (current host is implied in this case)
+ */
+ static void pingHosts(PrintStream out, String hosts) {
+ LinkedList<InetSocketAddress> addresses = new LinkedList<>();
+ boolean error = false;
+
+ for (String host : hosts.split(",")) {
+ try {
+ String[] segs = host.split(":");
+
+ switch (segs.length) {
+ case 1:
+ addresses.add(new InetSocketAddress(InetAddress.getLocalHost(),
+ Integer.parseInt(segs[0])));
+ break;
+ case 2:
+ addresses.add(new InetSocketAddress(segs[0],
+ Integer.parseInt(segs[1])));
+ break;
+ default:
+ out.println(host + ": Invalid host/port value");
+ error = true;
+ break;
+ }
+ } catch (NumberFormatException e) {
+ out.println(host + ": Invalid port value");
+ logger.error("Server.pingHosts error", e);
+ error = true;
+ } catch (UnknownHostException e) {
+ out.println(host + ": Unknown host");
+ logger.error("Server.pingHosts error", e);
+ error = true;
+ }
+ }
+ if (!error) {
+ pingHosts(out, addresses);
+ }
+ }
+
+ /**
+ * This method may be invoked from any thread:
+ * Send information about 'thisServer' to the list of hosts.
+ *
+ * @param out the 'PrintStream' to use for displaying information
+ * @param hosts a collection of 'InetSocketAddress' instances, which are
+ * the hosts to send the information to
+ */
+ static void pingHosts(final PrintStream out,
+ final Collection<InetSocketAddress> hosts) {
+ FutureTask<Integer> ft = new FutureTask<>(new Callable<Integer>() {
+ @Override
+ public Integer call() {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ DataOutputStream dos = new DataOutputStream(bos);
+
+ // add information for this server only
+ try {
+ thisServer.writeServerData(dos);
+
+ // create an 'Entity' that can be sent out to all hosts
+ Entity<String> entity = Entity.entity(
+ new String(Base64.getEncoder().encode(bos.toByteArray()),
+ StandardCharsets.UTF_8),
+ MediaType.APPLICATION_OCTET_STREAM_TYPE);
+
+ // loop through hosts
+ for (InetSocketAddress host : hosts) {
+ HttpClient client = null;
+
+ try {
+ client = buildClient(host.toString(), host,
+ socketAddressToName(host));
+ getTarget(client).path("admin").request().post(entity);
+ client.shutdown();
+ client = null;
+ } catch (KeyManagementException | NoSuchAlgorithmException e) {
+ out.println(host + ": Unable to create client connection");
+ logger.error("Server.pingHosts error", e);
+ } catch (NoSuchFieldException | IllegalAccessException e) {
+ out.println(host + ": Unable to get link to target");
+ logger.error("Server.pingHosts error", e);
+ } catch (Exception e) {
+ out.println(host + ": " + e);
+ logger.error("Server.pingHosts error", e);
+ }
+ if (client != null) {
+ client.shutdown();
+ }
+ }
+ } catch (IOException e) {
+ out.println("Unable to generate 'ping' data: " + e);
+ logger.error("Server.pingHosts error", e);
+ }
+ return 0;
+ }
+ });
+
+ MainLoop.queueWork(ft);
+ try {
+ ft.get(60, TimeUnit.SECONDS);
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ logger.error("Server.pingHosts: error waiting for queued work", e);
+ }
+ }
+
+ /**
+ * This method may be invoked from any thread:
+ * Dump out the current 'servers' table in a human-readable table form.
+ *
+ * @param out the 'PrintStream' to dump the table to
+ */
+ public static void dumpHosts(final PrintStream out) {
+ FutureTask<Integer> ft = new FutureTask<Integer>(new Callable<Integer>() {
+ public Integer call() {
+ dumpHostsInternal(out);
+ return 0;
+ }
+ });
+ MainLoop.queueWork(ft);
+ try {
+ ft.get(60, TimeUnit.SECONDS);
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ logger.error("Server.dumpHosts: error waiting for queued work", e);
+ }
+ }
+
+ /**
+ * Dump out the current 'servers' table in a human-readable table form.
+ *
+ * @param out the 'PrintStream' to dump the table to
+ */
+ private static void dumpHostsInternal(PrintStream out) {
+ // modifications to 'servers.values()' and 'notifyList'.
+ HashSet<Server> localNotifyList = new HashSet<>(getNotifyList());
+
+ // see if we have any site information
+ boolean siteData = false;
+ for (Server server : servers.values()) {
+ if (server.siteSocketAddress != null) {
+ siteData = true;
+ break;
+ }
+ }
+
+ String format = "%1s %-36s %-15s %5s %5s %12s %7s %7s\n";
+ SimpleDateFormat dateFormat = new SimpleDateFormat("kk:mm:ss.SSS");
+
+ if (siteData) {
+ format = "%1s %-36s %-15s %5s %-15s %5s %5s %12s %7s %7s\n";
+ // @formatter:off
+ out.printf(format, "", "UUID", "IP Address", "Port",
+ "Site IP Address", "Port",
+ "Count", "Update Time", "Elapsed", "Allowed");
+ out.printf(format, "", "----", "----------", "----",
+ "---------------", "----",
+ "-----", "-----------", "-------", "-------");
+ // @formatter:on
+ } else {
+ // @formatter:off
+ out.printf(format, "", "UUID", "IP Address", "Port",
+ "Count", "Update Time", "Elapsed", "Allowed");
+ out.printf(format, "", "----", "----------", "----",
+ "-----", "-----------", "-------", "-------");
+ // @formatter:on
+ }
+
+ long currentTime = System.currentTimeMillis();
+ for (Server server : servers.values()) {
+ String thisOne = "";
+
+ if (server == thisServer) {
+ thisOne = "*";
+ } else if (localNotifyList.contains(server)) {
+ thisOne = "n";
+ }
+ /*
+ else if (newHosts.contains(server))
+ {
+ thisOne = "N";
+ }
+ */
+
+ if (siteData) {
+ String siteIp = "";
+ String sitePort = "";
+ if (server.siteSocketAddress != null) {
+ siteIp =
+ server.siteSocketAddress.getAddress().getHostAddress();
+ sitePort = String.valueOf(server.siteSocketAddress.getPort());
+ }
+
+ out.printf(format, thisOne, server.uuid,
+ server.socketAddress.getAddress().getHostAddress(),
+ server.socketAddress.getPort(),
+ siteIp, sitePort, server.count,
+ dateFormat.format(new Date(server.lastUpdateTime)),
+ currentTime - server.lastUpdateTime,
+ server.allowedGap);
+ } else {
+ out.printf(format, thisOne, server.uuid,
+ server.socketAddress.getAddress().getHostAddress(),
+ server.socketAddress.getPort(), server.count,
+ dateFormat.format(new Date(server.lastUpdateTime)),
+ currentTime - server.lastUpdateTime,
+ server.allowedGap);
+ }
+ }
+ out.println("Count: " + servers.size());
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This interface supports the 'post' method, and provides the opportunity
+ * to change the WebTarget and/or receive the POST response message.
+ */
+ interface PostResponse {
+ /**
+ * Callback that can be used to modify 'WebTarget', and do things like
+ * add query parameters.
+ *
+ * @param webTarget the current WebTarget
+ * @return the updated WebTarget
+ */
+ public default WebTarget webTarget(WebTarget webTarget) {
+ return webTarget;
+ }
+
+ /**
+ * Callback that passes the POST response.
+ *
+ * @param response the POST response
+ */
+ public default void response(Response response) {
+ }
+
+ /**
+ * Callback that passes the POST exception response.
+ *
+ */
+ public default void exceptionResponse(Exception exception) {
+ Response.ResponseBuilder response;
+ response = Response.serverError();
+ this.response(response.build());
+ }
+ }
+}
diff --git a/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/ServerPoolApi.java b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/ServerPoolApi.java
new file mode 100644
index 00000000..c6337749
--- /dev/null
+++ b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/ServerPoolApi.java
@@ -0,0 +1,79 @@
+/*
+ * ============LICENSE_START=======================================================
+ * feature-server-pool
+ * ================================================================================
+ * Copyright (C) 2020 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=========================================================
+ */
+
+package org.onap.policy.drools.serverpool;
+
+import java.util.Collection;
+
+import org.onap.policy.common.utils.services.OrderedService;
+import org.onap.policy.common.utils.services.OrderedServiceImpl;
+
+public interface ServerPoolApi extends OrderedService {
+ /**
+ * 'ServerPoolApi.impl.getList()' returns an ordered list of objects
+ * implementing the 'ServerPoolApi' interface.
+ */
+ public static OrderedServiceImpl<ServerPoolApi> impl =
+ new OrderedServiceImpl<>(ServerPoolApi.class);
+
+ /**
+ * method gives all of the listening features the ability to add
+ * classes to the 'HttpServletServer'.
+ *
+ * @return a Collection of classes implementing REST methods
+ */
+ public default Collection<Class<?>> servletClasses() {
+ return null;
+ }
+
+ /**
+ * This is called in the case where no bucket migration data was received
+ * from the old owner of the bucket (such as if the old owner failed).
+ * It gives one or more features the opportunity to do the restore.
+ *
+ * @param bucket the bucket that needs restoring
+ */
+ public default void restoreBucket(Bucket bucket) {
+ }
+
+ /**
+ * This is called whenever a 'GlobalLocks' object is updated. It was added
+ * in order to support persistence, but may be used elsewhere as well.
+ *
+ * @param bucket the bucket containing the 'GlobalLocks' adjunct
+ * @param globalLocks the 'GlobalLocks' adjunct
+ */
+ public default void lockUpdate(Bucket bucket, TargetLock.GlobalLocks globalLocks) {
+ }
+
+ /**
+ * This is called when the state of a bucket has changed, but is currently
+ * stable, and it gives features the ability to do an audit. The intent is
+ * to make sure that the adjunct state is correct; in particular, to remove
+ * adjuncts that should no longer be there based upon the current state.
+ * Note that this method is called while being synchronized on the bucket.
+ *
+ * @param bucket the bucket to audit
+ * @param isOwner 'true' if the current host owns the bucket
+ * @param isBackup 'true' if the current host is a backup for the bucket
+ */
+ public default void auditBucket(Bucket bucket, boolean isOwner, boolean isBackup) {
+ }
+}
diff --git a/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/ServerPoolProperties.java b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/ServerPoolProperties.java
new file mode 100644
index 00000000..fb6a791e
--- /dev/null
+++ b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/ServerPoolProperties.java
@@ -0,0 +1,332 @@
+/*
+ * ============LICENSE_START=======================================================
+ * feature-server-pool
+ * ================================================================================
+ * Copyright (C) 2020 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=========================================================
+ */
+
+package org.onap.policy.drools.serverpool;
+
+import java.util.Properties;
+
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ServerPoolProperties {
+ // 'Server' port listener
+ public static final String SERVER_IP_ADDRESS = "server.pool.server.ipAddress";
+ public static final String SERVER_PORT = "server.pool.server.port";
+ public static final String SERVER_HTTPS = "server.pool.server.https";
+ public static final String SERVER_SELF_SIGNED_CERTIFICATES =
+ "server.pool.server.selfSignedCerts";
+
+ // 'site' information
+ public static final String SITE_IP_ADDRESS = "server.pool.server.site.ip";
+ public static final String SITE_PORT = "server.pool.server.site.port";
+
+ // the default is to listen to all IP addresses on the host
+ public static final String DEFAULT_SERVER_IP_ADDRESS = "0.0.0.0";
+
+ // the default is to dynamically select a port
+ public static final int DEFAULT_SERVER_PORT = 0;
+
+ // the default is to have HTTPS disabled
+ public static final boolean DEFAULT_HTTPS = false;
+
+ // the default is to not use self-signed certificates
+ public static final boolean DEFAULT_SELF_SIGNED_CERTIFICATES = false;
+
+ // list of remote server names to use in HTTP/HTTPS messages
+ // (instead of host names)
+ public static final String HOST_LIST = "server.pool.server.hostlist";
+
+ // 'Server' timeouts
+ public static final String SERVER_INITIAL_ALLOWED_GAP = "server.pool.server.allowedGap";
+ public static final String SERVER_ADAPTIVE_GAP_ADJUST =
+ "server.adaptiveGapAdjust";
+ public static final String SERVER_CONNECT_TIMEOUT = "server.pool.server.connectTimeout";
+ public static final String SERVER_READ_TIMEOUT = "server.pool.server.readTimeout";
+
+ // at startup, initially allow 30 seconds between pings
+ public static final long DEFAULT_SERVER_INITIAL_ALLOWED_GAP = 30000;
+
+ // when doing the adaptive calculation of the allowed gap between pings,
+ // adjust the time by adding 5 seconds (by default)
+ public static final long DEFAULT_SERVER_ADAPTIVE_GAP_ADJUST = 5000;
+
+ // the default is to allow 10 seconds for a TCP connect
+ public static final long DEFAULT_SERVER_CONNECT_TIMEOUT = 10000;
+
+ // the default is to allow 10 seconds for a TCP read response
+ public static final long DEFAULT_SERVER_READ_TIMEOUT = 10000;
+
+ // outgoing per-server thread pool parameters
+ public static final String SERVER_THREADS_CORE_POOL_SIZE =
+ "server.pool.server.threads.corePoolSize";
+ public static final String SERVER_THREADS_MAXIMUM_POOL_SIZE =
+ "server.pool.server.threads.maximumPoolSize";
+ public static final String SERVER_THREADS_KEEP_ALIVE_TIME =
+ "server.pool.server.threads.keepAliveTime";
+
+ public static final int DEFAULT_SERVER_THREADS_CORE_POOL_SIZE = 5;
+ public static final int DEFAULT_SERVER_THREADS_MAXIMUM_POOL_SIZE = 10;
+ public static final long DEFAULT_SERVER_THREADS_KEEP_ALIVE_TIME = 5000;
+
+ /*================*/
+ /* Host Discovery */
+ /*================*/
+
+ public static final String DISCOVERY_SERVERS = "server.pool.discovery.servers";
+ public static final String DISCOVERY_TOPIC = "server.pool.discovery.topic";
+
+ // HTTP authentication
+ public static final String DISCOVERY_USERNAME = "server.pool.discovery.username";
+ public static final String DISCOVERY_PASSWORD = "server.pool.discovery.password";
+
+ // Cambria authentication
+ public static final String DISCOVERY_API_KEY = "server.pool.discovery.apiKey";
+ public static final String DISCOVERY_API_SECRET = "server.pool.discovery.apiSecret";
+
+ // timeouts
+ public static final String DISCOVERY_FETCH_TIMEOUT =
+ "server.pool.discovery.fetchTimeout";
+
+ // this value is passed to the UEB/DMAAP server, and controls how long
+ // a 'fetch' request will wait when there are no incoming messages
+ public static final String DEFAULT_DISCOVERY_FETCH_TIMEOUT = "60000";
+
+ // maximum message fetch limit
+ public static final String DISCOVERY_FETCH_LIMIT = "server.pool.discovery.fetchLimit";
+
+ // this value is passed to the UEB/DMAAP server, and controls how many
+ // requests may be returned in a single fetch
+ public static final String DEFAULT_DISCOVERY_FETCH_LIMIT = "100";
+
+ // publisher thread cycle time
+ public static final String DISCOVER_PUBLISHER_LOOP_CYCLE_TIME =
+ "discovery.publisherLoopCycleTime";
+
+ // default cycle time is 5 seconds
+ public static final long DEFAULT_DISCOVER_PUBLISHER_LOOP_CYCLE_TIME = 5000;
+
+ // encryption
+ public static final String DISCOVERY_HTTPS = "server.pool.discovery.https";
+ public static final String DISCOVERY_ALLOW_SELF_SIGNED_CERTIFICATES =
+ "server.pool.discovery.selfSignedCertificates";
+
+ /*============================*/
+ /* Leader Election Parameters */
+ /*============================*/
+
+ public static final String LEADER_STABLE_IDLE_CYCLES =
+ "server.pool.leader.stableIdleCycles";
+ public static final String LEADER_STABLE_VOTING_CYCLES =
+ "server.pool.leader.stableVotingCycles";
+
+ // by default, wait for 5 cycles (seconds) of stability before voting starts
+ public static final int DEFAULT_LEADER_STABLE_IDLE_CYCLES = 5;
+
+ // by default, wait for 5 cycles of stability before declaring a winner
+ public static final int DEFAULT_LEADER_STABLE_VOTING_CYCLES = 5;
+
+ /*=====================*/
+ /* MainLoop Parameters */
+ /*=====================*/
+
+ public static final String MAINLOOP_CYCLE = "server.pool.mainLoop.cycle";
+
+ // by default, the main loop cycle is 1 second
+ public static final long DEFAULT_MAINLOOP_CYCLE = 1000;
+
+ /*=============================*/
+ /* Bucket Migration Parameters */
+ /*=============================*/
+
+ // time-to-live controls how many hops a 'TargetLock' message can take
+ public static final String BUCKET_TIME_TO_LIVE = "bucket.ttl";
+
+ // bucket migration timeout when a server has been notified that it
+ // is the new owner of the bucket
+ public static final String BUCKET_CONFIRMED_TIMEOUT =
+ "bucket.confirmed.timeout";
+
+ // bucket migration timeout when a server has inferred that it may be
+ // the new owner, but it hasn't yet been confirmed
+ public static final String BUCKET_UNCONFIRMED_TIMEOUT =
+ "bucket.unconfirmed.timeout";
+
+ // timeout for operation run within a Drools session
+ public static final String BUCKET_DROOLS_TIMEOUT =
+ "bucket.drools.timeout";
+
+ // when a new owner of a bucket has completed the takeover of the
+ // bucket, but it hasn't yet been confirmed, there is an additional
+ // grace period before leaving the 'NewOwner' state
+ public static final String BUCKET_UNCONFIRMED_GRACE_PERIOD =
+ "bucket.unconfirmed.graceperiod";
+
+ // time-to-live = 5 hops
+ public static final int DEFAULT_BUCKET_TIME_TO_LIVE = 5;
+
+ // 30 seconds timeout if it has been confirmed that we are the new owner
+ public static final long DEFAULT_BUCKET_CONFIRMED_TIMEOUT = 30000;
+
+ // 10 seconds timeout if it has not been confirmed that we are the new owner
+ public static final long DEFAULT_BUCKET_UNCONFIRMED_TIMEOUT = 10000;
+
+ // 10 seconds timeout waiting for a drools operation to complete
+ public static final long DEFAULT_BUCKET_DROOLS_TIMEOUT = 10000;
+
+ // 10 seconds timeout waiting to be confirmed that we are the new owner
+ public static final long DEFAULT_BUCKET_UNCONFIRMED_GRACE_PERIOD = 10000;
+
+ /*=======================*/
+ /* TargetLock Parameters */
+ /*=======================*/
+
+ // time-to-live controls how many hops a 'TargetLock' message can take
+ public static final String LOCK_TIME_TO_LIVE = "lock.ttl";
+
+ // how frequently should the audit run?
+ public static final String LOCK_AUDIT_PERIOD = "lock.audit.period";
+
+ // when the audit is rescheduled (e.g. due to a new server joining), this
+ // is the initial grace period, to allow time for bucket assignments, etc.
+ public static final String LOCK_AUDIT_GRACE_PERIOD =
+ "lock.audit.gracePeriod";
+
+ // there may be audit mismatches detected that are only due to the transient
+ // nature of the lock state -- we check the mismatches on both sides after
+ // this delay to see if we are still out-of-sync
+ public static final String LOCK_AUDIT_RETRY_DELAY = "lock.audit.retryDelay";
+
+ // time-to-live = 5 hops
+ public static final int DEFAULT_LOCK_TIME_TO_LIVE = 5;
+
+ // run the audit every 5 minutes
+ public static final long DEFAULT_LOCK_AUDIT_PERIOD = 300000;
+
+ // wait at least 60 seconds after an event before running the audit
+ public static final long DEFAULT_LOCK_AUDIT_GRACE_PERIOD = 60000;
+
+ // wait 5 seconds to see if the mismatches still exist
+ public static final long DEFAULT_LOCK_AUDIT_RETRY_DELAY = 5000;
+
+ /* ============================================================ */
+
+ private static Logger logger =
+ LoggerFactory.getLogger(ServerPoolProperties.class);
+
+ // save initial set of properties
+ private static Properties properties = new Properties();
+
+ /**
+ * Store the application properties values.
+ *
+ * @param properties the properties to save
+ */
+ public static void setProperties(Properties properties) {
+ ServerPoolProperties.properties = properties;
+ }
+
+ /**
+ * Return the properties used when starting this server.
+ *
+ * @return the properties used when starting this server.
+ */
+ public static Properties getProperties() {
+ return properties;
+ }
+
+ /**
+ * Convenience method to fetch a 'long' property.
+ *
+ * @param name the property name
+ * @param defaultValue the value to use if the property is not defined,
+ * or has an illegal value
+ * @return the property value
+ */
+ public static long getProperty(String name, long defaultValue) {
+ long rval = defaultValue;
+ String value = properties.getProperty(name);
+ if (StringUtils.isNotBlank(value)) {
+ // try to convert to a 'long' -- log a message in case of failure
+ try {
+ rval = Long.parseLong(value);
+ } catch (NumberFormatException e) {
+ logger.error("Property {}=\"{}\": illegal long -- "
+ + "using default of {}", name, value, defaultValue);
+ }
+ }
+ return rval;
+ }
+
+ /**
+ * Convenience method to fetch an 'int' property.
+ *
+ * @param name the property name
+ * @param defaultValue the value to use if the property is not defined,
+ * or has an illegal value
+ * @return the property value
+ */
+ public static int getProperty(String name, int defaultValue) {
+ int rval = defaultValue;
+ String value = properties.getProperty(name);
+ if (StringUtils.isNotBlank(value)) {
+ // try to convert to an 'int' -- log a message in case of failure
+ try {
+ rval = Integer.parseInt(value);
+ } catch (NumberFormatException e) {
+ logger.error("Property {}=\"{}\": illegal int -- "
+ + "using default of {}", name, value, defaultValue);
+ }
+ }
+ return rval;
+ }
+
+ /**
+ * Convenience method to fetch a 'boolean' property.
+ *
+ * @param name the property name
+ * @param defaultValue the value to use if the property is not defined,
+ * or has an illegal value
+ * @return the property value
+ */
+ public static boolean getProperty(String name, boolean defaultValue) {
+ boolean rval = defaultValue;
+ String value = properties.getProperty(name);
+ if (StringUtils.isNotBlank(value)) {
+ // try to convert to an 'boolean' -- log a message in case of failure
+ rval = Boolean.parseBoolean(value);
+ }
+ return rval;
+ }
+
+ /**
+ * Convenience method to fetch a 'String' property
+ * (provided for consistency with 'long' and 'int' versions).
+ *
+ * @param name the property name
+ * @param defaultValue the value to use if the property is not defined,
+ * or has an illegal value
+ * @return the property value
+ */
+ public static String getProperty(String name, String defaultValue) {
+ String value = properties.getProperty(name);
+ return (StringUtils.isNotBlank(value)) ? value : defaultValue;
+ }
+}
diff --git a/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/TargetLock.java b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/TargetLock.java
new file mode 100644
index 00000000..7e4b795f
--- /dev/null
+++ b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/TargetLock.java
@@ -0,0 +1,2821 @@
+/*
+ * ============LICENSE_START=======================================================
+ * feature-server-pool
+ * ================================================================================
+ * Copyright (C) 2020 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=========================================================
+ */
+
+package org.onap.policy.drools.serverpool;
+
+import static org.junit.Assert.assertTrue;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_LOCK_AUDIT_GRACE_PERIOD;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_LOCK_AUDIT_PERIOD;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_LOCK_AUDIT_RETRY_DELAY;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.DEFAULT_LOCK_TIME_TO_LIVE;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.LOCK_AUDIT_GRACE_PERIOD;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.LOCK_AUDIT_PERIOD;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.LOCK_AUDIT_RETRY_DELAY;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.LOCK_TIME_TO_LIVE;
+import static org.onap.policy.drools.serverpool.ServerPoolProperties.getProperty;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.PrintStream;
+import java.io.Serializable;
+import java.lang.ref.Reference;
+import java.lang.ref.ReferenceQueue;
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.TimerTask;
+import java.util.TreeMap;
+import java.util.UUID;
+import java.util.concurrent.LinkedTransferQueue;
+import java.util.concurrent.TimeUnit;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+
+import lombok.NonNull;
+import org.onap.policy.drools.core.DroolsRunnable;
+import org.onap.policy.drools.core.PolicyContainer;
+import org.onap.policy.drools.core.PolicySession;
+import org.onap.policy.drools.core.lock.Lock;
+import org.onap.policy.drools.core.lock.LockCallback;
+import org.onap.policy.drools.core.lock.PolicyResourceLockManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class provides a locking mechanism based upon a string key that
+ * identifies the lock, and another string key that identifies the owner.
+ * The existence of the 'TargetLock' instance doesn't mean that the
+ * corresponding lock has been acquired -- this is only the case if the
+ * instance is in the 'ACTIVE' state.
+ * A lock in the ACTIVE or WAITING state exists in two sets of tables,
+ * which may be on different hosts:
+ * LocalLocks - these two tables are associated with the owner key of the
+ * lock. They are in an adjunct to the bucket associated with this key,
+ * and the bucket is owned by the host containing the entry.
+ * GlobalLocks - this table is associated with the lock key. It is an
+ * adjunct to the bucket associated with this key, and the bucket is
+ * owned by the host containing the entry.
+ */
+public class TargetLock implements Lock, Serializable {
+ private static Logger logger = LoggerFactory.getLogger(TargetLock.class);
+
+ // Listener class to handle state changes that require restarting the audit
+ private static EventHandler eventHandler = new EventHandler();
+
+ static {
+ // register Listener class
+ Events.register(eventHandler);
+ }
+
+ // this is used to locate ACTIVE 'TargetLock' instances that have been
+ // abandoned -- as the GC cleans up the 'WeakReference' instances referring
+ // to these locks, we use that information to clean them up
+ private static ReferenceQueue<TargetLock> abandoned = new ReferenceQueue<>();
+
+ // some status codes
+ static final int ACCEPTED = 202; //Response.Status.ACCEPTED.getStatusCode();
+ static final int NO_CONTENT = 204; //Response.Status.NO_CONTENT.getStatusCode();
+ static final int LOCKED = 423;
+
+ // Values extracted from properties
+
+ private static String timeToLive;
+ private static long auditPeriod;
+ private static long auditGracePeriod;
+ private static long auditRetryDelay;
+
+ // lock states:
+ // WAITING - in line to acquire the lock
+ // ACTIVE - currently holding the lock
+ // FREE - WAITING/ACTIVE locks that were explicitly freed
+ // LOST - could occur when a de-serialized ACTIVE lock can't be made
+ // ACTIVE because there is already an ACTIVE holder of the lock
+ public enum State {
+ WAITING, ACTIVE, FREE, LOST
+ }
+
+ // this contains information that is placed in the 'LocalLocks' tables,
+ // and has a one-to-one correspondence with the 'TargetLock' instance
+ private Identity identity;
+
+ // this is the only field that can change after initialization
+ private State state;
+
+ // this is used to notify the application when a lock is available,
+ // or if it is not available
+ private LockCallback owner;
+
+ // This is what is actually called by the infrastructure to do the owner
+ // notification. The owner may be running in a Drools session, in which case
+ // the actual notification should be done within that thread -- the 'context'
+ // object ensures that it happens this way.
+ private LockCallback context;
+
+ /**
+ * This method triggers registration of 'eventHandler', and also extracts
+ * property values.
+ */
+ static void startup() {
+ int intTimeToLive =
+ getProperty(LOCK_TIME_TO_LIVE, DEFAULT_LOCK_TIME_TO_LIVE);
+ timeToLive = String.valueOf(intTimeToLive);
+ auditPeriod = getProperty(LOCK_AUDIT_PERIOD, DEFAULT_LOCK_AUDIT_PERIOD);
+ auditGracePeriod =
+ getProperty(LOCK_AUDIT_GRACE_PERIOD, DEFAULT_LOCK_AUDIT_GRACE_PERIOD);
+ auditRetryDelay =
+ getProperty(LOCK_AUDIT_RETRY_DELAY, DEFAULT_LOCK_AUDIT_RETRY_DELAY);
+ }
+
+ /**
+ * Shutdown threads.
+ */
+ static void shutdown() {
+ AbandonedHandler ah = abandonedHandler;
+
+ if (ah != null) {
+ abandonedHandler = null;
+ ah.interrupt();
+ }
+ }
+
+ /**
+ * Constructor - initializes the 'TargetLock' instance, and tries to go
+ * ACTIVE. The lock is initially placed in the WAITING state, and the owner
+ * and the owner will be notified when the success or failure of the lock
+ * attempt is determined.
+ *
+ * @param key string key identifying the lock
+ * @param ownerKey string key identifying the owner, which must hash to
+ * a bucket owned by the current host (it is typically a 'RequestID')
+ * @param owner owner of the lock (will be notified when going from
+ * WAITING to ACTIVE)
+ */
+ public TargetLock(String key, String ownerKey, LockCallback owner) {
+ this(key, ownerKey, owner, true);
+ }
+
+ /**
+ * Constructor - initializes the 'TargetLock' instance, and tries to go
+ * ACTIVE. The lock is initially placed in the WAITING state, and the owner
+ * and the owner will be notified when the success or failure of the lock
+ * attempt is determined.
+ *
+ * @param key string key identifying the lock
+ * @param ownerKey string key identifying the owner, which must hash to
+ * a bucket owned by the current host (it is typically a 'RequestID')
+ * @param owner owner of the lock (will be notified when going from
+ * WAITING to ACTIVE)
+ * @param waitForLock this controls the behavior when 'key' is already
+ * locked - 'true' means wait for it to be freed, 'false' means fail
+ */
+ public TargetLock(final String key, final String ownerKey,
+ final LockCallback owner, final boolean waitForLock) {
+ if (key == null) {
+ throw(new IllegalArgumentException("TargetLock: 'key' can't be null"));
+ }
+ if (ownerKey == null) {
+ throw(new IllegalArgumentException("TargetLock: 'ownerKey' can't be null"));
+ }
+ if (!Bucket.isKeyOnThisServer(ownerKey)) {
+ // associated bucket is assigned to a different server
+ throw(new IllegalArgumentException("TargetLock: 'ownerKey=" + ownerKey
+ + "' not currently assigned to this server"));
+ }
+ if (owner == null) {
+ throw(new IllegalArgumentException("TargetLock: 'owner' can't be null"));
+ }
+ identity = new Identity(key, ownerKey);
+ state = State.WAITING;
+ this.owner = owner;
+
+ // determine the context
+ PolicySession session = PolicySession.getCurrentSession();
+ if (session != null) {
+ // deliver through a 'PolicySessionContext' class
+ Object lcontext = session.getAdjunct(PolicySessionContext.class);
+ if (lcontext == null || !(lcontext instanceof LockCallback)) {
+ context = new PolicySessionContext(session);
+ session.setAdjunct(PolicySessionContext.class, context);
+ } else {
+ context = (LockCallback)lcontext;
+ }
+ } else {
+ // no context to deliver through -- call back directly to owner
+ context = owner;
+ }
+
+ // update 'LocalLocks' tables
+ final WeakReference<TargetLock> wr = new WeakReference<>(this, abandoned);
+ final LocalLocks localLocks = LocalLocks.get(ownerKey);
+
+ synchronized (localLocks) {
+ localLocks.weakReferenceToIdentity.put(wr, identity);
+ localLocks.uuidToWeakReference.put(identity.uuid, wr);
+ }
+
+ // The associated 'GlobalLocks' table may or may not be on a different
+ // host. Also, the following call may queue the message for later
+ // processing if the bucket is in a transient state.
+ Bucket.forwardAndProcess(key, new Bucket.Message() {
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void process() {
+ // 'GlobalLocks' is on the same host
+ State newState = GlobalLocks.get(key).lock(key, ownerKey, identity.uuid, waitForLock);
+ logger.info("Lock lock request: key={}, owner={}, uuid={}, wait={} (resp={})",
+ key, ownerKey, identity.uuid, waitForLock, state);
+
+ // The lock may now be ACTIVE, FREE, or WAITING -- we can notify
+ // the owner of the result now for ACTIVE or FREE. Also, the callback
+ // may occur while the constructor is still on the stack, although
+ // this won't happen in a Drools session.
+ setState(newState);
+ switch (newState) {
+ case ACTIVE:
+ // lock was successful - send notification
+ context.lockAvailable(TargetLock.this);
+ break;
+ case FREE:
+ // lock attempt failed -
+ // clean up local tables, and send notification
+ synchronized (localLocks) {
+ localLocks.weakReferenceToIdentity.remove(wr);
+ localLocks.uuidToWeakReference.remove(identity.uuid);
+ }
+ wr.clear();
+ context.lockUnavailable(TargetLock.this);
+ break;
+
+ case WAITING:
+ break;
+
+ default:
+ logger.error("Unknown state: {}", newState);
+ break;
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void sendToServer(Server server, int bucketNumber) {
+ // actual lock is on a remote host -- send the request as
+ // a REST message
+ logger.info("Sending lock request to {}: key={}, owner={}, uuid={}, wait={}",
+ server, key, ownerKey, identity.uuid, waitForLock);
+ server.post("lock/lock", null, new Server.PostResponse() {
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public WebTarget webTarget(WebTarget webTarget) {
+ return webTarget
+ .queryParam("key", key)
+ .queryParam("owner", ownerKey)
+ .queryParam("uuid", identity.uuid.toString())
+ .queryParam("wait", waitForLock)
+ .queryParam("ttl", timeToLive);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void response(Response response) {
+ logger.info("Lock response={} (code={})",
+ response, response.getStatus());
+
+ /**
+ * there are three possible responses:
+ * 204 No Content - operation was successful
+ * 202 Accepted - operation is still in progress
+ * 423 (Locked) - lock in use, and 'waitForLock' is 'false'
+ */
+ switch (response.getStatus()) {
+ case NO_CONTENT: {
+ // lock successful
+ setState(State.ACTIVE);
+ context.lockAvailable(TargetLock.this);
+ break;
+ }
+
+ case LOCKED: {
+ // failed -- lock in use, and 'waitForLock == false'
+ setState(State.FREE);
+ synchronized (localLocks) {
+ localLocks.weakReferenceToIdentity.remove(wr);
+ localLocks.uuidToWeakReference.remove(identity.uuid);
+ }
+ wr.clear();
+ context.lockUnavailable(TargetLock.this);
+ break;
+ }
+
+ case ACCEPTED:
+ break;
+
+ default:
+ logger.error("Unknown status: ", response.getStatus());
+ break;
+ }
+ }
+ });
+ }
+ });
+ }
+
+ /********************/
+ /* 'Lock' Interface */
+ /********************/
+
+ /**
+ * This method will free the current lock, or remove it from the waiting
+ * list if a response is pending.
+ *
+ * @return 'true' if successful, 'false' if it was not locked, or there
+ * appears to be corruption in 'LocalLocks' tables
+ */
+ @Override
+ public boolean free() {
+ synchronized (this) {
+ if (state != State.ACTIVE && state != State.WAITING) {
+ // nothing to free
+ return false;
+ }
+ state = State.FREE;
+ }
+
+ return identity.free();
+ }
+
+ /**
+ * Return 'true' if the lock is in the ACTIVE state.
+ *
+ * @return 'true' if the lock is in the ACTIVE state, and 'false' if not
+ */
+ @Override
+ public synchronized boolean isActive() {
+ return state == State.ACTIVE;
+ }
+
+ /**
+ * Return 'true' if the lock is not available.
+ *
+ * @return 'true' if the lock is in the FREE or LOST state,
+ * and 'false' if not
+ */
+ @Override
+ public synchronized boolean isUnavailable() {
+ return state == State.FREE || state == State.LOST;
+ }
+
+ /**
+ * Return 'true' if the lock is in the WAITING state.
+ *
+ * @return 'true' if the lock is in the WAITING state, and 'false' if not
+ */
+ public synchronized boolean isWaiting() {
+ return state == State.WAITING;
+ }
+
+ /**
+ * Return the lock's key.
+ *
+ * @return the lock's key
+ */
+ @Override
+ public String getResourceId() {
+ return identity.key;
+ }
+
+ /**
+ * Return the owner key field.
+ *
+ * @return the owner key field
+ */
+ @Override
+ public String getOwnerKey() {
+ return identity.ownerKey;
+ }
+
+ /**
+ * Extends the lock's hold time (not implemented yet).
+ */
+ @Override
+ public void extend(int holdSec, LockCallback callback) {
+ }
+
+ /********************/
+
+ /**
+ * Update the state.
+ *
+ * @param newState the new state value
+ */
+ private synchronized void setState(State newState) {
+ state = newState;
+ }
+
+ /**
+ * Return the currentstate of the lock.
+ *
+ * @return the current state of the lock
+ */
+ public synchronized State getState() {
+ return state;
+ }
+
+ /**
+ * This method is called when an incoming /lock/lock REST message is received.
+ *
+ * @param key string key identifying the lock, which must hash to a bucket
+ * owned by the current host
+ * @param ownerKey string key identifying the owner
+ * @param uuid the UUID that uniquely identifies the original 'TargetLock'
+ * @param waitForLock this controls the behavior when 'key' is already
+ * locked - 'true' means wait for it to be freed, 'false' means fail
+ * @param ttl similar to IP time-to-live -- it controls the number of hops
+ * the message may take
+ * @return the Response that should be passed back to the HTTP request
+ */
+ static Response incomingLock(String key, String ownerKey, UUID uuid, boolean waitForLock, int ttl) {
+ if (!Bucket.isKeyOnThisServer(key)) {
+ // this is the wrong server -- forward to the correct one
+ // (we can use this thread)
+ if ((ttl -= 1) > 0) {
+ Server server = Bucket.bucketToServer(Bucket.bucketNumber(key));
+ if (server != null) {
+ WebTarget webTarget = server.getWebTarget("lock/lock");
+ if (webTarget != null) {
+ logger.warn("Forwarding 'lock/lock' to uuid {} "
+ + "(key={},owner={},uuid={},wait={},ttl={})",
+ server.getUuid(), key, ownerKey, uuid,
+ waitForLock, ttl);
+ return webTarget
+ .queryParam("key", key)
+ .queryParam("owner", ownerKey)
+ .queryParam("uuid", uuid.toString())
+ .queryParam("wait", waitForLock)
+ .queryParam("ttl", String.valueOf(ttl))
+ .request().get();
+ }
+ }
+ }
+
+ // if we reach this point, we didn't forward for some reason --
+ // return failure by indicating it is locked and unavailable
+ logger.error("Couldn't forward 'lock/lock' "
+ + "(key={},owner={},uuid={},wait={},ttl={})",
+ key, ownerKey, uuid, waitForLock, ttl);
+ return Response.noContent().status(LOCKED).build();
+ }
+
+ State state = GlobalLocks.get(key).lock(key, ownerKey, uuid, waitForLock);
+ switch (state) {
+ case ACTIVE:
+ return Response.noContent().build();
+ case WAITING:
+ return Response.noContent().status(Response.Status.ACCEPTED).build();
+ default:
+ return Response.noContent().status(LOCKED).build();
+ }
+ }
+
+ /**
+ * This method is called when an incoming /lock/free REST message is received.
+ *
+ * @param key string key identifying the lock, which must hash to a bucket
+ * owned by the current host
+ * @param ownerKey string key identifying the owner
+ * @param uuid the UUID that uniquely identifies the original 'TargetLock'
+ * @param ttl similar to IP time-to-live -- it controls the number of hops
+ * the message may take
+ * @return the Response that should be passed back to the HTTP request
+ */
+ static Response incomingFree(String key, String ownerKey, UUID uuid, int ttl) {
+ if (!Bucket.isKeyOnThisServer(key)) {
+ // this is the wrong server -- forward to the correct one
+ // (we can use this thread)
+ if ((ttl -= 1) > 0) {
+ Server server = Bucket.bucketToServer(Bucket.bucketNumber(key));
+ if (server != null) {
+ WebTarget webTarget = server.getWebTarget("lock/free");
+ if (webTarget != null) {
+ logger.warn("Forwarding 'lock/free' to uuid {} "
+ + "(key={},owner={},uuid={},ttl={})",
+ server.getUuid(), key, ownerKey, uuid, ttl);
+ return webTarget
+ .queryParam("key", key)
+ .queryParam("owner", ownerKey)
+ .queryParam("uuid", uuid.toString())
+ .queryParam("ttl", String.valueOf(ttl))
+ .request().get();
+ }
+ }
+ }
+
+ // if we reach this point, we didn't forward for some reason --
+ // return failure by indicating it is locked and unavailable
+ logger.error("Couldn't forward 'lock/free' "
+ + "(key={},owner={},uuid={},ttl={})",
+ key, ownerKey, uuid, ttl);
+ return null;
+ }
+
+ // TBD: should this return a more meaningful response?
+ GlobalLocks.get(key).unlock(key, uuid);
+ return null;
+ }
+
+ /**
+ * This method is called when an incoming /lock/locked message is received
+ * (this is a callback to an earlier requestor that the lock is now
+ * available).
+ *
+ * @param key string key identifying the lock
+ * @param ownerKey string key identifying the owner, which must hash to
+ * a bucket owned by the current host (it is typically a 'RequestID')
+ * @param uuid the UUID that uniquely identifies the original 'TargetLock'
+ * @param ttl similar to IP time-to-live -- it controls the number of hops
+ * the message may take
+ * @return the Response that should be passed back to the HTTP request
+ */
+ static Response incomingLocked(String key, String ownerKey, UUID uuid, int ttl) {
+ if (!Bucket.isKeyOnThisServer(ownerKey)) {
+ // this is the wrong server -- forward to the correct one
+ // (we can use this thread)
+ if ((ttl -= 1) > 0) {
+ Server server = Bucket.bucketToServer(Bucket.bucketNumber(key));
+ if (server != null) {
+ WebTarget webTarget = server.getWebTarget("lock/locked");
+ if (webTarget != null) {
+ logger.warn("Forwarding 'lock/locked' to uuid {} "
+ + "(key={},owner={},uuid={},ttl={})",
+ server.getUuid(), key, ownerKey, uuid, ttl);
+ return webTarget
+ .queryParam("key", key)
+ .queryParam("owner", ownerKey)
+ .queryParam("uuid", uuid.toString())
+ .queryParam("ttl", String.valueOf(ttl))
+ .request().get();
+ }
+ }
+ }
+
+ // if we reach this point, we didn't forward for some reason --
+ // return failure by indicating it is locked and unavailable
+ logger.error("Couldn't forward 'lock/locked' "
+ + "(key={},owner={},uuid={},ttl={})",
+ key, ownerKey, uuid, ttl);
+ return Response.noContent().status(LOCKED).build();
+ }
+
+ TargetLock targetLock = null;
+ LocalLocks localLocks = LocalLocks.get(ownerKey);
+ synchronized (localLocks) {
+ WeakReference<TargetLock> wr =
+ localLocks.uuidToWeakReference.get(uuid);
+
+ if (wr != null) {
+ targetLock = wr.get();
+ if (targetLock == null) {
+ // lock has been abandoned
+ // (AbandonedHandler should usually find this first)
+ localLocks.weakReferenceToIdentity.remove(wr);
+ localLocks.uuidToWeakReference.remove(uuid);
+ } else {
+ // the lock has been made available -- update the state
+ // TBD: This could be outside of 'synchronized (localLocks)'
+ synchronized (targetLock) {
+ if (targetLock.state == State.WAITING) {
+ targetLock.state = State.ACTIVE;
+ } else {
+ // will return a failure -- not sure how this happened
+ logger.error("incomingLocked: {} is in state {}",
+ targetLock, targetLock.state);
+ targetLock = null;
+ }
+ }
+ }
+ } else {
+ // clean up what we can
+ localLocks.uuidToWeakReference.remove(uuid);
+ }
+ }
+ if (targetLock == null) {
+ // We can't locate the target lock
+ // TBD: This probably isn't the best error code to use
+ return Response.noContent().status(LOCKED).build();
+ } else {
+ targetLock.context.lockAvailable(targetLock);
+ return Response.noContent().build();
+ }
+ }
+
+ /**
+ * This is called when the state of a bucket has changed, but is currently
+ * stable. Note that this method is called while being synchronized on the
+ * bucket.
+ *
+ * @param bucket the bucket to audit
+ * @param owner 'true' if the current host owns the bucket
+ * @param backup 'true' if the current host is a backup for the bucket
+ */
+ static void auditBucket(Bucket bucket, boolean isOwner, boolean isBackup) {
+ if (!isOwner) {
+ // we should not have any 'TargetLock' adjuncts
+ if (bucket.removeAdjunct(LocalLocks.class) != null) {
+ logger.warn("Bucket {}: Removed superfluous "
+ + "'TargetLock.LocalLocks' adjunct",
+ bucket.getIndex());
+ }
+ if (bucket.removeAdjunct(GlobalLocks.class) != null) {
+ logger.warn("Bucket {}: Removed superfluous "
+ + "'TargetLock.GlobalLocks' adjunct",
+ bucket.getIndex());
+ }
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String toString() {
+ return "TargetLock(key=" + identity.key
+ + ", ownerKey=" + identity.ownerKey
+ + ", uuid=" + identity.uuid
+ + ", state=" + state + ")";
+ }
+
+ /*****************/
+ /* Serialization */
+ /*****************/
+
+ /**
+ * This method modifies the behavior of 'TargetLock' deserialization by
+ * creating the corresponding 'LocalLocks' entries.
+ */
+ private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
+ in.defaultReadObject();
+ if (state == State.ACTIVE || state == State.WAITING) {
+ // need to build entries in 'LocalLocks'
+ LocalLocks localLocks = LocalLocks.get(identity.ownerKey);
+ WeakReference<TargetLock> wr = new WeakReference<>(this, abandoned);
+
+ synchronized (localLocks) {
+ localLocks.weakReferenceToIdentity.put(wr, identity);
+ localLocks.uuidToWeakReference.put(identity.uuid, wr);
+ }
+ }
+ }
+
+ /* ============================================================ */
+
+ private static class LockFactory implements PolicyResourceLockManager {
+ /*****************************************/
+ /* 'PolicyResourceLockManager' interface */
+ /*****************************************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Lock createLock(String resourceId, String ownerKey,
+ int holdSec, LockCallback callback,
+ boolean waitForLock) {
+ // 'holdSec' isn't implemented yet
+ return new TargetLock(resourceId, ownerKey, callback, waitForLock);
+ }
+
+ /*************************/
+ /* 'Startable' interface */
+ /*************************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean start() {
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean stop() {
+ return false;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void shutdown() {
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean isAlive() {
+ return true;
+ }
+
+ /************************/
+ /* 'Lockable' interface */
+ /************************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean lock() {
+ return false;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean unlock() {
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean isLocked() {
+ return false;
+ }
+ }
+
+ private static LockFactory lockFactory = new LockFactory();
+
+ public static PolicyResourceLockManager getLockFactory() {
+ return lockFactory;
+ }
+
+ /* ============================================================ */
+
+ /**
+ * There is a single instance of class 'TargetLock.EventHandler', which is
+ * registered to listen for notifications of state transitions.
+ */
+ private static class EventHandler implements Events {
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void newServer(Server server) {
+ // with an additional server, the offset within the audit period changes
+ Audit.scheduleAudit();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void serverFailed(Server server) {
+ // when one less server, the offset within the audit period changes
+ Audit.scheduleAudit();
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This class usually has a one-to-one correspondence with a 'TargetLock'
+ * instance, unless the 'TargetLock' has been abandoned.
+ */
+ private static class Identity implements Serializable {
+ // this is the key associated with the lock
+ String key;
+
+ // this is the key associated with the lock requestor
+ String ownerKey;
+
+ // this is a unique identifier assigned to the 'TargetLock'
+ UUID uuid;
+
+ /**
+ * Constructor - initializes the 'Identity' instance, including the
+ * generation of the unique identifier.
+ *
+ * @param key string key identifying the lock
+ * @param ownerKey string key identifying the owner, which must hash to
+ * a bucket owned by the current host (it is typically a 'RequestID')
+ */
+ private Identity(String key, String ownerKey) {
+ this.key = key;
+ this.ownerKey = ownerKey;
+ this.uuid = UUID.randomUUID();
+ }
+
+ /**
+ * Constructor - initializes the 'Identity' instance, with the 'uuid'
+ * value passed at initialization time (only used for auditing).
+ *
+ * @param key string key identifying the lock
+ * @param ownerKey string key identifying the owner, which must hash to
+ * @param uuid the UUID that uniquely identifies the original 'TargetLock'
+ */
+ private Identity(String key, String ownerKey, UUID uuid) {
+ this.key = key;
+ this.ownerKey = ownerKey;
+ this.uuid = uuid;
+ }
+
+ /**
+ * Free the lock associated with this 'Identity' instance.
+ *
+ * @return 'false' if the 'LocalLocks' data is not there, true' if it is
+ */
+ private boolean free() {
+ // free the lock
+ Bucket.forwardAndProcess(key, new Bucket.Message() {
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void process() {
+ // the global lock entry is also on this server
+ GlobalLocks.get(key).unlock(key, uuid);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void sendToServer(Server server, int bucketNumber) {
+ logger.info("Sending free request to {}: key={}, owner={}, uuid={}",
+ server, key, ownerKey, uuid);
+ server.post("lock/free", null, new Server.PostResponse() {
+ @Override
+ public WebTarget webTarget(WebTarget webTarget) {
+ return webTarget
+ .queryParam("key", key)
+ .queryParam("owner", ownerKey)
+ .queryParam("uuid", uuid.toString())
+ .queryParam("ttl", timeToLive);
+ }
+
+ @Override
+ public void response(Response response) {
+ logger.info("Free response={} (code={})",
+ response, response.getStatus());
+ switch (response.getStatus()) {
+ case NO_CONTENT: {
+ // free successful -- don't need to do anything
+ break;
+ }
+
+ case LOCKED: {
+ // free failed
+ logger.error("TargetLock free failed, "
+ + "key={}, owner={}, uuid={}",
+ key, ownerKey, uuid);
+ break;
+ }
+
+ default:
+ logger.error("Unknown status: ", response.getStatus());
+ break;
+ }
+ }
+ });
+ }
+ });
+
+ // clean up locallocks entry
+ LocalLocks localLocks = LocalLocks.get(ownerKey);
+ synchronized (localLocks) {
+ WeakReference<TargetLock> wr =
+ localLocks.uuidToWeakReference.get(uuid);
+ if (wr == null) {
+ return false;
+ }
+
+ localLocks.weakReferenceToIdentity.remove(wr);
+ localLocks.uuidToWeakReference.remove(uuid);
+ wr.clear();
+ }
+ return true;
+ }
+
+ /***************************/
+ /* 'Object' class override */
+ /***************************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean equals(Object other) {
+ if (other instanceof Identity) {
+ Identity identity = (Identity)other;
+ return uuid.equals(identity.uuid)
+ && key.equals(identity.key)
+ && ownerKey.equals(identity.ownerKey);
+ }
+ return false;
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * An instance of this class is used for 'TargetLock.context' when the
+ * lock is allocated within a Drools session. Its purpose is to ensure that
+ * the callback to 'TargetLock.owner' runs within the Drools thread.
+ */
+ private static class PolicySessionContext implements LockCallback, Serializable {
+ // the 'PolicySession' instance in question
+ PolicySession policySession;
+
+ /**
+ * Constructor - initialize the 'policySession' field.
+ *
+ * @param policySession the Drools session
+ */
+ private PolicySessionContext(PolicySession policySession) {
+ this.policySession = policySession;
+ }
+
+ /*********************/
+ /* 'Owner' interface */
+ /*********************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void lockAvailable(final Lock lock) {
+ // Run 'owner.lockAvailable' within the Drools session
+ if (policySession != null) {
+ policySession.getKieSession().insert(new DroolsRunnable() {
+ @Override
+ public void run() {
+ ((TargetLock)lock).owner.lockAvailable(lock);
+ }
+ });
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void lockUnavailable(Lock lock) {
+ // Run 'owner.unlockAvailable' within the Drools session
+ if (policySession != null) {
+ policySession.getKieSession().insert(new DroolsRunnable() {
+ @Override
+ public void run() {
+ ((TargetLock)lock).owner.lockUnavailable(lock);
+ }
+ });
+ }
+ }
+
+ /*****************/
+ /* Serialization */
+ /*****************/
+
+ /**
+ * Specializes serialization of 'PolicySessionContext'.
+ */
+ private void writeObject(ObjectOutputStream out) throws IOException {
+ // 'PolicySession' can't be serialized directly --
+ // store as 'groupId', 'artifactId', 'sessionName'
+ PolicyContainer pc = policySession.getPolicyContainer();
+
+ out.writeObject(pc.getGroupId());
+ out.writeObject(pc.getArtifactId());
+ out.writeObject(policySession.getName());
+ }
+
+ /**
+ * Specializes deserialization of 'PolicySessionContext'.
+ */
+ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
+ // 'PolicySession' can't be serialized directly --
+ // read in 'groupId', 'artifactId', 'sessionName'
+ String groupId = String.class.cast(in.readObject());
+ String artifactId = String.class.cast(in.readObject());
+ String sessionName = String.class.cast(in.readObject());
+
+ // locate the 'PolicySession' associated with
+ // 'groupId', 'artifactId', and 'sessionName'
+ for (PolicyContainer pc : PolicyContainer.getPolicyContainers()) {
+ if (artifactId.equals(pc.getArtifactId())
+ && groupId.equals(pc.getGroupId())) {
+ // found 'PolicyContainer' -- look up the session
+ policySession = pc.getPolicySession(sessionName);
+ if (policySession == null) {
+ logger.error("TargetLock.PolicySessionContext.readObject: "
+ + "Can't find session {}:{}:{}",
+ groupId, artifactId, sessionName);
+ }
+ }
+ }
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This class contains two tables that have entries for any 'TargetLock'
+ * in the 'ACTIVE' or 'WAITING' state. This is the "client" end of the
+ * lock implementation.
+ */
+ static class LocalLocks {
+ // this table makes it easier to clean up locks that have been
+ // abandoned (see 'AbandonedHandler')
+ private Map<WeakReference<TargetLock>, Identity> weakReferenceToIdentity = new IdentityHashMap<>();
+
+ // this table is used to locate a 'TargetLock' instance from a UUID
+ private Map<UUID, WeakReference<TargetLock>> uuidToWeakReference =
+ new HashMap<>();
+
+ /**
+ * Fetch the 'LocalLocks' entry associated with a particular owner key
+ * (it is created if necessary).
+ *
+ * @param ownerKey string key identifying the owner, which must hash to
+ * a bucket owned by the current host (it is typically a 'RequestID')
+ * @return the associated 'LocalLocks' instance (it should never be 'null')
+ */
+ private static LocalLocks get(String ownerKey) {
+ return Bucket.getBucket(ownerKey).getAdjunct(LocalLocks.class);
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This class contains the actual lock table, which is the "server" end
+ * of the lock implementation.
+ */
+ public static class GlobalLocks implements Serializable {
+ // this is the lock table, mapping 'key' to 'LockEntry', which indicates
+ // the current lock holder, and all those waiting
+ private Map<String, LockEntry> keyToEntry = new HashMap<>();
+
+ /**
+ * Fetch the 'GlobalLocks' entry associated with a particular key
+ * (it is created if necessary).
+ *
+ * @param key string key identifying the lock
+ * @return the associated 'GlobalLocks' instance
+ * (it should never be 'null')
+ */
+ private static GlobalLocks get(String key) {
+ return Bucket.getBucket(key).getAdjunct(GlobalLocks.class);
+ }
+
+ /**
+ * Do the 'lock' operation -- lock immediately, if possible. If not,
+ * get on the waiting list, if requested.
+ *
+ * @param key string key identifying the lock, which must hash to a bucket
+ * owned by the current host
+ * @param ownerKey string key identifying the owner
+ * @param uuid the UUID that uniquely identifies the original 'TargetLock'
+ * (on the originating host)
+ * @param waitForLock this controls the behavior when 'key' is already
+ * locked - 'true' means wait for it to be freed, 'false' means fail
+ * @return the lock State corresponding to the current request
+ */
+ synchronized State lock(String key, String ownerKey, UUID uuid, boolean waitForLock) {
+ synchronized (keyToEntry) {
+ LockEntry entry = keyToEntry.get(key);
+ if (entry == null) {
+ // there is no existing entry -- create one, and return ACTIVE
+ entry = new LockEntry(key, ownerKey, uuid);
+ keyToEntry.put(key, entry);
+ sendUpdate(key);
+ return State.ACTIVE;
+ }
+ if (waitForLock) {
+ // the requestor is willing to wait -- get on the waiting list,
+ // and return WAITING
+ entry.waitingList.add(new Waiting(ownerKey, uuid));
+ sendUpdate(key);
+ return State.WAITING;
+ }
+
+ // the requestor is not willing to wait -- return FREE,
+ // which will be interpreted as a failure
+ return State.FREE;
+ }
+ }
+
+ /**
+ * Free a lock or a pending lock request.
+ *
+ * @param key string key identifying the lock
+ * @param uuid the UUID that uniquely identifies the original 'TargetLock'
+ */
+ synchronized void unlock(String key, UUID uuid) {
+ synchronized (keyToEntry) {
+ final LockEntry entry = keyToEntry.get(key);
+ if (entry == null) {
+ logger.error("GlobalLocks.unlock: unknown lock, key={}, uuid={}",
+ key, uuid);
+ return;
+ }
+ if (entry.currentOwnerUuid.equals(uuid)) {
+ // this is the current lock holder
+ if (entry.waitingList.isEmpty()) {
+ // free this lock
+ keyToEntry.remove(key);
+ } else {
+ // pass it on to the next one in the list
+ Waiting waiting = entry.waitingList.remove();
+ entry.currentOwnerKey = waiting.ownerKey;
+ entry.currentOwnerUuid = waiting.ownerUuid;
+
+ entry.notifyNewOwner(this);
+ }
+ sendUpdate(key);
+ } else {
+ // see if one of the waiting entries is being freed
+ for (Waiting waiting : entry.waitingList) {
+ if (waiting.ownerUuid.equals(uuid)) {
+ entry.waitingList.remove(waiting);
+ sendUpdate(key);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Notify all features that an update has occurred on this GlobalLock.
+ *
+ * @param key the key associated with the change
+ * (used to locate the bucket)
+ */
+ private void sendUpdate(String key) {
+ Bucket bucket = Bucket.getBucket(key);
+ for (ServerPoolApi feature : ServerPoolApi.impl.getList()) {
+ feature.lockUpdate(bucket, this);
+ }
+ }
+
+ /*===============*/
+ /* Serialization */
+ /*===============*/
+
+ private void writeObject(ObjectOutputStream out) throws IOException {
+ synchronized (this) {
+ out.defaultWriteObject();
+ }
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Each instance of this object corresponds to a single key in the lock
+ * table. It includes the current holder of the lock, as well as
+ * any that are waiting.
+ */
+ private static class LockEntry implements Serializable {
+ // string key identifying the lock
+ String key;
+
+ // string key identifying the owner
+ String currentOwnerKey;
+
+ // UUID identifying the original 'TargetLock
+ UUID currentOwnerUuid;
+
+ // list of pending lock requests for this key
+ Queue<Waiting> waitingList = new LinkedList<>();
+
+ /**
+ * Constructor - initialize the 'LockEntry'.
+ *
+ * @param key string key identifying the lock, which must hash to a bucket
+ * owned by the current host
+ * @param ownerKey string key identifying the owner
+ * @param uuid the UUID that uniquely identifies the original 'TargetLock'
+ */
+ private LockEntry(String key, String ownerKey, UUID uuid) {
+ this.key = key;
+ this.currentOwnerKey = ownerKey;
+ this.currentOwnerUuid = uuid;
+ }
+
+ /**
+ * This method is called after the 'currentOwnerKey' and
+ * 'currentOwnerUuid' fields have been updated, and it notifies the new
+ * owner that they now have the lock.
+ *
+ * @param globalLocks the 'GlobalLocks' instance containing this entry
+ */
+ private void notifyNewOwner(final GlobalLocks globalLocks) {
+ Bucket.forwardAndProcess(currentOwnerKey, new Bucket.Message() {
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void process() {
+ // the new owner is on this host
+ incomingLocked(key, currentOwnerKey, currentOwnerUuid, 1);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void sendToServer(Server server, int bucketNumber) {
+ // the new owner is on a remote host
+ logger.info("Sending locked notification to {}: key={}, owner={}, uuid={}",
+ server, key, currentOwnerKey, currentOwnerUuid);
+ server.post("lock/locked", null, new Server.PostResponse() {
+ @Override
+ public WebTarget webTarget(WebTarget webTarget) {
+ return webTarget
+ .queryParam("key", key)
+ .queryParam("owner", currentOwnerKey)
+ .queryParam("uuid", currentOwnerUuid.toString())
+ .queryParam("ttl", timeToLive);
+ }
+
+ @Override
+ public void response(Response response) {
+ logger.info("Locked response={} (code={})",
+ response, response.getStatus());
+ switch (response.getStatus()) {
+ case NO_CONTENT: {
+ // successful -- we are done
+ break;
+ }
+
+ default: {
+ // notification failed -- free this one
+ globalLocks.unlock(key, currentOwnerUuid);
+ break;
+ }
+ }
+ }
+ });
+ }
+ });
+
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This corresponds to a member of 'LockEntry.waitingList'
+ */
+ private static class Waiting implements Serializable {
+ // string key identifying the owner
+ String ownerKey;
+
+ // uniquely identifies the new owner 'TargetLock'
+ UUID ownerUuid;
+
+ /**
+ * Constructor.
+ *
+ * @param ownerKey string key identifying the owner
+ * @param ownerUuid uniquely identifies the new owner 'TargetLock'
+ */
+ private Waiting(String ownerKey, UUID ownerUuid) {
+ this.ownerKey = ownerKey;
+ this.ownerUuid = ownerUuid;
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Backup data associated with a 'GlobalLocks' instance.
+ */
+ static class LockBackup implements Bucket.Backup {
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Bucket.Restore generate(int bucketNumber) {
+ Bucket bucket = Bucket.getBucket(bucketNumber);
+
+ // just remove 'LocalLocks' -- it will need to be rebuilt from
+ // 'TargetLock' instances
+ bucket.removeAdjunct(LocalLocks.class);
+
+ // global locks need to be transferred
+ GlobalLocks globalLocks = bucket.removeAdjunct(GlobalLocks.class);
+ return globalLocks == null ? null : new LockRestore(globalLocks);
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This class is used to restore a 'GlobalLocks' instance from a backup.
+ */
+ static class LockRestore implements Bucket.Restore, Serializable {
+ GlobalLocks globalLocks;
+
+ /**
+ * Constructor - runs as part of backup (deserialization bypasses this constructor).
+ *
+ * @param globalLocks GlobalLocks instance extracted as part of backup
+ */
+ LockRestore(GlobalLocks globalLocks) {
+ this.globalLocks = globalLocks;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void restore(int bucketNumber) {
+ // fetch bucket
+ Bucket bucket = Bucket.getBucket(bucketNumber);
+
+ // update the adjunct
+ if (bucket.putAdjunct(globalLocks) != null) {
+ logger.error("LockRestore({}): GlobalLocks adjunct already existed",
+ bucketNumber);
+ }
+
+ // notify features of the 'globalLocks' update
+ for (ServerPoolApi feature : ServerPoolApi.impl.getList()) {
+ feature.lockUpdate(bucket, globalLocks);
+ }
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This class is a deamon that monitors the 'abandoned' queue. If an
+ * ACTIVE 'TargetLock' is abandoned, the GC will eventually place the
+ * now-empty 'WeakReference' in this queue.
+ */
+ private static class AbandonedHandler extends Thread {
+ AbandonedHandler() {
+ super("TargetLock.AbandonedHandler");
+ }
+
+ /**
+ * This method camps on the 'abandoned' queue, processing entries as
+ * they are received.
+ */
+ @Override
+ public void run() {
+ while (abandonedHandler != null) {
+ try {
+ Reference<? extends TargetLock> wr = abandoned.remove();
+ TargetLock notify = null;
+
+ // At this point, we know that 'ref' is a
+ // 'WeakReference<TargetLock>' instance that has been abandoned,
+ // but we don't know what the associated 'Identity' instance
+ // is. Here, we search through every bucket looking for a
+ // matching entry. The assumption is that this is rare enough,
+ // and due to a bug, so it doesn't hurt to spend extra CPU time
+ // here. The alternative is to add some additional information
+ // to make this mapping quick, at the expense of a slight
+ // slow down of normal lock operations.
+ for (int i = 0 ; i < Bucket.BUCKETCOUNT ; i += 1) {
+ LocalLocks localLocks =
+ Bucket.getBucket(i).getAdjunctDontCreate(LocalLocks.class);
+ if (localLocks != null) {
+ // the adjunct does exist -- see if the WeakReference
+ // instance is known to this bucket
+ synchronized (localLocks) {
+ Identity identity =
+ localLocks.weakReferenceToIdentity.get(wr);
+ if (identity != null) {
+ // found it
+ logger.error("Abandoned TargetLock: bucket={}, "
+ + "key={}, ownerKey={}, uuid={}",
+ i, identity.key, identity.ownerKey,
+ identity.uuid);
+ identity.free();
+ break;
+ }
+ }
+ }
+ }
+ } catch (Exception e) {
+ logger.error("TargetLock.AbandonedHandler exception", e);
+ }
+ }
+ }
+ }
+
+ // create a single instance of 'AbandonedHandler', and start it
+ private static AbandonedHandler abandonedHandler = new AbandonedHandler();
+
+ static {
+ abandonedHandler.start();
+ }
+
+ /* ============================================================ */
+
+ /**
+ * This class handles the '/cmd/dumpLocks' REST command.
+ */
+ static class DumpLocks {
+ // indicates whether a more detailed dump should be done
+ private boolean detail;
+
+ // this table maps the 'TargetLock' UUID into an object containing
+ // both client (LocalLocks) and server (GlobalLocks) information
+ private Map<UUID, MergedData> mergedDataMap =
+ new TreeMap<>(Util.uuidComparator);
+
+ // this table maps the 'TargetLock' key into the associated 'LockEntry'
+ // (server end)
+ private Map<String, LockEntry> lockEntries = new TreeMap<>();
+
+ // this table maps the 'TargetLock' key into entries that only exist
+ // on the client end
+ private Map<String, MergedData> clientOnlyEntries = new TreeMap<>();
+
+ // display format (although it is now dynamically adjusted)
+ private String format = "%-14s %-14s %-36s %-10s %s\n";
+
+ // calculation of maximum key length for display
+ private int keyLength = 10;
+
+ // calculation of maximum owner key length for display
+ private int ownerKeyLength = 10;
+
+ // 'true' if any comments need to be displayed (affects format)
+ private boolean commentsIncluded = false;
+
+ /**
+ * Entry point for the '/cmd/dumpLocks' REST command.
+ *
+ * @param out where the output should be displayed
+ * @param detail 'true' provides additional bucket and host information
+ * (but abbreviates all UUIDs in order to avoid excessive
+ * line length)
+ */
+ static void dumpLocks(PrintStream out, boolean detail)
+ throws InterruptedException, IOException, ClassNotFoundException {
+
+ // the actual work is done in the constructor
+ new DumpLocks(out, detail);
+ }
+
+ /**
+ * Entry point for the '/lock/dumpLocksData' REST command, which generates
+ * a byte stream for this particular host.
+ *
+ * @param serverUuid the UUID of the intended destination server
+ * @param ttl similar to IP time-to-live -- it controls the number of hops
+ * the message may take
+ * @return a base64-encoded byte stream containing serialized 'HostData'
+ */
+ static byte[] dumpLocksData(UUID serverUuid, int ttl) throws IOException {
+ if (!Server.getThisServer().getUuid().equals(serverUuid)) {
+ if ((ttl -= 1) > 0) {
+ Server server = Server.getServer(serverUuid);
+ if (server != null) {
+ WebTarget webTarget =
+ server.getWebTarget("lock/dumpLocksData");
+ if (webTarget != null) {
+ logger.info("Forwarding 'lock/dumpLocksData' to uuid {}",
+ serverUuid);
+ return webTarget
+ .queryParam("server", serverUuid.toString())
+ .queryParam("ttl", String.valueOf(ttl))
+ .request().get(byte[].class);
+ }
+ }
+ }
+
+ // if we reach this point, we didn't forward for some reason
+
+ logger.error("Couldn't forward 'lock/dumpLocksData to uuid {}",
+ serverUuid);
+ return null;
+ }
+
+ return Base64.getEncoder().encode(Util.serialize(new HostData()));
+ }
+
+ /**
+ * Constructor - does the '/cmd/dumpLocks' REST command.
+ *
+ * @param out where the output should be displayed
+ */
+ DumpLocks(PrintStream out, boolean detail)
+ throws IOException, InterruptedException, ClassNotFoundException {
+
+ this.detail = detail;
+
+ // receives responses from '/lock/dumpLocksData'
+ final LinkedTransferQueue<Response> responseQueue =
+ new LinkedTransferQueue<>();
+
+ // generate a count of the number of external servers that should respond
+ int pendingResponseCount = 0;
+
+ // iterate over all of the servers
+ for (final Server server : Server.getServers()) {
+ if (server == Server.getThisServer()) {
+ // skip this server -- we will handle it differently
+ continue;
+ }
+
+ // keep a running count
+ pendingResponseCount += 1;
+ server.post("lock/dumpLocksData", null, new Server.PostResponse() {
+ @Override
+ public WebTarget webTarget(WebTarget webTarget) {
+ return webTarget
+ .queryParam("server", server.getUuid().toString())
+ .queryParam("ttl", timeToLive);
+ }
+
+ @Override
+ public void response(Response response) {
+ // responses are queued, and the main thread will collect them
+ responseQueue.put(response);
+ }
+ });
+ }
+
+ // this handles data associated with this server -- it also goes through
+ // serialization/deserialization, which provides a deep copy of the data
+ populateLockData(dumpLocksData(Server.getThisServer().getUuid(), 0));
+
+ // now, poll for responses from all of the the other servers
+ while (pendingResponseCount > 0) {
+ pendingResponseCount -= 1;
+ Response response = responseQueue.poll(60, TimeUnit.SECONDS);
+ if (response == null) {
+ // timeout -- we aren't expecting any more responses
+ break;
+ }
+
+ // populate data associated with this server
+ populateLockData(response.readEntity(byte[].class));
+ }
+
+ // we have processed all of the servers that we are going to,
+ // now generate the output
+ dump(out);
+ }
+
+ /**
+ * process base64-encoded data from a server (local or remote).
+ *
+ * @param data base64-encoded data (class 'HostData')
+ */
+ void populateLockData(byte[] data) throws IOException, ClassNotFoundException {
+ Object decodedData = Util.deserialize(Base64.getDecoder().decode(data));
+ if (decodedData instanceof HostData) {
+ // deserialized data
+ HostData hostData = (HostData)decodedData;
+
+ // fetch 'Server' instance associated with the responding server
+ Server server = Server.getServer(hostData.hostUuid);
+
+ // process the client-end data
+ for (ClientData clientData : hostData.clientDataList) {
+ // 'true' if the bucket associated with this 'ClientData'
+ // doesn't belong to the remote server, as far as we can tell
+ boolean serverMismatch =
+ Bucket.bucketToServer(clientData.bucketNumber) != server;
+
+ // each 'ClientDataRecord' instance corresponds to an
+ // active 'Identity' (TargetLock) instance
+ for (ClientDataRecord cdr : clientData.clientDataRecords) {
+ // update maximum 'key' and 'ownerKey' lengths
+ updateKeyLength(cdr.identity.key);
+ updateOwnerKeyLength(cdr.identity.ownerKey);
+
+ // fetch UUID
+ UUID uuid = cdr.identity.uuid;
+
+ // fetch/generate 'MergeData' instance for this UUID
+ MergedData md = mergedDataMap.get(uuid);
+ if (md == null) {
+ md = new MergedData(uuid);
+ mergedDataMap.put(uuid, md);
+ }
+
+ // update 'MergedData.clientDataRecord'
+ if (md.clientDataRecord == null) {
+ md.clientDataRecord = cdr;
+ } else {
+ md.comment("Duplicate client entry for UUID");
+ }
+
+ if (serverMismatch) {
+ // need to generate an additional error
+ md.comment(server.toString()
+ + "(client) does not own bucket "
+ + clientData.bucketNumber);
+ }
+ }
+ }
+
+ // process the server-end data
+ for (ServerData serverData : hostData.serverDataList) {
+ // 'true' if the bucket associated with this 'ServerData'
+ // doesn't belong to the remote server, as far as we can tell
+ boolean serverMismatch =
+ Bucket.bucketToServer(serverData.bucketNumber) != server;
+
+ // each 'LockEntry' instance corresponds to the current holder
+ // of a lock, and all requestors waiting for it to be freed
+ for (LockEntry le : serverData.globalLocks.keyToEntry.values()) {
+ // update maximum 'key' and 'ownerKey' lengths
+ updateKeyLength(le.key);
+ updateOwnerKeyLength(le.currentOwnerKey);
+
+ // fetch uuid
+ UUID uuid = le.currentOwnerUuid;
+
+ // fetch/generate 'MergeData' instance for this UUID
+ MergedData md = mergedDataMap.get(uuid);
+ if (md == null) {
+ md = new MergedData(uuid);
+ mergedDataMap.put(uuid, md);
+ }
+
+ // update 'lockEntries' table entry
+ if (lockEntries.get(le.key) != null) {
+ md.comment("Duplicate server entry for key " + le.key);
+ } else {
+ lockEntries.put(le.key, le);
+ }
+
+ // update 'MergedData.serverLockEntry'
+ // (leave 'MergedData.serverWaiting' as 'null', because
+ // this field is only used for waiting entries)
+ if (md.serverLockEntry == null) {
+ md.serverLockEntry = le;
+ } else {
+ md.comment("Duplicate server entry for UUID");
+ }
+
+ if (serverMismatch) {
+ // need to generate an additional error
+ md.comment(server.toString()
+ + "(server) does not own bucket "
+ + serverData.bucketNumber);
+ }
+
+ // we need 'MergeData' entries for all waiting requests
+ for (Waiting waiting : le.waitingList) {
+ // update maximum 'ownerKey' length
+ updateOwnerKeyLength(waiting.ownerKey);
+
+ // fetch uuid
+ uuid = waiting.ownerUuid;
+
+ // fetch/generate 'MergeData' instance for this UUID
+ md = mergedDataMap.get(uuid);
+ if (md == null) {
+ md = new MergedData(uuid);
+ mergedDataMap.put(uuid, md);
+ }
+
+ // update 'MergedData.serverLockEntry' and
+ // 'MergedData.serverWaiting'
+ if (md.serverLockEntry == null) {
+ md.serverLockEntry = le;
+ md.serverWaiting = waiting;
+ } else {
+ md.comment("Duplicate server entry for UUID");
+ }
+
+ if (serverMismatch) {
+ // need to generate an additional error
+ md.comment(server.toString()
+ + "(server) does not own bucket "
+ + serverData.bucketNumber);
+ }
+ }
+ }
+ }
+ } else {
+ logger.error("TargetLock.DumpLocks.populateLockData: "
+ + "received data has class "
+ + decodedData.getClass().getName());
+ }
+ }
+
+ /**
+ * Do some additional sanity checks on the 'MergedData', and then
+ * display all of the results.
+ *
+ * @param out where the output should be displayed
+ */
+ void dump(PrintStream out) {
+ // iterate over the 'MergedData' instances looking for problems
+ for (MergedData md : mergedDataMap.values()) {
+ if (md.clientDataRecord == null) {
+ md.comment("Client data missing");
+ } else if (md.serverLockEntry == null) {
+ md.comment("Server data missing");
+ clientOnlyEntries.put(md.clientDataRecord.identity.key, md);
+ } else if (!md.clientDataRecord.identity.key.equals(md.serverLockEntry.key)) {
+ md.comment("Client key(" + md.clientDataRecord.identity.key
+ + ") server key(" + md.serverLockEntry.key
+ + ") mismatch");
+ } else {
+ String serverOwnerKey = (md.serverWaiting == null
+ ? md.serverLockEntry.currentOwnerKey : md.serverWaiting.ownerKey);
+ if (!md.clientDataRecord.identity.ownerKey.equals(serverOwnerKey)) {
+ md.comment("Client owner key("
+ + md.clientDataRecord.identity.ownerKey
+ + ") server owner key(" + serverOwnerKey
+ + ") mismatch");
+ }
+ // TBD: test for state mismatch
+ }
+ }
+
+ if (detail) {
+ // generate format based upon the maximum key length, maximum
+ // owner key length, and whether comments are included anywhere
+ format = "%-" + keyLength + "s %6s %-9s %-" + ownerKeyLength
+ + "s %6s %-9s %-9s %-10s" + (commentsIncluded ? " %s\n" : "\n");
+
+ // dump out the header
+ out.printf(format, "Key", "Bucket", "Host UUID",
+ "Owner Key", "Bucket", "Host UUID",
+ "Lock UUID", "State", "Comments");
+ out.printf(format, "---", "------", "---------",
+ "---------", "------", "---------",
+ "---------", "-----", "--------");
+ } else {
+ // generate format based upon the maximum key length, maximum
+ // owner key length, and whether comments are included anywhere
+ format = "%-" + keyLength + "s %-" + ownerKeyLength
+ + "s %-36s %-10s" + (commentsIncluded ? " %s\n" : "\n");
+
+ // dump out the header
+ out.printf(format, "Key", "Owner Key", "UUID", "State", "Comments");
+ out.printf(format, "---", "---------", "----", "-----", "--------");
+ }
+
+ // iterate over the server table
+ for (LockEntry le : lockEntries.values()) {
+ // fetch merged data
+ MergedData md = mergedDataMap.get(le.currentOwnerUuid);
+
+ // dump out record associated with lock owner
+ if (detail) {
+ out.printf(format,
+ le.key, getBucket(le.key), bucketOwnerUuid(le.key),
+ le.currentOwnerKey, getBucket(le.currentOwnerKey),
+ bucketOwnerUuid(le.currentOwnerKey),
+ abbrevUuid(le.currentOwnerUuid),
+ md.getState(), md.firstComment());
+ } else {
+ out.printf(format,
+ le.key, le.currentOwnerKey, le.currentOwnerUuid,
+ md.getState(), md.firstComment());
+ }
+ dumpMoreComments(out, md);
+
+ // iterate over all requests waiting for this lock
+ for (Waiting waiting: le.waitingList) {
+ // fetch merged data
+ md = mergedDataMap.get(waiting.ownerUuid);
+
+ // dump out record associated with waiting request
+ if (detail) {
+ out.printf(format,
+ "", "", "",
+ waiting.ownerKey, getBucket(waiting.ownerKey),
+ bucketOwnerUuid(waiting.ownerKey),
+ abbrevUuid(waiting.ownerUuid),
+ md.getState(), md.firstComment());
+ } else {
+ out.printf(format, "", waiting.ownerKey, waiting.ownerUuid,
+ md.getState(), md.firstComment());
+ }
+ dumpMoreComments(out, md);
+ }
+ }
+
+ // client records that don't have matching server entries
+ for (MergedData md : clientOnlyEntries.values()) {
+ ClientDataRecord cdr = md.clientDataRecord;
+ if (detail) {
+ out.printf(format,
+ cdr.identity.key, getBucket(cdr.identity.key),
+ bucketOwnerUuid(cdr.identity.key),
+ cdr.identity.ownerKey,
+ getBucket(cdr.identity.ownerKey),
+ bucketOwnerUuid(cdr.identity.ownerKey),
+ abbrevUuid(cdr.identity.uuid),
+ md.getState(), md.firstComment());
+ } else {
+ out.printf(format, cdr.identity.key, cdr.identity.ownerKey,
+ cdr.identity.uuid, md.getState(), md.firstComment());
+ }
+ dumpMoreComments(out, md);
+ }
+ }
+
+ /**
+ * This method converts a String keyword into the corresponding bucket
+ * number.
+ *
+ * @param key the keyword to be converted
+ * @return the bucket number
+ */
+ private static int getBucket(String key) {
+ return Bucket.bucketNumber(key);
+ }
+
+ /**
+ * Determine the abbreviated UUID associated with a key.
+ *
+ * @param key the keyword to be converted
+ * @return the abbreviated UUID of the bucket owner
+ */
+ private static String bucketOwnerUuid(String key) {
+ // fetch the bucket
+ Bucket bucket = Bucket.getBucket(Bucket.bucketNumber(key));
+
+ // fetch the bucket owner (may be 'null' if unassigned)
+ Server owner = bucket.getOwner();
+
+ return owner == null ? "NONE" : abbrevUuid(owner.getUuid());
+ }
+
+ /**
+ * Convert a UUID to an abbreviated form, which is the
+ * first 8 hex digits of the UUID, followed by the character '*'.
+ *
+ * @param uuid the UUID to convert
+ * @return the abbreviated form
+ */
+ private static String abbrevUuid(UUID uuid) {
+ return uuid.toString().substring(0, 8) + "*";
+ }
+
+ /**
+ * If the 'MergedData' instance has more than one comment,
+ * dump out comments 2-n.
+ *
+ * @param out where the output should be displayed
+ * @param md the MergedData instance
+ */
+ void dumpMoreComments(PrintStream out, MergedData md) {
+ if (md.comments.size() > 1) {
+ Queue<String> comments = new LinkedList<>(md.comments);
+
+ // remove the first entry, because it has already been displayed
+ comments.remove();
+ for (String comment : comments) {
+ if (detail) {
+ out.printf(format, "", "", "", "", "", "", "", "", comment);
+ } else {
+ out.printf(format, "", "", "", "", comment);
+ }
+ }
+ }
+ }
+
+ /**
+ * Check the length of the specified 'key', and update 'keyLength' if
+ * it exceeds the current maximum.
+ *
+ * @param key the key to be tested
+ */
+ void updateKeyLength(String key) {
+ int length = key.length();
+ if (length > keyLength) {
+ keyLength = length;
+ }
+ }
+
+ /**
+ * Check the length of the specified 'ownerKey', and update
+ * 'ownerKeyLength' if it exceeds the current maximum.
+ *
+ * @param ownerKey the owner key to be tested
+ */
+ void updateOwnerKeyLength(String ownerKey) {
+ int length = ownerKey.length();
+ if (length > ownerKeyLength) {
+ ownerKeyLength = length;
+ }
+ }
+
+ /* ============================== */
+
+ /**
+ * Each instance of this class corresponds to client and/or server
+ * data structures, and is used to check consistency between the two.
+ */
+ class MergedData {
+ // the client/server UUID
+ UUID uuid;
+
+ // client-side data (from LocalLocks)
+ ClientDataRecord clientDataRecord = null;
+
+ // server-side data (from GlobalLocks)
+ LockEntry serverLockEntry = null;
+ Waiting serverWaiting = null;
+
+ // detected problems, such as server/client mismatches
+ Queue<String> comments = new LinkedList<String>();
+
+ /**
+ * Constructor - initialize the 'uuid'.
+ *
+ * @param uuid the UUID that identifies the original 'TargetLock'
+ */
+ MergedData(UUID uuid) {
+ this.uuid = uuid;
+ }
+
+ /**
+ * add a comment to the list, and indicate that there are now
+ * comments present.
+ *
+ * @param co the comment to add
+ */
+ void comment(String co) {
+ comments.add(co);
+ commentsIncluded = true;
+ }
+
+ /**
+ * Return the first comment, or an empty string if there are no
+ * comments.
+ *
+ * @return the first comment, or an empty string if there are no
+ * comments (useful for formatting output).
+ */
+ String firstComment() {
+ return comments.isEmpty() ? "" : comments.poll();
+ }
+
+ /**
+ * Return a string description of the state.
+ *
+ * @return a string description of the state.
+ */
+ String getState() {
+ return clientDataRecord == null
+ ? "MISSING" : clientDataRecord.state.toString();
+ }
+ }
+
+ /**
+ * This class contains all of the data sent from each host to the
+ * host that is consolidating the information for display.
+ */
+ static class HostData implements Serializable {
+ // the UUID of the host sending the data
+ UUID hostUuid;
+
+ // all of the information derived from the 'LocalLocks' data
+ List<ClientData> clientDataList;
+
+ // all of the information derived from the 'GlobalLocks' data
+ List<ServerData> serverDataList;
+
+ /**
+ * Constructor - this goes through all of the lock tables,
+ * and populates 'clientDataList' and 'serverDataList'.
+ */
+ HostData() {
+ // fetch UUID
+ hostUuid = Server.getThisServer().getUuid();
+
+ // initial storage for client and server data
+ clientDataList = new ArrayList<ClientData>();
+ serverDataList = new ArrayList<ServerData>();
+
+ // go through buckets
+ for (int i = 0 ; i < Bucket.BUCKETCOUNT ; i += 1) {
+ Bucket bucket = Bucket.getBucket(i);
+
+ // client data
+ LocalLocks localLocks =
+ bucket.getAdjunctDontCreate(LocalLocks.class);
+ if (localLocks != null) {
+ // we have client data for this bucket
+ ClientData clientData = new ClientData(i);
+ clientDataList.add(clientData);
+
+ synchronized (localLocks) {
+ for (WeakReference<TargetLock> wr :
+ localLocks.weakReferenceToIdentity.keySet()) {
+ // Note: 'targetLock' may be 'null' if it has
+ // been abandoned, and garbage collected
+ TargetLock targetLock = wr.get();
+
+ // fetch associated 'identity'
+ Identity identity =
+ localLocks.weakReferenceToIdentity.get(wr);
+ if (identity != null) {
+ // add a new 'ClientDataRecord' for this bucket
+ clientData.clientDataRecords.add(
+ new ClientDataRecord(identity,
+ (targetLock == null ? null :
+ targetLock.getState())));
+ }
+ }
+ }
+ }
+
+ // server data
+ GlobalLocks globalLocks =
+ bucket.getAdjunctDontCreate(GlobalLocks.class);
+ if (globalLocks != null) {
+ // server data is already in serializable form
+ serverDataList.add(new ServerData(i, globalLocks));
+ }
+ }
+ }
+ }
+
+ /**
+ * Information derived from the 'LocalLocks' adjunct to a single bucket.
+ */
+ static class ClientData implements Serializable {
+ // number of the bucket
+ int bucketNumber;
+
+ // all of the client locks within this bucket
+ List<ClientDataRecord> clientDataRecords;
+
+ /**
+ * Constructor - initially, there are no 'clientDataRecords'.
+ *
+ * @param bucketNumber the bucket these records are associated with
+ */
+ ClientData(int bucketNumber) {
+ this.bucketNumber = bucketNumber;
+ clientDataRecords = new ArrayList<>();
+ }
+ }
+
+ /**
+ * This corresponds to the information contained within a
+ * single 'TargetLock'.
+ */
+ static class ClientDataRecord implements Serializable {
+ // contains key, ownerKey, uuid
+ Identity identity;
+
+ // state field of 'TargetLock'
+ // (may be 'null' if there is no 'TargetLock')
+ State state;
+
+ /**
+ * Constructor - initialize the fields.
+ *
+ * @param identity contains key, ownerKey, uuid
+ * @param state the state if the 'TargetLock' exists, and 'null' if it
+ * has been garbage collected
+ */
+ ClientDataRecord(Identity identity, State state) {
+ this.identity = identity;
+ this.state = state;
+ }
+ }
+
+ /**
+ * Information derived from the 'GlobalLocks' adjunct to a single bucket.
+ */
+ static class ServerData implements Serializable {
+ // number of the bucket
+ int bucketNumber;
+
+ // server-side data associated with a single bucket
+ GlobalLocks globalLocks;
+
+ /**
+ * Constructor - initialize the fields.
+ *
+ * @param bucketNumber the bucket these records are associated with
+ * @param globalLocks GlobalLocks instance associated with 'bucketNumber'
+ */
+ ServerData(int bucketNumber, GlobalLocks globalLocks) {
+ this.bucketNumber = bucketNumber;
+ this.globalLocks = globalLocks;
+ }
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Instances of 'AuditData' are passed between servers as part of the
+ * 'TargetLock' audit.
+ */
+ static class AuditData implements Serializable {
+ // sending UUID
+ UUID hostUuid;
+
+ // client records that currently exist, or records to be cleared
+ // (depending upon message) -- client/server is from the senders side
+ List<Identity> clientData;
+
+ // server records that currently exist, or records to be cleared
+ // (depending upon message) -- client/server is from the senders side
+ List<Identity> serverData;
+
+ /**
+ * Constructor - set 'hostUuid' to the current host, and start with
+ * empty lists.
+ */
+ AuditData() {
+ hostUuid = Server.getThisServer().getUuid();
+ clientData = new ArrayList<Identity>();
+ serverData = new ArrayList<Identity>();
+ }
+
+ /**
+ * This is called when we receive an incoming 'AuditData' object from
+ * a remote host.
+ *
+ * @param includeWarnings if 'true', generate warning messages
+ * for mismatches
+ * @return an 'AuditData' instance that only contains records we
+ * can't confirm
+ */
+ AuditData generateResponse(boolean includeWarnings) {
+ AuditData response = new AuditData();
+
+ // compare remote servers client data with our server data
+ for (Identity identity : clientData) {
+ // we are the server in this case
+ Bucket bucket = Bucket.getBucket(identity.key);
+ GlobalLocks globalLocks =
+ bucket.getAdjunctDontCreate(GlobalLocks.class);
+
+ if (globalLocks != null) {
+ Map<String, LockEntry> keyToEntry = globalLocks.keyToEntry;
+ synchronized (keyToEntry) {
+ LockEntry le = keyToEntry.get(identity.key);
+ if (le != null) {
+ if (identity.uuid.equals(le.currentOwnerUuid)
+ && identity.ownerKey.equals(le.currentOwnerKey)) {
+ // we found a match
+ continue;
+ }
+
+ // check the waiting list
+ boolean match = false;
+ for (Waiting waiting : le.waitingList) {
+ if (identity.uuid.equals(waiting.ownerUuid)
+ && identity.ownerKey.equals(waiting.ownerKey)) {
+ // we found a match on the waiting list
+ match = true;
+ break;
+ }
+ }
+ if (match) {
+ // there was a match on the waiting list
+ continue;
+ }
+ }
+ }
+ }
+
+ // If we reach this point, a match was not confirmed. Note that it
+ // is possible that we got caught in a transient state, so we need
+ // to somehow make sure that we don't "correct" a problem that
+ // isn't real.
+
+ if (includeWarnings) {
+ logger.warn("TargetLock audit issue: server match not found "
+ + "(key={},ownerKey={},uuid={})",
+ identity.key, identity.ownerKey, identity.uuid);
+ }
+
+ // it was 'clientData' to the sender, but 'serverData' to us
+ response.serverData.add(identity);
+ }
+
+ // test server data
+ for (Identity identity : serverData) {
+ // we are the client in this case
+ Bucket bucket = Bucket.getBucket(identity.ownerKey);
+ LocalLocks localLocks =
+ bucket.getAdjunctDontCreate(LocalLocks.class);
+ if (localLocks != null) {
+ synchronized (localLocks) {
+ WeakReference<TargetLock> wr =
+ localLocks.uuidToWeakReference.get(identity.uuid);
+ if (wr != null) {
+ Identity identity2 =
+ localLocks.weakReferenceToIdentity.get(wr);
+ if (identity2 != null
+ && identity.key.equals(identity2.key)
+ && identity.ownerKey.equals(identity2.ownerKey)) {
+ // we have a match
+ continue;
+ }
+ }
+ }
+ }
+
+ // If we reach this point, a match was not confirmed. Note that it
+ // is possible that we got caught in a transient state, so we need
+ // to somehow make sure that we don't "correct" a problem that
+ // isn't real.
+ if (includeWarnings) {
+ logger.warn("TargetLock audit issue: client match not found "
+ + "(key={},ownerKey={},uuid={})",
+ identity.key, identity.ownerKey, identity.uuid);
+ }
+ response.clientData.add(identity);
+ }
+
+ return response;
+ }
+
+ /**
+ * The response messages contain 'Identity' objects that match those
+ * in our outgoing '/lock/audit' message, but that the remote end could
+ * not confirm. Again, the definition of 'client' and 'server' are
+ * the remote ends' version.
+ *
+ * @param server the server we sent the request to
+ */
+ void processResponse(Server server) {
+ if (clientData.isEmpty() && serverData.isEmpty()) {
+ // no mismatches
+ logger.info("TargetLock audit with {} completed -- no mismatches",
+ server);
+ return;
+ }
+
+ // There are still mismatches -- note that 'clientData' and
+ // 'serverData' are from the remote end's perspective, which is the
+ // opposite of this end
+
+ for (Identity identity : clientData) {
+ // these are on our server end -- we were showing a lock on this
+ // end, but the other end has no such client
+ logger.error("Audit mismatch (GlobalLocks): (key={},owner={},uuid={})",
+ identity.key, identity.ownerKey, identity.uuid);
+
+ // free the lock
+ GlobalLocks.get(identity.key).unlock(identity.key, identity.uuid);
+ }
+ for (Identity identity : serverData) {
+ // these are on our client end
+ logger.error("Audit mismatch (LocalLocks): (key={},owner={},uuid={})",
+ identity.key, identity.ownerKey, identity.uuid);
+
+ // clean up 'LocalLocks' tables
+ LocalLocks localLocks = LocalLocks.get(identity.ownerKey);
+ TargetLock targetLock = null;
+ synchronized (localLocks) {
+ WeakReference<TargetLock> wr =
+ localLocks.uuidToWeakReference.get(identity.uuid);
+ if (wr != null) {
+ targetLock = wr.get();
+ localLocks.weakReferenceToIdentity.remove(wr);
+ localLocks.uuidToWeakReference
+ .remove(identity.uuid);
+ wr.clear();
+ }
+ }
+
+ if (targetLock != null) {
+ // may need to update state
+ synchronized (targetLock) {
+ if (targetLock.state != State.FREE) {
+ targetLock.state = State.LOST;
+ }
+ }
+ }
+ }
+ logger.info("TargetLock audit with {} completed -- {} mismatches",
+ server, clientData.size() + serverData.size());
+ }
+
+ /**
+ * Serialize and base64-encode this 'AuditData' instance, so it can
+ * be sent in a message.
+ *
+ * @return a byte array, which can be decoded and deserialized at
+ * the other end ('null' is returned if there were any problems)
+ */
+ byte[] encode() {
+ try {
+ return Base64.getEncoder().encode(Util.serialize(this));
+ } catch (IOException e) {
+ logger.error("TargetLock.AuditData.encode Exception", e);
+ return null;
+ }
+ }
+
+ /**
+ * Base64-decode and deserialize a byte array.
+ *
+ * @param encodedData a byte array encoded via 'AuditData.encode'
+ * (typically on the remote end of a connection)
+ * @return an 'AuditData' instance if decoding was successful,
+ * and 'null' if not
+ */
+ static AuditData decode(byte[] encodedData) {
+ try {
+ Object decodedData =
+ Util.deserialize(Base64.getDecoder().decode(encodedData));
+ if (decodedData instanceof AuditData) {
+ return (AuditData)decodedData;
+ } else {
+ logger.error(
+ "TargetLock.AuditData.decode returned instance of class {}",
+ decodedData.getClass().getName());
+ return null;
+ }
+ } catch (IOException | ClassNotFoundException e) {
+ logger.error("TargetLock.AuditData.decode Exception", e);
+ return null;
+ }
+ }
+ }
+
+ /**
+ * This class contains methods that control the audit. Also, sn instance of
+ * 'Audit' is created for each audit that is in progress.
+ */
+ static class Audit {
+ // if non-null, it means that we have a timer set that periodicall
+ // triggers the audit
+ static TimerTask timerTask = null;
+
+ // maps 'Server' to audit data associated with that server
+ Map<Server,AuditData> auditMap = new IdentityHashMap<>();
+
+ /**
+ * Run a single audit cycle.
+ */
+ static void runAudit() {
+ try {
+ logger.info("Starting TargetLock audit");
+ Audit audit = new Audit();
+
+ // populate 'auditMap' table
+ audit.build();
+
+ // send to all of the servers in 'auditMap' (may include this server)
+ audit.send();
+ } catch (InterruptedException e) {
+ logger.error("TargetLock audit interrupted", e);
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ /**
+ * Schedule the audit to run periodically based upon defined properties.
+ */
+ static void scheduleAudit() {
+ scheduleAudit(auditPeriod, auditGracePeriod);
+ }
+
+ /**
+ * Schedule the audit to run periodically -- all of the hosts arrange to
+ * run their audit at a different time, evenly spaced across the audit
+ * period.
+ *
+ * @param period how frequently to run the audit, in milliseconds
+ * @param gracePeriod ensure that the audit doesn't run until at least
+ * 'gracePeriod' milliseconds have passed from the current time
+ */
+ static synchronized void scheduleAudit(final long period, final long gracePeriod) {
+
+ if (timerTask != null) {
+ // cancel current timer
+ timerTask.cancel();
+ timerTask = null;
+ }
+
+ // this needs to run in the 'MainLoop' thread, because it is dependent
+ // upon the list of servers, and our position in this list
+ MainLoop.queueWork(new Runnable() {
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void run() {
+ // current list of servers
+ Collection<Server> servers = Server.getServers();
+
+ // count of the number of servers
+ int count = servers.size();
+
+ // will contain our position in this list
+ int index = 0;
+
+ // current server
+ Server thisServer = Server.getThisServer();
+
+ for (Server server : servers) {
+ if (server == thisServer) {
+ break;
+ }
+ index += 1;
+ }
+
+ // if index == count, we didn't find this server
+ // (which shouldn't happen)
+
+ if (index < count) {
+ // The servers are ordered by UUID, and 'index' is this
+ // server's position within the list. Suppose the period is
+ // 60000 (60 seconds), and there are 5 servers -- the first one
+ // will run the audit at 0 seconds after the minute, the next
+ // at 12 seconds after the minute, then 24, 36, 48.
+ long offset = (period * index) / count;
+
+ // the earliest time we want the audit to run
+ long time = System.currentTimeMillis() + gracePeriod;
+ long startTime = time - (time % period) + offset;
+ if (startTime <= time) {
+ startTime += period;
+ }
+ synchronized (Audit.class) {
+ if (timerTask != null) {
+ timerTask.cancel();
+ }
+ timerTask = new TimerTask() {
+ @Override
+ public void run() {
+ runAudit();
+ }
+ };
+
+ // now, schedule the timer
+ Util.timer.scheduleAtFixedRate(
+ timerTask, new Date(startTime), period);
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * Handle an incoming '/lock/audit' message.
+ *
+ * @param serverUuid the UUID of the intended destination server
+ * @param ttl similar to IP time-to-live -- it controls the number of hops
+ * @param data base64-encoded data, containing a serialized 'AuditData'
+ * instance
+ * @return a serialized and base64-encoded 'AuditData' response
+ */
+ static byte[] incomingAudit(UUID serverUuid, int ttl, byte[] encodedData) {
+ if (!Server.getThisServer().getUuid().equals(serverUuid)) {
+ if ((ttl -= 1) > 0) {
+ Server server = Server.getServer(serverUuid);
+ if (server != null) {
+ WebTarget webTarget = server.getWebTarget("lock/audit");
+ if (webTarget != null) {
+ logger.info("Forwarding 'lock/audit' to uuid {}",
+ serverUuid);
+ Entity<String> entity =
+ Entity.entity(new String(encodedData),
+ MediaType.APPLICATION_OCTET_STREAM_TYPE);
+ return webTarget
+ .queryParam("server", serverUuid.toString())
+ .queryParam("ttl", String.valueOf(ttl))
+ .request().post(entity, byte[].class);
+ }
+ }
+ }
+
+ // if we reach this point, we didn't forward for some reason
+
+ logger.error("Couldn't forward 'lock/audit to uuid {}", serverUuid);
+ return null;
+ }
+
+ AuditData auditData = AuditData.decode(encodedData);
+ if (auditData != null) {
+ AuditData auditResp = auditData.generateResponse(true);
+ return auditResp.encode();
+ }
+ return null;
+ }
+
+ /**
+ * This method populates the 'auditMap' table by going through all of
+ * the client and server lock data, and sorting it according to the
+ * remote server.
+ */
+ void build() {
+ for (int i = 0 ; i < Bucket.BUCKETCOUNT ; i += 1) {
+ Bucket bucket = Bucket.getBucket(i);
+
+ // client data
+ LocalLocks localLocks =
+ bucket.getAdjunctDontCreate(LocalLocks.class);
+ if (localLocks != null) {
+ synchronized (localLocks) {
+ // we have client data for this bucket
+ for (Identity identity :
+ localLocks.weakReferenceToIdentity.values()) {
+ // find or create the 'AuditData' instance associated
+ // with the server owning the 'key'
+ AuditData auditData = getAuditData(identity.key);
+ if (auditData != null) {
+ auditData.clientData.add(identity);
+ }
+ }
+ }
+ }
+
+ // server data
+ GlobalLocks globalLocks =
+ bucket.getAdjunctDontCreate(GlobalLocks.class);
+ if (globalLocks != null) {
+ // we have server data for this bucket
+ Map<String, LockEntry> keyToEntry = globalLocks.keyToEntry;
+ synchronized (keyToEntry) {
+ for (LockEntry le : keyToEntry.values()) {
+ // find or create the 'AuditData' instance associated
+ // with the current 'ownerKey'
+ AuditData auditData = getAuditData(le.currentOwnerKey);
+ if (auditData != null) {
+ // create an 'Identity' entry, and add it to
+ // the list associated with the remote server
+ auditData.serverData.add(
+ new Identity(le.key, le.currentOwnerKey,
+ le.currentOwnerUuid));
+ }
+
+ for (Waiting waiting : le.waitingList) {
+ // find or create the 'AuditData' instance associated
+ // with the waiting entry 'ownerKey'
+ auditData = getAuditData(waiting.ownerKey);
+ if (auditData != null) {
+ // create an 'Identity' entry, and add it to
+ // the list associated with the remote server
+ auditData.serverData.add(
+ new Identity(le.key, waiting.ownerKey,
+ waiting.ownerUuid));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Find or create the 'AuditData' structure associated with a particular
+ * key.
+ */
+ AuditData getAuditData(String key) {
+ // map 'key -> bucket number', and then 'bucket number' -> 'server'
+ Server server = Bucket.bucketToServer(Bucket.bucketNumber(key));
+ if (server != null) {
+ AuditData auditData = auditMap.get(server);
+ if (auditData == null) {
+ // doesn't exist yet -- create it
+ auditData = new AuditData();
+ auditMap.put(server, auditData);
+ }
+ return auditData;
+ }
+
+ // this happens when the bucket has not been assigned to a server yet
+ return null;
+ }
+
+ /**
+ * Using the collected 'auditMap', send out the messages to all of the
+ * servers.
+ */
+ void send() throws InterruptedException {
+ if (auditMap.isEmpty()) {
+ logger.info("TargetLock audit: no locks on this server");
+ } else {
+ logger.info("TargetLock audit: sending audit information to {}",
+ auditMap.keySet());
+ }
+
+ for (final Server server : auditMap.keySet()) {
+ // fetch audit data
+ AuditData auditData = auditMap.get(server);
+
+ if (server == Server.getThisServer()) {
+ // process this locally
+ final AuditData respData = auditData.generateResponse(true);
+ if (respData.clientData.isEmpty()
+ && respData.serverData.isEmpty()) {
+ // no mismatches
+ logger.info("TargetLock.Audit.send: "
+ + "no errors from self ({})", server);
+ continue;
+ }
+
+ // do the rest in a separate thread
+ server.getThreadPool().execute(new Runnable() {
+ @Override
+ public void run() {
+ // wait a few seconds, and see if we still know of these
+ // errors
+ logger.info("TargetLock.Audit.send: "
+ + "mismatches from self ({})", server);
+ try {
+ Thread.sleep(auditRetryDelay);
+ } catch (InterruptedException e) {
+ logger.error("TargetLock.Audit.send: Interrupted "
+ + "handling audit response from self ({})",
+ server);
+ // just abort
+ Thread.currentThread().interrupt();
+ return;
+ }
+
+ // This will check against our own data -- any mismatches
+ // mean that things have changed since we sent out the
+ // first message. We will remove any mismatches from
+ // 'respData', and see if there are any left.
+ AuditData mismatches = respData.generateResponse(false);
+
+ respData.serverData.removeAll(mismatches.clientData);
+ respData.clientData.removeAll(mismatches.serverData);
+
+ if (respData.clientData.isEmpty()
+ && respData.serverData.isEmpty()) {
+ // no mismatches --
+ // there must have been transient issues on our side
+ logger.info("TargetLock.Audit.send: "
+ + "no mismatches from self "
+ + "({}) after retry", server);
+ return;
+ }
+
+ // any mismatches left in 'respData' are still issues
+ respData.processResponse(server);
+ }
+ });
+ continue;
+ }
+
+ // serialize
+ byte[] encodedData = auditData.encode();
+ if (encodedData == null) {
+ // error has already been displayed
+ continue;
+ }
+
+ // generate entity
+ Entity<String> entity =
+ Entity.entity(new String(encodedData),
+ MediaType.APPLICATION_OCTET_STREAM_TYPE);
+
+ server.post("lock/audit", entity, new Server.PostResponse() {
+ @Override
+ public WebTarget webTarget(WebTarget webTarget) {
+ // include the 'uuid' keyword
+ return webTarget
+ .queryParam("server", server.getUuid().toString())
+ .queryParam("ttl", timeToLive);
+ }
+
+ @Override
+ public void response(Response response) {
+ // process the response here
+ AuditData respData =
+ AuditData.decode(response.readEntity(byte[].class));
+ if (respData == null) {
+ logger.error("TargetLock.Audit.send: "
+ + "couldn't process response from {}",
+ server);
+ return;
+ }
+
+ // if we reach this point, we got a response
+ if (respData.clientData.isEmpty()
+ && respData.serverData.isEmpty()) {
+ // no mismatches
+ logger.info("TargetLock.Audit.send: "
+ + "no errors from {}", server);
+ return;
+ }
+
+ // wait a few seconds, and see if we still know of these
+ // errors
+ logger.info("TargetLock.Audit.send: mismatches from {}",
+ server);
+ try {
+ Thread.sleep(auditRetryDelay);
+ } catch (InterruptedException e) {
+ logger.error("TargetLock.Audit.send: Interrupted "
+ + "handling audit response from {}",
+ server);
+ // just abort
+ Thread.currentThread().interrupt();
+ return;
+ }
+
+ // This will check against our own data -- any mismatches
+ // mean that things have changed since we sent out the
+ // first message. We will remove any mismatches from
+ // 'respData', and see if there are any left.
+ AuditData mismatches = respData.generateResponse(false);
+
+ respData.serverData.removeAll(mismatches.clientData);
+ respData.clientData.removeAll(mismatches.serverData);
+
+ if (respData.clientData.isEmpty()
+ && respData.serverData.isEmpty()) {
+ // no mismatches --
+ // there must have been transient issues on our side
+ logger.info("TargetLock.Audit.send: no mismatches from "
+ + "{} after retry", server);
+ return;
+ }
+
+ // any mismatches left in 'respData' are still there --
+ // hopefully, they are transient issues on the other side
+ AuditData auditData = new AuditData();
+ auditData.clientData = respData.serverData;
+ auditData.serverData = respData.clientData;
+
+ // serialize
+ byte[] encodedData = auditData.encode();
+ if (encodedData == null) {
+ // error has already been displayed
+ return;
+ }
+
+ // generate entity
+ Entity<String> entity =
+ Entity.entity(new String(encodedData),
+ MediaType.APPLICATION_OCTET_STREAM_TYPE);
+
+ // send new list to other end
+ response = server
+ .getWebTarget("lock/audit")
+ .queryParam("server", server.getUuid().toString())
+ .queryParam("ttl", timeToLive)
+ .request().post(entity);
+
+ respData = AuditData.decode(response.readEntity(byte[].class));
+ if (respData == null) {
+ logger.error("TargetLock.auditDataBuilder.send: "
+ + "couldn't process response from {}",
+ server);
+ return;
+ }
+
+ // if there are mismatches left, they are presumably real
+ respData.processResponse(server);
+ }
+ });
+ }
+ }
+ }
+}
diff --git a/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Util.java b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Util.java
new file mode 100644
index 00000000..2ad0a401
--- /dev/null
+++ b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/Util.java
@@ -0,0 +1,181 @@
+/*
+ * ============LICENSE_START=======================================================
+ * feature-server-pool
+ * ================================================================================
+ * Copyright (C) 2020 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=========================================================
+ */
+
+package org.onap.policy.drools.serverpool;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Comparator;
+import java.util.Timer;
+import java.util.UUID;
+
+import org.apache.commons.io.IOUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class Util {
+ private static Logger logger = LoggerFactory.getLogger(Util.class);
+ // create a shared 'Timer' instance
+ public static final Timer timer = new Timer("Server Pool Timer", true);
+
+ /**
+ * Internally, UUID objects use two 'long' variables, and the default
+ * comparison is signed, which means the order for the first and 16th digit
+ * is: '89abcdef01234567', while the order for the rest is
+ * '0123456789abcdef'.
+ * The following comparator uses the ordering '0123456789abcdef' for all
+ * digits.
+ */
+ public static final Comparator<UUID> uuidComparator =
+ new Comparator<UUID>() {
+ public int compare(UUID u1, UUID u2) {
+ // compare most significant portion
+ int rval = Long.compareUnsigned(u1.getMostSignificantBits(),
+ u2.getMostSignificantBits());
+ if (rval == 0) {
+ // most significant portion matches --
+ // compare least significant portion
+ rval = Long.compareUnsigned(u1.getLeastSignificantBits(),
+ u2.getLeastSignificantBits());
+ }
+ return rval;
+ }
+ };
+
+ /* ============================================================ */
+
+ /**
+ * write a UUID to an output stream.
+ *
+ * @param ds the output stream
+ * @param uuid the uuid to write
+ */
+ public static void writeUuid(DataOutputStream ds, UUID uuid) throws IOException {
+ // write out 16 byte UUID
+ ds.writeLong(uuid.getMostSignificantBits());
+ ds.writeLong(uuid.getLeastSignificantBits());
+ }
+
+ /**
+ * read a UUID from an input stream.
+ *
+ * @param ds the input stream
+ */
+ public static UUID readUuid(DataInputStream ds) throws IOException {
+ long mostSigBits = ds.readLong();
+ long leastSigBits = ds.readLong();
+ return new UUID(mostSigBits, leastSigBits);
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Read from an 'InputStream' until EOF or until it is closed. This method
+ * may block, depending on the type of 'InputStream'.
+ *
+ * @param input This is the input stream
+ * @return A 'String' containing the contents of the input stream
+ */
+ public static String inputStreamToString(InputStream input) {
+ try {
+ return IOUtils.toString(input, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ // TODO Auto-generated catch block
+ logger.error("Util.inputStreamToString error", e);
+ return "";
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Serialize an object into a byte array.
+ *
+ * @param object the object to serialize
+ * @return a byte array containing the serialized object
+ * @throws IOException this may be an exception thrown by the output stream,
+ * a NotSerializableException if an object can't be serialized, or an
+ * InvalidClassException
+ */
+ public static byte[] serialize(Object object) throws IOException {
+ try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ ObjectOutputStream oos = new ObjectOutputStream(bos)) {
+ oos.writeObject(object);
+ oos.flush();
+ return bos.toByteArray();
+ }
+ }
+
+ /**
+ * Deserialize a byte array into an object.
+ *
+ * @param data a byte array containing the serialized object
+ * @return the deserialized object
+ * @throws IOException this may be an exception thrown by the input stream,
+ * a StreamCorrupted Exception if the information in the stream is not
+ * consistent, an OptionalDataException if the input data primitive data,
+ * rather than an object, or InvalidClassException
+ * @throws ClassNotFoundException if the class of a serialized object can't
+ * be found
+ */
+ public static Object deserialize(byte[] data) throws IOException, ClassNotFoundException {
+ try (ByteArrayInputStream bis = new ByteArrayInputStream(data);
+ ObjectInputStream ois = new ObjectInputStream(bis)) {
+ return ois.readObject();
+ }
+ }
+
+ /**
+ * Deserialize a byte array into an object.
+ *
+ * @param data a byte array containing the serialized object
+ * @param classLoader the class loader to use when locating classes
+ * @return the deserialized object
+ * @throws IOException this may be an exception thrown by the input stream,
+ * a StreamCorrupted Exception if the information in the stream is not
+ * consistent, an OptionalDataException if the input data primitive data,
+ * rather than an object, or InvalidClassException
+ * @throws ClassNotFoundException if the class of a serialized object can't
+ * be found
+ */
+ public static Object deserialize(byte[] data, ClassLoader classLoader)
+ throws IOException, ClassNotFoundException {
+
+ try (ByteArrayInputStream bis = new ByteArrayInputStream(data);
+ ExtendedObjectInputStream ois =
+ new ExtendedObjectInputStream(bis, classLoader)) {
+ return ois.readObject();
+ }
+ }
+
+ /**
+ * Shutdown the timer thread.
+ */
+ public static void shutdown() {
+ timer.cancel();
+ }
+}
diff --git a/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/persistence/Persistence.java b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/persistence/Persistence.java
new file mode 100644
index 00000000..295194d2
--- /dev/null
+++ b/feature-server-pool/src/main/java/org/onap/policy/drools/serverpool/persistence/Persistence.java
@@ -0,0 +1,875 @@
+/*
+ * ============LICENSE_START=======================================================
+ * feature-server-pool
+ * ================================================================================
+ * Copyright (C) 2020 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=========================================================
+ */
+
+package org.onap.policy.drools.serverpool.persistence;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectOutputStream;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import javax.ws.rs.Consumes;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.MediaType;
+
+import org.kie.api.event.rule.ObjectDeletedEvent;
+import org.kie.api.event.rule.ObjectInsertedEvent;
+import org.kie.api.event.rule.ObjectUpdatedEvent;
+import org.kie.api.event.rule.RuleRuntimeEventListener;
+import org.kie.api.runtime.KieSession;
+import org.onap.policy.drools.core.DroolsRunnable;
+import org.onap.policy.drools.core.PolicyContainer;
+import org.onap.policy.drools.core.PolicySession;
+import org.onap.policy.drools.core.PolicySessionFeatureApi;
+import org.onap.policy.drools.serverpool.Bucket;
+import org.onap.policy.drools.serverpool.Keyword;
+import org.onap.policy.drools.serverpool.Server;
+import org.onap.policy.drools.serverpool.ServerPoolApi;
+import org.onap.policy.drools.serverpool.TargetLock.GlobalLocks;
+import org.onap.policy.drools.serverpool.Util;
+import org.onap.policy.drools.system.PolicyControllerConstants;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class provides a persistence implementation for 'feature-server-pool',
+ * backing up the data of selected Drools sessions and server-side 'TargetLock'
+ * data on separate hosts.
+ */
+public class Persistence implements PolicySessionFeatureApi, ServerPoolApi {
+ private static Logger logger = LoggerFactory.getLogger(Persistence.class);
+
+ /***************************************/
+ /* 'PolicySessionFeatureApi' interface */
+ /***************************************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int getSequenceNumber() {
+ return 1;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void newPolicySession(PolicySession policySession) {
+ // a new Drools session is being created -- look at the properties
+ // 'persistence.<session-name>.type' and 'persistence.type' to determine
+ // whether persistence is enabled for this session
+
+ // fetch properties file
+ PolicyContainer container = policySession.getPolicyContainer();
+ Properties properties = PolicyControllerConstants.getFactory().get(
+ container.getGroupId(), container.getArtifactId()).getProperties();
+
+ // look at 'persistence.<session-name>.type', and 'persistence.type'
+ String type = properties.getProperty("persistence." + policySession.getName() + ".type");
+ if (type == null) {
+ type = properties.getProperty("persistence.type");
+ }
+
+ if ("auto".equals(type) || "native".equals(type)) {
+ // persistence is enabled this session
+ policySession.setAdjunct(PersistenceRunnable.class,
+ new PersistenceRunnable(policySession));
+ }
+ }
+
+ /*****************************/
+ /* 'ServerPoolApi' interface */
+ /*****************************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Collection<Class<?>> servletClasses() {
+ // the nested class 'Rest' contains additional REST calls
+ List<Class<?>> classes = new LinkedList<>();
+ classes.add(Rest.class);
+ return classes;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void restoreBucket(Bucket bucket) {
+ // if we reach this point, no data was received from the old server, which
+ // means we just initialized, or we did not have a clean bucket migration
+
+ ReceiverBucketData rbd = bucket.removeAdjunct(ReceiverBucketData.class);
+ if (rbd != null) {
+ // there is backup data -- do a restore
+ rbd.restoreBucket(bucket);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void lockUpdate(Bucket bucket, GlobalLocks globalLocks) {
+ // we received a notification from 'TargetLock' that 'GlobalLocks' data
+ // has changed (TBD: should any attempt be made to group updates that
+ // occur in close succession?)
+
+ sendLockDataToBackups(bucket, globalLocks);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void auditBucket(Bucket bucket, boolean isOwner, boolean isBackup) {
+ if (isOwner) {
+ // it may be that backup hosts have changed --
+ // send out lock and session data
+
+ // starting with lock data
+ GlobalLocks globalLocks =
+ bucket.getAdjunctDontCreate(GlobalLocks.class);
+ if (globalLocks != null) {
+ sendLockDataToBackups(bucket, globalLocks);
+ }
+
+ // now, session data
+ SenderBucketData sbd =
+ bucket.getAdjunctDontCreate(SenderBucketData.class);
+ if (sbd != null) {
+ synchronized (sbd) {
+ // go through all of the sessions where we have persistent data
+ for (PolicySession session : sbd.sessionData.keySet()) {
+ Object obj = session.getAdjunct(PersistenceRunnable.class);
+ if (obj instanceof PersistenceRunnable) {
+ PersistenceRunnable pr = (PersistenceRunnable)obj;
+ synchronized (pr.modifiedBuckets) {
+ // mark bucket associated with this session
+ // as modified
+ pr.modifiedBuckets.add(bucket);
+ }
+ }
+ }
+ }
+ }
+ } else if (bucket.removeAdjunct(SenderBucketData.class) != null) {
+ logger.warn("Bucket {}: Removed superfluous "
+ + "'SenderBucketData' adjunct",
+ bucket.getIndex());
+ }
+ if (!isBackup && bucket.removeAdjunct(ReceiverBucketData.class) != null) {
+ logger.warn("Bucket {}: Removed superfluous "
+ + "'ReceiverBucketData' adjunct",
+ bucket.getIndex());
+ }
+ }
+
+ /**
+ * This method supports 'lockUpdate' -- it has been moved to a separate
+ * 'static' method, so it can also be called after restoring 'GlobalLocks',
+ * so it can be backed up on its new servers.
+ *
+ * @param bucket the bucket containing the 'GlobalLocks' adjunct
+ * @param globalLocks the 'GlobalLocks' adjunct
+ */
+ private static void sendLockDataToBackups(Bucket bucket, GlobalLocks globalLocks) {
+ final int bucketNumber = bucket.getIndex();
+ SenderBucketData sbd = bucket.getAdjunct(SenderBucketData.class);
+ int lockCount = 0;
+
+ // serialize the 'globalLocks' instance
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try {
+ ObjectOutputStream oos = new ObjectOutputStream(bos);
+ synchronized (globalLocks) {
+ // the 'GlobalLocks' instance and counter are tied together
+ oos.writeObject(globalLocks);
+ lockCount = sbd.getLockCountAndIncrement();
+ }
+ oos.close();
+ } catch (IOException e) {
+ logger.error("Persistence.LockUpdate({})", bucketNumber, e);
+ return;
+ }
+
+ // convert to Base64, and generate an 'Entity' for the REST call
+ byte[] serializedData = Base64.getEncoder().encode(bos.toByteArray());
+ final Entity<String> entity =
+ Entity.entity(new String(serializedData),
+ MediaType.APPLICATION_OCTET_STREAM_TYPE);
+ final int count = lockCount;
+
+ // build list of backup servers
+ Set<Server> servers = new HashSet<>();
+ synchronized (bucket) {
+ servers.add(bucket.getPrimaryBackup());
+ servers.add(bucket.getSecondaryBackup());
+ }
+ for (final Server server : servers) {
+ if (server != null) {
+ // send out REST command
+ server.getThreadPool().execute(new Runnable() {
+ @Override
+ public void run() {
+ WebTarget webTarget =
+ server.getWebTarget("persistence/lock");
+ if (webTarget != null) {
+ webTarget
+ .queryParam("bucket", bucketNumber)
+ .queryParam("count", count)
+ .queryParam("dest", server.getUuid())
+ .request().post(entity);
+ }
+ }
+ });
+ }
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * One instance of this class exists for every Drools session that is
+ * being backed up. It implements the 'RuleRuntimeEventListener' interface,
+ * so it receives notifications of Drools object changes, and also implements
+ * the 'DroolsRunnable' interface, so it can run within the Drools session
+ * thread, which should reduce the chance of catching an object in a
+ * transient state.
+ */
+ static class PersistenceRunnable implements DroolsRunnable,
+ RuleRuntimeEventListener {
+ // this is the Drools session associated with this instance
+ private PolicySession session;
+
+ // this is the string "<groupId>:<artifactId>:<sessionName>"
+ private String encodedSessionName;
+
+ // the buckets in this session which have modifications that still
+ // need to be backed up
+ private Set<Bucket> modifiedBuckets = new HashSet<>();
+
+ /**
+ * Constructor - save the session information, and start listing for
+ * updates.
+ */
+ PersistenceRunnable(PolicySession session) {
+ PolicyContainer pc = session.getPolicyContainer();
+
+ this.session = session;
+ this.encodedSessionName =
+ pc.getGroupId() + ":" + pc.getArtifactId() + ":" + session.getName();
+ session.getKieSession().addEventListener(this);
+ }
+
+ /******************************/
+ /* 'DroolsRunnable' interface */
+ /******************************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void run() {
+ try {
+ // save a snapshot of 'modifiedBuckets'
+ Set<Bucket> saveModifiedBuckets;
+ synchronized (modifiedBuckets) {
+ saveModifiedBuckets = new HashSet<>(modifiedBuckets);
+ modifiedBuckets.clear();
+ }
+
+ // iterate over all of the modified buckets, sending update data
+ // to all of the backup servers
+ for (Bucket bucket : saveModifiedBuckets) {
+ SenderBucketData sbd =
+ bucket.getAdjunctDontCreate(SenderBucketData.class);
+ if (sbd != null) {
+ // serialization occurs within the Drools session thread
+ SenderSessionBucketData ssbd = sbd.getSessionData(session);
+ byte[] serializedData =
+ ssbd.getLatestEncodedSerializedData();
+ final int count = ssbd.getCount();
+ final Entity<String> entity =
+ Entity.entity(new String(serializedData),
+ MediaType.APPLICATION_OCTET_STREAM_TYPE);
+
+ // build list of backup servers
+ Set<Server> servers = new HashSet<>();
+ synchronized (bucket) {
+ servers.add(bucket.getPrimaryBackup());
+ servers.add(bucket.getSecondaryBackup());
+ }
+ for (final Server server : servers) {
+ if (server != null) {
+ // send out REST command
+ server.getThreadPool().execute(new Runnable() {
+ @Override
+ public void run() {
+ WebTarget webTarget =
+ server.getWebTarget("persistence/session");
+ if (webTarget != null) {
+ webTarget
+ .queryParam("bucket",
+ bucket.getIndex())
+ .queryParam("session",
+ encodedSessionName)
+ .queryParam("count", count)
+ .queryParam("dest", server.getUuid())
+ .request().post(entity);
+ }
+ }
+ });
+ }
+ }
+ }
+ }
+ } catch (Exception e) {
+ logger.error("Persistence.PersistenceRunnable.run:", e);
+ }
+ }
+
+ /****************************************/
+ /* 'RuleRuntimeEventListener' interface */
+ /****************************************/
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void objectDeleted(ObjectDeletedEvent event) {
+ // determine Drools object that was deleted
+ Object object = event.getOldObject();
+
+ // determine keyword, if any
+ String keyword = Keyword.lookupKeyword(object);
+ if (keyword == null) {
+ // no keyword, so there is no associated bucket
+ return;
+ }
+
+ // locate bucket and associated data
+ // (don't create adjunct if it isn't there -- there's nothing to delete)
+ Bucket bucket = Bucket.getBucket(keyword);
+ SenderBucketData sbd =
+ bucket.getAdjunctDontCreate(SenderBucketData.class);
+ if (sbd != null) {
+ // add bucket to 'modified' list
+ synchronized (modifiedBuckets) {
+ modifiedBuckets.add(bucket);
+ }
+
+ // update set of Drools objects in this bucket
+ sbd.getSessionData(session).objectDeleted(object);
+
+ // insert this 'DroolsRunnable' to do the backup (note that it
+ // may already be inserted from a previous update to this
+ // DroolsSession -- eventually, the rule will fire, and the 'run'
+ // method will be called)
+ session.getKieSession().insert(this);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void objectInserted(ObjectInsertedEvent event) {
+ objectChanged(event.getObject());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void objectUpdated(ObjectUpdatedEvent event) {
+ objectChanged(event.getObject());
+ }
+
+ /**
+ * A Drools session object was either inserted or updated
+ * (both are treated the same way).
+ *
+ * @param object the object being inserted or updated
+ */
+ private void objectChanged(Object object) {
+ // determine keyword, if any
+ String keyword = Keyword.lookupKeyword(object);
+ if (keyword == null) {
+ // no keyword, so there is no associated bucket
+ return;
+ }
+
+ // locate bucket and associated data
+ Bucket bucket = Bucket.getBucket(keyword);
+ SenderBucketData sbd = bucket.getAdjunct(SenderBucketData.class);
+
+ // add bucket to 'modified' list
+ synchronized (modifiedBuckets) {
+ modifiedBuckets.add(bucket);
+ }
+
+ // update set of Drools objects in this bucket
+ sbd.getSessionData(session).objectChanged(object);
+
+ // insert this 'DroolsRunnable' to do the backup (note that it
+ // may already be inserted from a previous update to this
+ // DroolsSession -- eventually, the rule will fire, and the 'run'
+ // method will be called)
+ session.getKieSession().insert(this);
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Per-session data for a single bucket on the sender's side.
+ */
+ static class SenderSessionBucketData {
+ // the set of all objects in the session associated with this bucket
+ Map<Object,Object> droolsObjects = new IdentityHashMap<>();
+
+ // used by the receiver to determine whether an update is really newer
+ int count = 0;
+
+ // serialized base64 form of 'droolsObjects'
+ // (TBD: determine if we are getting any benefit from caching this)
+ byte[] encodedSerializedData = null;
+
+ // 'true' means that 'encodedSerializedData' is out-of-date
+ boolean rebuildNeeded = true;
+
+ /**
+ * Notification that a Drools object associated with this bucket
+ * was deleted.
+ *
+ * @param object the object that was deleted
+ */
+ synchronized void objectDeleted(Object object) {
+ if (droolsObjects.remove(object) != null) {
+ rebuildNeeded = true;
+ }
+ }
+
+ /**
+ * Notification that a Drools object associated with this bucket
+ * was inserted or updated.
+ *
+ * @param object the object that was updated
+ */
+ synchronized void objectChanged(Object object) {
+ droolsObjects.put(object, object);
+ rebuildNeeded = true;
+ }
+
+ /**
+ * Serialize and base64-encode the objects in this Drools session.
+ *
+ * @return a byte array containing the encoded serialized objects
+ */
+ synchronized byte[] getLatestEncodedSerializedData() {
+ if (rebuildNeeded) {
+ try {
+ // this should be run in the Drools session thread in order
+ // to avoid transient data
+ encodedSerializedData =
+ Base64.getEncoder().encode(Util.serialize(droolsObjects));
+ count += 1;
+ } catch (IOException e) {
+ logger.error("Persistence.SenderSessionBucketData."
+ + "getLatestEncodedSerializedData: ", e);
+ }
+ rebuildNeeded = false;
+ }
+ return encodedSerializedData;
+ }
+
+ /**
+ * Return a counter that will be used for update comparison.
+ *
+ * @return the value of a counter that can be used to determine whether
+ * an update is really newer than the previous update
+ */
+ synchronized int getCount() {
+ return count;
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Data for a single bucket on the sender's side.
+ */
+ public static class SenderBucketData {
+ // maps session name into SenderSessionBucketData
+ Map<PolicySession, SenderSessionBucketData> sessionData =
+ new IdentityHashMap<>();
+
+ // used by the receiver to determine whether an update is really newer
+ int lockCount = 0;
+
+ /**
+ * Create or fetch the 'SenderSessionBucketData' instance associated
+ * with the specified session.
+ *
+ * @param session the 'PolicySession' object
+ * @return the associated 'SenderSessionBucketData' instance
+ */
+ synchronized SenderSessionBucketData getSessionData(PolicySession session) {
+ // try to fetch the associated instance
+ SenderSessionBucketData rval = sessionData.get(session);
+ if (rval == null) {
+ // it doesn't exist, so create one
+ rval = new SenderSessionBucketData();
+ sessionData.put(session, rval);
+ }
+ return rval;
+ }
+
+ /**
+ * Return a counter that will be used for update comparison.
+ *
+ * @return the value of a counter that can be used to determine whether
+ * an update is really newer than the previous update
+ */
+ int getLockCountAndIncrement() {
+ // note that this is synchronized using the 'GlobalLocks' instance
+ // within the same bucket
+ return lockCount++;
+ }
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Data for a single bucket and session on the receiver's side.
+ */
+ static class ReceiverSessionBucketData {
+ // used to determine whether an update is really newer
+ int count = -1;
+
+ // serialized base64 form of 'droolsObjects'
+ byte[] encodedSerializedData = null;
+ }
+
+ /* ============================================================ */
+
+ /**
+ * Data for a single bucket on the receiver's side -- this adjunct is used
+ * to store encoded data on a backup host. It will only be needed if the
+ * bucket owner fails.
+ */
+ public static class ReceiverBucketData {
+ // maps session name into encoded data
+ Map<String, ReceiverSessionBucketData> sessionData = new HashMap<>();
+
+ // used by the receiver to determine whether an update is really newer
+ int lockCount = -1;
+
+ // encoded lock data
+ byte[] lockData = null;
+
+ /**
+ * This method is called in response to the '/persistence/session'
+ * REST message. It stores the base64-encoded and serialized data
+ * for a particular bucket and session.
+ *
+ * @param bucketNumber identifies the bucket
+ * @param sessionName identifies the Drools session
+ * @param count counter used to determine whether data is really newer
+ * @param data base64-encoded serialized data for this bucket and session
+ */
+ static void receiveSession(int bucketNumber, String sessionName, int count, byte[] data) {
+ // fetch the bucket
+ Bucket bucket = Bucket.getBucket(bucketNumber);
+
+ // create/fetch the 'ReceiverBucketData' adjunct
+ ReceiverBucketData rbd = bucket.getAdjunct(ReceiverBucketData.class);
+ synchronized (rbd) {
+ // update the session data
+ ReceiverSessionBucketData rsbd = rbd.sessionData.get(sessionName);
+ if (rsbd == null) {
+ rsbd = new ReceiverSessionBucketData();
+ rbd.sessionData.put(sessionName, rsbd);
+ }
+
+ if ((count - rsbd.count) > 0 || count == 0) {
+ // this is new data
+ rsbd.count = count;
+ rsbd.encodedSerializedData = data;
+ }
+ }
+ }
+
+ /**
+ * This method is called in response to the '/persistence/lock'
+ * REST message. It stores the base64-encoded and serialized
+ * server-side lock data associated with this bucket.
+ *
+ * @param bucketNumber identifies the bucket
+ * @param count counter used to determine whether data is really newer
+ * @param data base64-encoded serialized lock data for this bucket
+ */
+ static void receiveLockData(int bucketNumber, int count, byte[] data) {
+ // fetch the bucket
+ Bucket bucket = Bucket.getBucket(bucketNumber);
+
+ // create/fetch the 'ReceiverBucketData' adjunct
+ ReceiverBucketData rbd = bucket.getAdjunct(ReceiverBucketData.class);
+ synchronized (rbd) {
+ // update the lock data
+ if ((count - rbd.lockCount) > 0 || count == 0) {
+ rbd.lockCount = count;
+ rbd.lockData = data;
+ }
+ }
+ }
+
+ /**
+ * This method is called when a bucket is being restored from persistent
+ * data, which indicates that a clean migration didn't occur.
+ * Drools session and/or lock data is restored.
+ *
+ * @param bucket the bucket being restored
+ */
+ synchronized void restoreBucket(Bucket bucket) {
+ // one entry for each Drools session being restored --
+ // indicates when the restore is complete (restore runs within
+ // the Drools session thread)
+ List<CountDownLatch> sessionLatches = new LinkedList<>();
+ for (String sessionName : sessionData.keySet()) {
+ // [0]="<groupId>" [1]="<artifactId>", [2]="<sessionName>"
+ String[] nameSegments = sessionName.split(":");
+ PolicySession policySession = null;
+
+ // locate the 'PolicyContainer' and 'PolicySession'
+ if (nameSegments.length == 3) {
+ // step through all 'PolicyContainer' instances looking
+ // for a matching 'artifactId' & 'groupId'
+ for (PolicyContainer pc : PolicyContainer.getPolicyContainers()) {
+ if (nameSegments[1].equals(pc.getArtifactId())
+ && nameSegments[0].equals(pc.getGroupId())) {
+ // 'PolicyContainer' matches -- try to fetch the session
+ policySession = pc.getPolicySession(nameSegments[2]);
+ break;
+ }
+ }
+ }
+
+ if (policySession == null) {
+ logger.error("Persistence.ReceiverBucketData.restoreBucket: "
+ + "Can't find PolicySession{}", sessionName);
+ continue;
+ }
+
+ Object obj = null;
+ try {
+ // deserialization needs to use the correct 'ClassLoader'
+ ReceiverSessionBucketData rsbd = sessionData.get(sessionName);
+ obj = Util.deserialize(Base64.getDecoder().decode(rsbd.encodedSerializedData),
+ policySession.getPolicyContainer().getClassLoader());
+ } catch (IOException | ClassNotFoundException | IllegalArgumentException e) {
+ logger.error("Persistence.ReceiverBucketData.restoreBucket: "
+ + "Failed to read data for session '{}'",
+ sessionName, e);
+
+ // can't decode -- skip this session
+ continue;
+ }
+
+ if (!(obj instanceof Map)) {
+ logger.error("Persistence.ReceiverBucketData.restoreBucket: "
+ + "Session '{}' data has class {}, expected 'Map'",
+ sessionName, obj.getClass().getName());
+
+ // wrong object type decoded -- skip this session
+ continue;
+ }
+
+ // if we reach this point, we have decoded the persistent data
+
+ final Map<?,?> droolsObjects = (Map<?,?>) obj;
+
+ // signal when restore is complete
+ final CountDownLatch sessionLatch = new CountDownLatch(1);
+
+ // 'KieSession' object
+ final KieSession kieSession = policySession.getKieSession();
+
+ // run the following within the Drools session thread
+ kieSession.insert(new DroolsRunnable() {
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void run() {
+ try {
+ // insert all of the Drools objects into the session
+ for (Object obj : droolsObjects.keySet()) {
+ kieSession.insert(obj);
+ }
+ } finally {
+ // signal completion
+ sessionLatch.countDown();
+ }
+ }
+ });
+
+ // add this to the set of 'CountDownLatch's we are waiting for
+ sessionLatches.add(sessionLatch);
+ }
+
+ // restore lock data
+ if (lockData != null) {
+ Object obj = null;
+ try {
+ // decode lock data
+ obj = Util.deserialize(Base64.getDecoder().decode(lockData));
+ if (obj instanceof GlobalLocks) {
+ bucket.putAdjunct(obj);
+
+ // send out updated date
+ sendLockDataToBackups(bucket, (GlobalLocks)obj);
+ } else {
+ logger.error("Persistence.ReceiverBucketData.restoreBucket: "
+ + "Expected 'GlobalLocks', got '{}'",
+ obj.getClass().getName());
+ }
+ } catch (IOException | ClassNotFoundException | IllegalArgumentException e) {
+ logger.error("Persistence.ReceiverBucketData.restoreBucket: "
+ + "Failed to read lock data", e);
+ // skip the lock data
+ }
+
+ }
+
+ // wait for all of the sessions to update
+ try {
+ for (CountDownLatch sessionLatch : sessionLatches) {
+ if (!sessionLatch.await(10000L, TimeUnit.MILLISECONDS)) {
+ logger.error("{}: timed out waiting for session latch",
+ this);
+ }
+ }
+ } catch (InterruptedException e) {
+ logger.error("Exception in {}", this, e);
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ /* ============================================================ */
+
+ @Path("/")
+ public static class Rest {
+ /**
+ * Handle the '/persistence/session' REST call.
+ */
+ @POST
+ @Path("/persistence/session")
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ public void receiveSession(@QueryParam("bucket") int bucket,
+ @QueryParam("session") String sessionName,
+ @QueryParam("count") int count,
+ @QueryParam("dest") UUID dest,
+ byte[] data) {
+ logger.debug("/persistence/session: (bucket={},session={},count={}) "
+ + "got {} bytes of data",
+ bucket, sessionName, count, data.length);
+ if (dest == null || dest.equals(Server.getThisServer().getUuid())) {
+ ReceiverBucketData.receiveSession(bucket, sessionName, count, data);
+ } else {
+ // This host is not the intended destination -- this could happen
+ // if it was sent from another site. Leave off the 'dest' param
+ // when forwarding the message, to ensure that we don't have
+ // an infinite forwarding loop, if the site data happens to be bad.
+ Server server;
+ WebTarget webTarget;
+
+ if ((server = Server.getServer(dest)) != null
+ && (webTarget =
+ server.getWebTarget("persistence/session")) != null) {
+ Entity<String> entity =
+ Entity.entity(new String(data),
+ MediaType.APPLICATION_OCTET_STREAM_TYPE);
+ webTarget
+ .queryParam("bucket", bucket)
+ .queryParam("session", sessionName)
+ .queryParam("count", count)
+ .request().post(entity);
+ }
+ }
+ }
+
+ /**
+ * Handle the '/persistence/lock' REST call.
+ */
+ @POST
+ @Path("/persistence/lock")
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ public void receiveLockData(@QueryParam("bucket") int bucket,
+ @QueryParam("count") int count,
+ @QueryParam("dest") UUID dest,
+ byte[] data) {
+ logger.debug("/persistence/lock: (bucket={},count={}) "
+ + "got {} bytes of data", bucket, count, data.length);
+ if (dest == null || dest.equals(Server.getThisServer().getUuid())) {
+ ReceiverBucketData.receiveLockData(bucket, count, data);
+ } else {
+ // This host is not the intended destination -- this could happen
+ // if it was sent from another site. Leave off the 'dest' param
+ // when forwarding the message, to ensure that we don't have
+ // an infinite forwarding loop, if the site data happens to be bad.
+ Server server;
+ WebTarget webTarget;
+
+ if ((server = Server.getServer(dest)) != null
+ && (webTarget = server.getWebTarget("persistence/lock")) != null) {
+ Entity<String> entity =
+ Entity.entity(new String(data),
+ MediaType.APPLICATION_OCTET_STREAM_TYPE);
+ webTarget
+ .queryParam("bucket", bucket)
+ .queryParam("count", count)
+ .request().post(entity);
+ }
+ }
+ }
+ }
+}