diff --git a/plugin/src/main/groovy/org/unify4j/common/Byte4j.java b/plugin/src/main/groovy/org/unify4j/common/Byte4j.java
new file mode 100644
index 0000000..5a4b59c
--- /dev/null
+++ b/plugin/src/main/groovy/org/unify4j/common/Byte4j.java
@@ -0,0 +1,67 @@
+package org.unify4j.common;
+
+public class Byte4j {
+ protected static final char[] hexes = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'}; // Array of hexadecimal characters used for encoding bytes
+
+ /**
+ * Decodes a hexadecimal string into a byte array.
+ *
+ * @param s the hexadecimal string to decode
+ * @return the decoded byte array, or null if the input string length is not even
+ */
+ public static byte[] decode(final String s) {
+ final int len = s.length();
+ // Check if the length of the string is even
+ if (len % 2 != 0) {
+ return null;
+ }
+ byte[] bytes = new byte[len / 2];
+ int pos = 0;
+ // Loop through the string two characters at a time
+ for (int i = 0; i < len; i += 2) {
+ byte hi = (byte) Character.digit(s.charAt(i), 16);
+ byte lo = (byte) Character.digit(s.charAt(i + 1), 16);
+ // Combine the high and low nibbles into a single byte
+ bytes[pos++] = (byte) (hi * 16 + lo);
+ }
+ return bytes;
+ }
+
+ /**
+ * Converts a byte array into a string of hexadecimal digits.
+ *
+ * @param bytes the byte array to encode
+ * @return a string containing the hexadecimal representation of the byte array
+ */
+ public static String encode(final byte[] bytes) {
+ // Use StringBuilder to construct the hexadecimal string
+ StringBuilder sb = new StringBuilder(bytes.length << 1);
+ // Loop through each byte and convert it to two hexadecimal characters
+ for (byte aByte : bytes) {
+ sb.append(convertDigit(aByte >> 4));
+ sb.append(convertDigit(aByte & 0x0f));
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Converts a value (0 .. 15) to the corresponding hexadecimal digit.
+ *
+ * @param value the value to convert
+ * @return the corresponding hexadecimal character ('0'..'F')
+ */
+ private static char convertDigit(final int value) {
+ return hexes[value & 0x0f];
+ }
+
+ /**
+ * Checks if a byte array is gzip compressed.
+ *
+ * @param bytes the byte array to check
+ * @return true if the byte array is gzip compressed, false otherwise
+ */
+ public static boolean isGzipped(byte[] bytes) {
+ // Gzip files start with the magic number 0x1f8b
+ return bytes[0] == (byte) 0x1f && bytes[1] == (byte) 0x8b;
+ }
+}
diff --git a/plugin/src/main/groovy/org/unify4j/common/Encryption4j.java b/plugin/src/main/groovy/org/unify4j/common/Encryption4j.java
new file mode 100644
index 0000000..cf4d097
--- /dev/null
+++ b/plugin/src/main/groovy/org/unify4j/common/Encryption4j.java
@@ -0,0 +1,330 @@
+package org.unify4j.common;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.security.Key;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.AlgorithmParameterSpec;
+import java.util.Objects;
+
+public class Encryption4j {
+
+ /**
+ * Computes the MD5 hash of a file using a FileChannel and a direct ByteBuffer.
+ *
+ * @param file the file for which to compute the MD5 hash
+ * @return the MD5 hash of the file as a hexadecimal string, or null if an error occurs
+ */
+ public static String fastMD5(File file) {
+ try (FileInputStream in = new FileInputStream(file)) {
+ return calculateFileHash(in.getChannel(), getMD5Digest());
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Computes the SHA-1 hash of a file using a FileChannel and a direct ByteBuffer.
+ *
+ * @param file the file for which to compute the SHA-1 hash
+ * @return the SHA-1 hash of the file as a hexadecimal string, or null if an error occurs
+ */
+ public static String fastSHA1(File file) {
+ try (FileInputStream in = new FileInputStream(file)) {
+ return calculateFileHash(in.getChannel(), getSHA1Digest());
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Computes the SHA-256 hash of a file using a FileChannel and a direct ByteBuffer.
+ *
+ * @param file the file for which to compute the SHA-256 hash
+ * @return the SHA-256 hash of the file as a hexadecimal string, or null if an error occurs
+ */
+ public static String fastSHA256(File file) {
+ try (FileInputStream in = new FileInputStream(file)) {
+ return calculateFileHash(in.getChannel(), getSHA256Digest());
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Computes the SHA-512 hash of a file using a FileChannel and a direct ByteBuffer.
+ *
+ * @param file the file for which to compute the SHA-512 hash
+ * @return the SHA-512 hash of the file as a hexadecimal string, or null if an error occurs
+ */
+ public static String fastSHA512(File file) {
+ try (FileInputStream in = new FileInputStream(file)) {
+ return calculateFileHash(in.getChannel(), getSHA512Digest());
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Calculates the hash of a file using a given MessageDigest.
+ *
+ * @param ch the FileChannel to read from
+ * @param d the MessageDigest to update with the file data
+ * @return the hash of the file as a hexadecimal string
+ * @throws IOException if an I/O error occurs
+ */
+ public static String calculateFileHash(FileChannel ch, MessageDigest d) throws IOException {
+ ByteBuffer bb = ByteBuffer.allocateDirect(65536);
+ int nRead;
+ while ((nRead = ch.read(bb)) != -1) {
+ if (nRead == 0) {
+ continue;
+ }
+ bb.position(0);
+ bb.limit(nRead);
+ d.update(bb);
+ bb.clear();
+ }
+ return Byte4j.encode(d.digest());
+ }
+
+ /**
+ * Calculates the MD5 hash of a byte array.
+ *
+ * @param bytes the byte array to hash
+ * @return the MD5 hash as a hexadecimal string
+ */
+ public static String calculateMD5Hash(byte[] bytes) {
+ return calculateHash(getMD5Digest(), bytes);
+ }
+
+ /**
+ * Returns a MessageDigest instance for the specified algorithm.
+ *
+ * @param digest the name of the algorithm
+ * @return the MessageDigest instance
+ * @throws IllegalArgumentException if the algorithm is not available
+ */
+ public static MessageDigest getDigest(String digest) {
+ try {
+ return MessageDigest.getInstance(digest);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalArgumentException(String.format("The requested MessageDigest (%s) does not exist", digest), e);
+ }
+
+ }
+
+ /**
+ * Returns a MessageDigest instance for MD5.
+ *
+ * @return the MD5 MessageDigest instance
+ */
+ public static MessageDigest getMD5Digest() {
+ return getDigest("MD5");
+ }
+
+ /**
+ * Calculates the SHA-1 hash of a byte array.
+ *
+ * @param bytes the byte array to hash
+ * @return the SHA-1 hash as a hexadecimal string
+ */
+ public static String calculateSHA1Hash(byte[] bytes) {
+ return calculateHash(getSHA1Digest(), bytes);
+ }
+
+ /**
+ * Returns a MessageDigest instance for SHA-1.
+ *
+ * @return the SHA-1 MessageDigest instance
+ */
+ public static MessageDigest getSHA1Digest() {
+ return getDigest("SHA-1");
+ }
+
+ /**
+ * Calculates the SHA-256 hash of a byte array.
+ *
+ * @param bytes the byte array to hash
+ * @return the SHA-256 hash as a hexadecimal string
+ */
+ public static String calculateSHA256Hash(byte[] bytes) {
+ return calculateHash(getSHA256Digest(), bytes);
+ }
+
+ /**
+ * Returns a MessageDigest instance for SHA-256.
+ *
+ * @return the SHA-256 MessageDigest instance
+ */
+ public static MessageDigest getSHA256Digest() {
+ return getDigest("SHA-256");
+ }
+
+ /**
+ * Calculates the SHA-512 hash of a byte array.
+ *
+ * @param bytes the byte array to hash
+ * @return the SHA-512 hash as a hexadecimal string
+ */
+ public static String calculateSHA512Hash(byte[] bytes) {
+ return calculateHash(getSHA512Digest(), bytes);
+ }
+
+ /**
+ * Returns a MessageDigest instance for SHA-512.
+ *
+ * @return the SHA-512 MessageDigest instance
+ */
+ public static MessageDigest getSHA512Digest() {
+ return getDigest("SHA-512");
+ }
+
+ /**
+ * Creates a byte array suitable for use as an encryption key from a string key.
+ *
+ * @param key the string key
+ * @param bitsNeeded the number of bits needed for the encryption key
+ * @return the byte array of the encryption key
+ */
+ public static byte[] createCipherBytes(String key, int bitsNeeded) {
+ String word = calculateMD5Hash(key.getBytes(StandardCharsets.UTF_8));
+ return word.substring(0, bitsNeeded / 8).getBytes(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Creates an AES encryption cipher using a given key.
+ *
+ * @param key the encryption key
+ * @return the AES encryption cipher
+ * @throws Exception if an error occurs creating the cipher
+ */
+ public static Cipher createAesEncryptionCipher(String key) throws Exception {
+ return createAesCipher(key, Cipher.ENCRYPT_MODE);
+ }
+
+ /**
+ * Creates an AES decryption cipher using a given key.
+ *
+ * @param key the decryption key
+ * @return the AES decryption cipher
+ * @throws Exception if an error occurs creating the cipher
+ */
+ public static Cipher createAesDecryptionCipher(String key) throws Exception {
+ return createAesCipher(key, Cipher.DECRYPT_MODE);
+ }
+
+ /**
+ * Creates an AES cipher using a given key and mode.
+ *
+ * @param key the encryption/decryption key
+ * @param mode the cipher mode (Cipher.ENCRYPT_MODE or Cipher.DECRYPT_MODE)
+ * @return the AES cipher
+ * @throws Exception if an error occurs creating the cipher
+ */
+ public static Cipher createAesCipher(String key, int mode) throws Exception {
+ Key sKey = new SecretKeySpec(createCipherBytes(key, 128), "AES");
+ return createAesCipher(sKey, mode);
+ }
+
+ /**
+ * Creates a Cipher from the passed in key, using the passed in mode.
+ *
+ * @param key the SecretKeySpec
+ * @param mode the cipher mode (Cipher.ENCRYPT_MODE or Cipher.DECRYPT_MODE)
+ * @return the Cipher instance
+ * @throws Exception if an error occurs creating the cipher
+ */
+ public static Cipher createAesCipher(Key key, int mode) throws Exception {
+ MessageDigest d = getMD5Digest(); // Use password key as seed for IV (must be 16 bytes)
+ d.update(key.getEncoded());
+ byte[] iv = d.digest();
+ AlgorithmParameterSpec paramSpec = new IvParameterSpec(iv);
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); // CBC faster than CFB8/NoPadding (but file length changes)
+ cipher.init(mode, key, paramSpec);
+ return cipher;
+ }
+
+ /**
+ * Encrypts a string using AES-128 and returns the encrypted content as a hexadecimal string.
+ *
+ * @param key the encryption key
+ * @param content the content to be encrypted
+ * @return the encrypted content as a hexadecimal string
+ */
+ public static String encrypt(String key, String content) {
+ try {
+ return Byte4j.encode(createAesEncryptionCipher(key).doFinal(content.getBytes(StandardCharsets.UTF_8)));
+ } catch (Exception e) {
+ throw new IllegalStateException("Error occurred encrypting data", e);
+ }
+ }
+
+ /**
+ * Encrypts a byte array using AES-128 and returns the encrypted content as a hexadecimal string.
+ *
+ * @param key the encryption key
+ * @param content the byte array to be encrypted
+ * @return the encrypted content as a hexadecimal string
+ */
+ public static String encryptBytes(String key, byte[] content) {
+ try {
+ return Byte4j.encode(createAesEncryptionCipher(key).doFinal(content));
+ } catch (Exception e) {
+ throw new IllegalStateException("Error occurred encrypting data", e);
+ }
+ }
+
+ /**
+ * Decrypts a hexadecimal string using AES-128 and returns the original content as a string.
+ *
+ * @param key the decryption key
+ * @param hexStr the encrypted content as a hexadecimal string
+ * @return the original content as a string
+ */
+ public static String decrypt(String key, String hexStr) {
+ try {
+ return new String(createAesDecryptionCipher(key).doFinal(Objects.requireNonNull(Byte4j.decode(hexStr))));
+ } catch (Exception e) {
+ throw new IllegalStateException("Error occurred decrypting data", e);
+ }
+ }
+
+ /**
+ * Decrypts a hexadecimal string using AES-128 and returns the original content as a byte array.
+ *
+ * @param key the decryption key
+ * @param hexStr the encrypted content as a hexadecimal string
+ * @return the original content as a byte array
+ */
+ public static byte[] decryptBytes(String key, String hexStr) {
+ try {
+ return createAesDecryptionCipher(key).doFinal(Objects.requireNonNull(Byte4j.decode(hexStr)));
+ } catch (Exception e) {
+ throw new IllegalStateException("Error occurred decrypting data", e);
+ }
+ }
+
+ /**
+ * Calculates a hash from a byte array using a given MessageDigest.
+ *
+ * @param d the MessageDigest to update
+ * @param bytes the byte array to hash
+ * @return the hash as a hexadecimal string
+ */
+ public static String calculateHash(MessageDigest d, byte[] bytes) {
+ if (bytes == null) {
+ return null;
+ }
+ d.update(bytes);
+ return Byte4j.encode(d.digest());
+ }
+}
diff --git a/plugin/src/main/groovy/org/unify4j/common/Os4j.java b/plugin/src/main/groovy/org/unify4j/common/Os4j.java
index 8133eac..b8398c1 100644
--- a/plugin/src/main/groovy/org/unify4j/common/Os4j.java
+++ b/plugin/src/main/groovy/org/unify4j/common/Os4j.java
@@ -134,4 +134,18 @@ public static String withNormalised(String path) {
return new String(chars, 0, dst);
}
}
+
+ /**
+ * Fetch value from environment variable and if not set, then fetch from
+ * System properties. If neither available, return null.
+ *
+ * @param var String key of variable to return
+ */
+ public static String getExternalVariable(String var) {
+ String value = System.getProperty(var);
+ if (String4j.isEmpty(value)) {
+ value = System.getenv(var);
+ }
+ return String4j.isEmpty(value) ? null : value;
+ }
}
diff --git a/plugin/src/main/groovy/org/unify4j/common/String4j.java b/plugin/src/main/groovy/org/unify4j/common/String4j.java
index 231e98e..aeb8e61 100644
--- a/plugin/src/main/groovy/org/unify4j/common/String4j.java
+++ b/plugin/src/main/groovy/org/unify4j/common/String4j.java
@@ -760,4 +760,44 @@ public static String stripAccents(String str) {
public static String unAccents(String str) {
return Normalizer.normalize(str, Normalizer.Form.NFD).replaceAll("[^\\p{ASCII}]", "");
}
+
+ /**
+ * Checks if a CharSequence is empty (""), null or whitespace only.
+ *
+ * @param cs the CharSequence to check, may be null
+ * @return {@code true} if the CharSequence is null, empty or whitespace only
+ */
+ public static boolean isWhitespace(CharSequence cs) {
+ int strLen = length(cs);
+ if (strLen == 0) {
+ return true;
+ }
+ for (int i = 0; i < strLen; i++) {
+ if (!Character.isWhitespace(cs.charAt(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Checks if a String is not empty (""), not null and not whitespace only.
+ *
+ * @param s the CharSequence to check, may be null
+ * @return {@code true} if the CharSequence is
+ * not empty and not null and not whitespace only
+ */
+ public static boolean hasContent(String s) {
+ return !isWhitespace(s);
+ }
+
+ /**
+ * Gets a CharSequence length or {@code 0} if the CharSequence is {@code null}.
+ *
+ * @param cs a CharSequence or {@code null}
+ * @return CharSequence length or {@code 0} if the CharSequence is {@code null}.
+ */
+ public static int length(CharSequence cs) {
+ return cs == null ? 0 : cs.length();
+ }
}
diff --git a/plugin/src/main/groovy/org/unify4j/common/UniqueId4j.java b/plugin/src/main/groovy/org/unify4j/common/UniqueId4j.java
new file mode 100644
index 0000000..3eea3e3
--- /dev/null
+++ b/plugin/src/main/groovy/org/unify4j/common/UniqueId4j.java
@@ -0,0 +1,242 @@
+package org.unify4j.common;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import static java.lang.Integer.parseInt;
+import static java.lang.Math.abs;
+import static java.lang.System.currentTimeMillis;
+
+/**
+ * Generate a unique ID that fits within a long value. The ID will be unique for the given JVM, and it makes a
+ * solid attempt to ensure uniqueness in a clustered environment. An environment variable JAVA_UTIL_CLUSTERID
+ * can be set to a value 0-99 to mark this JVM uniquely in the cluster. If this environment variable is not set,
+ * then hostname, cluster id, and finally a SecureRandom value from 0-99 is chosen for the machine's id within cluster.
+ *
+ * There is an API [getUniqueId()] to get a unique ID that will work through the year 5138. This API will generate
+ * unique IDs at a rate of up to 1 million per second. There is another API [getUniqueId19()] that will work through
+ * the year 2286, however this API will generate unique IDs at a rate up to 10 million per second. The trade-off is
+ * the faster API will generate positive IDs only good for about 286 years [after 2000].
+ *
+ * The IDs are guaranteed to be strictly increasing. There is an API you can call (getDate(unique)) that will return
+ * the date and time (to the millisecond) that the ID was created.
+ *
+ * @author John DeRegnaucourt (jdereg@gmail.com)
+ * @author Roger Judd (@HonorKnight on GitHub) for adding code to ensure increasing order.
+ *
+ * Copyright (c) Cedar Software LLC
+ *
+ * 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
+ *
+ * License
+ *
+ * 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.
+ */
+@SuppressWarnings("SpellCheckingInspection")
+public class UniqueId4j {
+ public static final String JAVA_UTIL_CLUSTER_ID = "JAVA_UTIL_CLUSTERID";
+ protected static final Logger logger = LoggerFactory.getLogger(UniqueId4j.class);
+
+ public UniqueId4j() {
+ super();
+ }
+
+ protected static final Lock lock = new ReentrantLock();
+ protected static final Lock lock19 = new ReentrantLock();
+ protected static int count = 0;
+ protected static int count2 = 0;
+ protected static long previousTimeMilliseconds = 0;
+ protected static long previousTimeMilliseconds2 = 0;
+ protected static final int serverId;
+ protected static final Map lastIds = new LinkedHashMap() {
+ protected boolean removeEldestEntry(Map.Entry eldest) {
+ return size() > 1000;
+ }
+ };
+ protected static final Map lastIdsFull = new LinkedHashMap() {
+ protected boolean removeEldestEntry(Map.Entry eldest) {
+ return size() > 10_000;
+ }
+ };
+
+ static {
+ int id = getServerId(JAVA_UTIL_CLUSTER_ID);
+ String setVia = "environment variable: " + JAVA_UTIL_CLUSTER_ID;
+ if (id == -1) {
+ String envName = Os4j.getExternalVariable(JAVA_UTIL_CLUSTER_ID);
+ if (String4j.hasContent(envName)) {
+ String envValue = Os4j.getExternalVariable(envName);
+ id = getServerId(envValue);
+ setVia = "environment variable: " + envName;
+ }
+ if (id == -1) { // Try Cloud Foundry instance index
+ id = getServerId("CF_INSTANCE_INDEX");
+ setVia = "environment variable: CF_INSTANCE_INDEX";
+ if (id == -1) {
+ String hostName = Os4j.getExternalVariable("HOSTNAME");
+ if (String4j.isEmpty(hostName)) {
+ SecureRandom random = new SecureRandom(); // use random number if all else fails
+ id = abs(random.nextInt()) % 100;
+ setVia = "new SecureRandom()";
+ } else {
+ String hostnameSha256 = Encryption4j.calculateSHA256Hash(hostName.getBytes(StandardCharsets.UTF_8));
+ id = (byte) ((hostnameSha256.charAt(0) & 0xFF) % 100);
+ setVia = "environment variable hostname: " + hostName + " (" + hostnameSha256 + ")";
+ }
+ }
+ }
+ }
+ logger.info("server_id: {} for last two digits of generated unique IDs. Set using {}", id, setVia);
+ serverId = id;
+ }
+
+ /**
+ * ID format will be 1234567890123.999.99 (no dots - only there for clarity - the number is a long). There are
+ * 13 digits for time - good until 2286, and then it will be 14 digits (good until 5138) for time - milliseconds
+ * since Jan 1, 1970. This is followed by a count that is 000 through 999. This is followed by a random 2 digit
+ * number. This number is chosen when the JVM is started and then stays fixed until next restart. This is to
+ * ensure cluster uniqueness.
+ *
+ * Because there is the possibility two machines could choose the same random number and be at the same count, at the
+ * same time, a unique machine index is chosen to provide a 00 to 99 value for machine instance within a cluster.
+ * To set the unique machine index value, set the environment variable JAVA_UTIL_CLUSTERID to a unique two-digit
+ * number on each machine in the cluster. If the machines are in a managed container, the uniqueId will use the
+ * hash of the hostname, first byte of hash, modulo 100 to provide unique machine ID. If neither of these
+ * environment variables are set, it will resort to using a secure random number from 00 to 99 for the machine
+ * instance number portion of the unique ID.
+ *
+ * This API is slower than the 19 digit API. Grabbing a bunch of IDs in a tight loop for example, could cause
+ * delays while it waits for the millisecond to tick over. This API can return up to 1,000 unique IDs per millisecond.
+ *
+ * The IDs returned are guaranteed to be strictly increasing.
+ *
+ * @return long unique ID
+ */
+ public static long getUniqueId() {
+ lock.lock();
+ try {
+ long id = getUniqueIdAttempt();
+ while (lastIds.containsKey(id)) {
+ id = getUniqueIdAttempt();
+ }
+ lastIds.put(id, null);
+ return id;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * ID format will be 1234567890123.9999.99 (no dots - only there for clarity - the number is a long). There are
+ * 13 digits for time - milliseconds since Jan 1, 1970. This is followed by a count that is 0000 through 9999.
+ * This is followed by a random 2-digit number. This number is chosen when the JVM is started and then stays fixed
+ * until next restart. This is to ensure uniqueness within cluster.
+ *
+ * Because there is the possibility two machines could choose the same random number and be at the same count, at the
+ * same time, a unique machine index is chosen to provide a 00 to 99 value for machine instance within a cluster.
+ * To set the unique machine index value, set the environment variable JAVA_UTIL_CLUSTERID to a unique two-digit
+ * number on each machine in the cluster. If the machines are in a managed container, the uniqueId will use the
+ * hash of the hostname, first byte of hash, modulo 100 to provide unique machine ID. If neither of these
+ * environment variables are set, will it resort to using a secure random number from 00 to 99 for the machine
+ * instance number portion of the unique ID.
+ *
+ * The returned ID will be 19 digits and this API will work through 2286. After then, it will return negative
+ * numbers (still unique).
+ *
+ * This API is faster than the 18 digit API. This API can return up to 10,000 unique IDs per millisecond.
+ *
+ * The IDs returned are guaranteed to be strictly increasing.
+ *
+ * @return long unique ID
+ */
+ public static long getUniqueId19() {
+ lock19.lock();
+ try {
+ long id = getFullUniqueId19();
+ while (lastIdsFull.containsKey(id)) {
+ id = getFullUniqueId19();
+ }
+ lastIdsFull.put(id, null);
+ return id;
+ } finally {
+ lock19.unlock();
+ }
+ }
+
+ /**
+ * Find out when the ID was generated.
+ *
+ * @param uniqueId long unique ID that was generated from the .getUniqueId() API
+ * @return Date when the ID was generated, with the time portion accurate to the millisecond. The time
+ * is measured in milliseconds, between the time the id was generated and midnight, January 1, 1970 UTC.
+ */
+ public static Date getDate(long uniqueId) {
+ return new Date(uniqueId / 100_000);
+ }
+
+ /**
+ * Find out when the ID was generated. "19" version.
+ *
+ * @param uniqueId long unique ID that was generated from the .getUniqueId19() API
+ * @return Date when the ID was generated, with the time portion accurate to the millisecond. The time
+ * is measured in milliseconds, between the time the id was generated and midnight, January 1, 1970 UTC.
+ */
+ public static Date getDate19(long uniqueId) {
+ return new Date(uniqueId / 1_000_000);
+ }
+
+ // Use up to 19 digits (much faster)
+ private static long getFullUniqueId19() {
+ count2++;
+ if (count2 >= 10_000) {
+ count2 = 0;
+ }
+ long currentTimeMilliseconds = currentTimeMillis();
+ if (currentTimeMilliseconds > previousTimeMilliseconds2) {
+ count2 = 0;
+ previousTimeMilliseconds2 = currentTimeMilliseconds;
+ }
+ return currentTimeMilliseconds * 1_000_000 + count2 * 100L + serverId;
+ }
+
+ private static long getUniqueIdAttempt() {
+ count++;
+ if (count >= 1000) {
+ count = 0;
+ }
+ long currentTimeMilliseconds = currentTimeMillis();
+ if (currentTimeMilliseconds > previousTimeMilliseconds) {
+ count = 0;
+ previousTimeMilliseconds = currentTimeMilliseconds;
+ }
+ return currentTimeMilliseconds * 100_000 + count * 100L + serverId;
+ }
+
+ private static int getServerId(String externalVarName) {
+ try {
+ String id = Os4j.getExternalVariable(externalVarName);
+ if (String4j.isEmpty(id)) {
+ return -1;
+ }
+ return abs(parseInt(id)) % 100;
+ } catch (Throwable e) {
+ logger.error("Unable to get unique server id or index from environment variable/system property key-value: {}, by an exception: {}", externalVarName, e.getMessage(), e);
+ e.printStackTrace(System.err);
+ return -1;
+ }
+ }
+}
diff --git a/plugin/src/test/groovy/org/unify4j/UniqueIdTest.java b/plugin/src/test/groovy/org/unify4j/UniqueIdTest.java
new file mode 100644
index 0000000..ea85d13
--- /dev/null
+++ b/plugin/src/test/groovy/org/unify4j/UniqueIdTest.java
@@ -0,0 +1,264 @@
+package org.unify4j;
+
+import org.junit.Test;
+import org.unify4j.common.UniqueId4j;
+
+import java.util.Date;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import static java.lang.Math.abs;
+import static java.lang.System.currentTimeMillis;
+import static org.junit.Assert.assertEquals;
+import static org.unify4j.common.UniqueId4j.*;
+
+public class UniqueIdTest {
+ protected static final int bucketSize = 200000;
+
+ @Test
+ public void testIdLengths() {
+ long id18 = getUniqueId();
+ long id19 = getUniqueId19();
+
+ assert String.valueOf(id18).length() == 18;
+ assert String.valueOf(id19).length() == 19;
+ }
+
+ @Test
+ public void testIDtoDate() {
+ long id = getUniqueId();
+ Date date = getDate(id);
+ assert abs(date.getTime() - currentTimeMillis()) < 2;
+
+ id = getUniqueId19();
+ date = getDate19(id);
+ assert abs(date.getTime() - currentTimeMillis()) < 2;
+ }
+
+ @Test
+ public void testUniqueIdGeneration() {
+ int testSize = 100000;
+ Long[] keep = new Long[testSize];
+ Long[] keep19 = new Long[testSize];
+
+ for (int i = 0; i < testSize; i++) {
+ keep[i] = getUniqueId();
+ keep19[i] = getUniqueId19();
+ }
+
+ Set unique = new HashSet<>(testSize);
+ Set unique19 = new HashSet<>(testSize);
+ for (int i = 0; i < testSize; i++) {
+ unique.add(keep[i]);
+ unique19.add(keep19[i]);
+ }
+ assertEquals(unique.size(), testSize);
+ assertEquals(unique19.size(), testSize);
+
+ assertMonotonicallyIncreasing(keep);
+ assertMonotonicallyIncreasing(keep19);
+ }
+
+ @Test
+ public void speedTest() {
+ long start = System.currentTimeMillis();
+ int count = 0;
+ while (System.currentTimeMillis() < start + 1000) {
+ UniqueId4j.getUniqueId19();
+ count++;
+ }
+ System.out.println("count = " + count);
+ }
+
+ @Test
+ public void testConcurrency() {
+ final CountDownLatch startLatch = new CountDownLatch(1);
+ int numTests = 4;
+ final CountDownLatch finishedLatch = new CountDownLatch(numTests);
+
+ // 18 digit ID buckets
+ final Set bucket1 = new LinkedHashSet<>();
+ final Set bucket2 = new LinkedHashSet<>();
+ final Set bucket3 = new LinkedHashSet<>();
+ final Set bucket4 = new LinkedHashSet<>();
+
+ // 19 digit ID buckets
+ final Set bucketA = new LinkedHashSet<>();
+ final Set bucketB = new LinkedHashSet<>();
+ final Set bucketC = new LinkedHashSet<>();
+ final Set bucketD = new LinkedHashSet<>();
+
+ Runnable test1 = () -> {
+ await(startLatch);
+ fillBucket(bucket1);
+ fillBucket19(bucketA);
+ finishedLatch.countDown();
+ };
+
+ Runnable test2 = () -> {
+ await(startLatch);
+ fillBucket(bucket2);
+ fillBucket19(bucketB);
+ finishedLatch.countDown();
+ };
+
+ Runnable test3 = () -> {
+ await(startLatch);
+ fillBucket(bucket3);
+ fillBucket19(bucketC);
+ finishedLatch.countDown();
+ };
+
+ Runnable test4 = () -> {
+ await(startLatch);
+ fillBucket(bucket4);
+ fillBucket19(bucketD);
+ finishedLatch.countDown();
+ };
+
+ long start = System.nanoTime();
+ ExecutorService executor = Executors.newFixedThreadPool(numTests);
+ executor.execute(test1);
+ executor.execute(test2);
+ executor.execute(test3);
+ executor.execute(test4);
+
+ startLatch.countDown(); // trigger all threads to begin
+ await(finishedLatch); // wait for all threads to finish
+
+ long end = System.nanoTime();
+ System.out.println("(end - start) / 1000000.0 = " + (end - start) / 1000000.0);
+
+ assertMonotonicallyIncreasing(bucket1.toArray(new Long[]{}));
+ assertMonotonicallyIncreasing(bucket2.toArray(new Long[]{}));
+ assertMonotonicallyIncreasing(bucket3.toArray(new Long[]{}));
+ assertMonotonicallyIncreasing(bucket4.toArray(new Long[]{}));
+
+ assertMonotonicallyIncreasing(bucketA.toArray(new Long[]{}));
+ assertMonotonicallyIncreasing(bucketB.toArray(new Long[]{}));
+ assertMonotonicallyIncreasing(bucketC.toArray(new Long[]{}));
+ assertMonotonicallyIncreasing(bucketD.toArray(new Long[]{}));
+
+ // Assert that there are no duplicates between any buckets
+ // Compare:
+ // 1->2, 1->3, 1->4
+ // 2->3, 2->4
+ // 3->4
+ // That covers all combinations. Each bucket has 3 comparisons (can be on either side of the comparison).
+ Set copy = new HashSet<>(bucket1);
+ assert bucket1.size() == bucketSize;
+ bucket1.retainAll(bucket2);
+ assert bucket1.isEmpty();
+ bucket1.addAll(copy);
+
+ assert bucket1.size() == bucketSize;
+ bucket1.retainAll(bucket3);
+ assert bucket1.isEmpty();
+ bucket1.addAll(copy);
+
+ assert bucket1.size() == bucketSize;
+ bucket1.retainAll(bucket4);
+ assert bucket1.isEmpty();
+ bucket1.addAll(copy);
+
+ // Assert that there are no duplicates between bucket2 and any of the other buckets (bucket1/bucket2 has already been checked).
+ copy = new HashSet<>(bucket2);
+ assert bucket2.size() == bucketSize;
+ bucket2.retainAll(bucket3);
+ assert bucket2.isEmpty();
+ bucket2.addAll(copy);
+
+ assert bucket2.size() == bucketSize;
+ bucket2.retainAll(bucket4);
+ assert bucket2.isEmpty();
+ bucket2.addAll(copy);
+
+ // Assert that there are no duplicates between bucket3 and any of the other buckets (bucket3 has already been compared to 1 & 2)
+ copy = new HashSet<>(bucket3);
+ assert bucket3.size() == bucketSize;
+ bucket3.retainAll(bucket4);
+ assert bucket3.isEmpty();
+ bucket3.addAll(copy);
+
+ // Assert that there are no duplicates between bucketA and any of the other buckets (19 digit buckets).
+ copy = new HashSet<>(bucketA);
+ assert bucketA.size() == bucketSize;
+ bucketA.retainAll(bucketB);
+ assert bucketA.isEmpty();
+ bucketA.addAll(copy);
+
+ assert bucketA.size() == bucketSize;
+ bucketA.retainAll(bucketC);
+ assert bucketA.isEmpty();
+ bucketA.addAll(copy);
+
+ assert bucketA.size() == bucketSize;
+ bucketA.retainAll(bucketD);
+ assert bucketA.isEmpty();
+ bucketA.addAll(copy);
+
+ // Assert that there are no duplicates between bucket2 and any of the other buckets (bucketA/bucketB has already been checked).
+ copy = new HashSet<>(bucketB);
+ assert bucketB.size() == bucketSize;
+ bucketB.retainAll(bucketC);
+ assert bucketB.isEmpty();
+ bucketB.addAll(copy);
+
+ assert bucketB.size() == bucketSize;
+ bucketB.retainAll(bucketD);
+ assert bucketB.isEmpty();
+ bucketB.addAll(copy);
+
+ // Assert that there are no duplicates between bucket3 and any of the other buckets (bucketC has already been compared to A & B)
+ copy = new HashSet<>(bucketC);
+ assert bucketC.size() == bucketSize;
+ bucketC.retainAll(bucketD);
+ assert bucketC.isEmpty();
+ bucketC.addAll(copy);
+
+ executor.shutdown();
+ }
+
+ private void assertMonotonicallyIncreasing(Long[] ids) {
+ final long len = ids.length;
+ long prevId = -1;
+ for (int i = 0; i < len; i++) {
+ long id = ids[i];
+ if (prevId != -1) {
+ if (prevId >= id) {
+ System.out.println("index = " + i);
+ System.out.println(prevId);
+ System.out.println(id);
+ System.out.flush();
+ assert false : "ids are not monotonically increasing";
+ }
+ }
+ prevId = id;
+ }
+ }
+
+ @SuppressWarnings({"CallToPrintStackTrace"})
+ private void await(CountDownLatch latch) {
+ try {
+ latch.await();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private void fillBucket(Set bucket) {
+ for (int i = 0; i < bucketSize; i++) {
+ bucket.add(getUniqueId());
+ }
+ }
+
+ private void fillBucket19(Set bucket) {
+ for (int i = 0; i < bucketSize; i++) {
+ bucket.add(getUniqueId19());
+ }
+ }
+}