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()); + } + } +}