diff --git a/pom.xml b/pom.xml index aa849568694..3e2486f6112 100644 --- a/pom.xml +++ b/pom.xml @@ -56,6 +56,7 @@ 1.0.0.Final 3.20.2 1.11.4 + 4.3.0 1.14.11 3.1.0 1.9.4 @@ -117,6 +118,7 @@ 0.11.2 20231013 1.19.0 + 2.3.0 4.13.2 5.12.0 1.12.0 @@ -149,6 +151,8 @@ 2.3.5.Final 17.0.1.Final + + 2.9.9 5.4.0 3.1.4 2.12.2 @@ -1137,6 +1141,11 @@ + + org.awaitility + awaitility + ${version.awaitutility} + org.eclipse.emf org.eclipse.emf.common @@ -1469,6 +1478,12 @@ ${version.weld-test} test + + org.junit-pioneer + junit-pioneer + ${version.junit-pioneer} + test + org.junit.jupiter junit-jupiter diff --git a/web-services/cached-results/src/main/java/datawave/webservice/results/cached/CachedResultsBean.java b/web-services/cached-results/src/main/java/datawave/webservice/results/cached/CachedResultsBean.java index f416641273e..39a2bcaa377 100644 --- a/web-services/cached-results/src/main/java/datawave/webservice/results/cached/CachedResultsBean.java +++ b/web-services/cached-results/src/main/java/datawave/webservice/results/cached/CachedResultsBean.java @@ -240,6 +240,7 @@ public class CachedResultsBean { private AccumuloConnectionRequestBean accumuloConnectionRequestBean; @Inject + @SpringBean(name = "queryLimiter") private QueryLimiter queryLimiter; protected static final String COMMA = ","; diff --git a/web-services/deploy/application/pom.xml b/web-services/deploy/application/pom.xml index 1ca5fd78d00..86ff5c9ebe9 100644 --- a/web-services/deploy/application/pom.xml +++ b/web-services/deploy/application/pom.xml @@ -49,6 +49,12 @@ gov.nsa.datawave datawave-metrics-core ${project.version} + + + org.yaml + snakeyaml + + gov.nsa.datawave @@ -63,6 +69,10 @@ gov.nsa.datawave.core datawave-core-connection-pool + + org.yaml + snakeyaml + diff --git a/web-services/deploy/configuration/src/main/resources/datawave/query/QueryLimiterFactory.xml b/web-services/deploy/configuration/src/main/resources/datawave/query/QueryLimiterFactory.xml index 4ecb5b1cf38..278a95345b9 100644 --- a/web-services/deploy/configuration/src/main/resources/datawave/query/QueryLimiterFactory.xml +++ b/web-services/deploy/configuration/src/main/resources/datawave/query/QueryLimiterFactory.xml @@ -146,10 +146,42 @@ - + + + + + + + + + + + + + + + + diff --git a/web-services/query/pom.xml b/web-services/query/pom.xml index 9abcbc0d2b2..1d47b4d981f 100644 --- a/web-services/query/pom.xml +++ b/web-services/query/pom.xml @@ -9,7 +9,20 @@ datawave-ws-query ejb ${project.artifactId} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + + ${version.wildfly.jackson} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + ${version.wildfly.jackson} + com.google.code.gson gson @@ -99,6 +112,10 @@ 3.0 jar + + org.awaitility + awaitility + org.easymock easymock @@ -249,6 +266,11 @@ javassist test + + org.junit-pioneer + junit-pioneer + test + org.mockito mockito-inline diff --git a/web-services/query/src/main/java/datawave/webservice/query/cache/QueryExpirationBean.java b/web-services/query/src/main/java/datawave/webservice/query/cache/QueryExpirationBean.java index 8db89c2ff18..330dfa69d52 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/cache/QueryExpirationBean.java +++ b/web-services/query/src/main/java/datawave/webservice/query/cache/QueryExpirationBean.java @@ -43,45 +43,27 @@ public class QueryExpirationBean { private static final Logger log = Logger.getLogger(QueryExpirationBean.class); - private QueryCache queryCache; - private QueryExpirationProperties config; - private AccumuloConnectionFactory connectionFactory; - private CreatedQueryLogicCacheBean queryLogicCacheBean; - private QueryMetricsBean metricsBean; - private QueryLimiter queryLimiter; - - private boolean clearAll = false; - @Inject - public void setQueryCache(QueryCache cache) { - this.queryCache = cache; - } + private QueryCache queryCache; @Inject @SpringBean(refreshable = true) - public void setQueryExpirationProperties(QueryExpirationProperties properties) { - this.config = properties; - } + private QueryExpirationProperties config; @Inject - public void setConnectionFactory(AccumuloConnectionFactory connectionFactory) { - this.connectionFactory = connectionFactory; - } + private AccumuloConnectionFactory connectionFactory; @Inject - public void setCreatedQueryLogicCacheBean(CreatedQueryLogicCacheBean cacheBean) { - this.queryLogicCacheBean = cacheBean; - } + private CreatedQueryLogicCacheBean queryLogicCacheBean; @Inject - public void setQueryMetricsBean(QueryMetricsBean metrics) { - this.metricsBean = metrics; - } + private QueryMetricsBean metricsBean; @Inject - public void setQueryLimiter(QueryLimiter queryLimiter) { - this.queryLimiter = queryLimiter; - } + @SpringBean(name = "queryLimiter") + private QueryLimiter queryLimiter; + + private boolean clearAll = false; @PostConstruct public void init() { diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/ActiveQueryTracker.java b/web-services/query/src/main/java/datawave/webservice/query/limit/ActiveQueryTracker.java index 669b0daf260..7902841469e 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/ActiveQueryTracker.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/ActiveQueryTracker.java @@ -1,15 +1,11 @@ package datawave.webservice.query.limit; -import java.io.File; -import java.net.URI; +import static datawave.webservice.zookeeper.ZkUtils.EMPTY_DATA; + import java.util.ArrayList; import java.util.List; import java.util.Set; -import java.util.Timer; -import java.util.TimerTask; import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiFunction; import java.util.function.Function; @@ -17,16 +13,18 @@ import org.apache.curator.framework.CuratorFrameworkFactory; import org.apache.curator.framework.recipes.nodes.PersistentNode; import org.apache.curator.retry.RetryNTimes; -import org.apache.hadoop.fs.Path; import org.apache.log4j.Logger; import org.apache.zookeeper.CreateMode; import org.apache.zookeeper.KeeperException; import org.apache.zookeeper.data.Stat; -import org.apache.zookeeper.server.quorum.QuorumPeer; -import org.apache.zookeeper.server.quorum.QuorumPeerConfig; +import org.apache.zookeeper.server.quorum.QuorumPeerConfig.ConfigException; + +import datawave.webservice.zookeeper.LockedZkClientDispatcher; +import datawave.webservice.zookeeper.ZkUtils; /** - * This class provides methods for leveraging Zookeeper to track queries and their active status. + * This class provides methods for leveraging Zookeeper to track queries and their active status. It is expected that only one instance of an + * {@link ActiveQueryTracker} will exist at a time within a singleton {@link QueryLimiter}, and the Zookeeper logic herein adheres to that assumption. */ public class ActiveQueryTracker implements AutoCloseable { @@ -34,58 +32,34 @@ public class ActiveQueryTracker implements AutoCloseable { private static final Logger log = Logger.getLogger(ActiveQueryTracker.class); - private static final byte[] EMPTY_DATA = new byte[0]; - private static final String DISTINCT_QUERY_LOGICS_CONTAINER_PATH = "/distinctQueryLogics"; private static final String SYSTEMS_CONTAINER_PATH = "/systems"; private static final String USERS_CONTAINER_PATH = "/users"; - private final String zookeeperConfig; - private final long cleanUpClientInterval; - private final Lock clientLock = new ReentrantLock(); - - private CuratorFramework client; - private long lastClientAccess; - private Timer clientCleanupTimer; + private final CuratorFrameworkFactory.Builder clientFactory; + private LockedZkClientDispatcher clientDispatcher; /** - * Create and return a new {@link ActiveQueryTracker} instance + * Create and return a new {@link ActiveQueryTracker} instance. * * @param zookeeperConfig * the zookeeper config * @param clientCleanupInterval * the interval in milliseconds after which the zookeeper client should be cleaned up since its last access - * @throws QuorumPeerConfig.ConfigException + * @throws ConfigException * if an error occurs when verifying the zookeeper configuration */ - public ActiveQueryTracker(String zookeeperConfig, long clientCleanupInterval) throws QuorumPeerConfig.ConfigException { - this.zookeeperConfig = getQuorumPeerConfig(zookeeperConfig); - this.cleanUpClientInterval = clientCleanupInterval; - } - - private static String getQuorumPeerConfig(String zookeeperConfig) throws QuorumPeerConfig.ConfigException { - URI zookeeperConfigFile; - try { - zookeeperConfigFile = new Path(zookeeperConfig).toUri(); - if (new File(zookeeperConfigFile).exists()) { - QuorumPeerConfig zooConfig = new QuorumPeerConfig(); - zooConfig.parse(zookeeperConfigFile.getPath()); - StringBuilder sb = new StringBuilder(); - for (QuorumPeer.QuorumServer server : zooConfig.getServers().values()) { - if (sb.length() > 0) { - sb.append(','); - } - sb.append(server.addr.getReachableOrOne().getHostName()).append(':').append(zooConfig.getClientPortAddress().getPort()); - } - if (sb.length() == 0) { - sb.append(zooConfig.getClientPortAddress().getHostName()).append(':').append(zooConfig.getClientPortAddress().getPort()); - } - return sb.toString(); - } - } catch (IllegalArgumentException e) { - // Try the zookeeper config as is. - } - return zookeeperConfig; + public ActiveQueryTracker(String zookeeperConfig, long clientCleanupInterval) throws ConfigException { + zookeeperConfig = ZkUtils.getQuorumPeerConfig(zookeeperConfig); + // @formatter:off + clientFactory = CuratorFrameworkFactory.builder() + .namespace(ZOOKEEPER_NAMESPACE) + .connectString(zookeeperConfig) + .sessionTimeoutMs(60000) + .connectionTimeoutMs(60000) + .retryPolicy(new RetryNTimes(10, 1000)); + // @formatter:on + clientDispatcher = new LockedZkClientDispatcher(clientFactory, clientCleanupInterval, clientCleanupInterval, TimeUnit.MILLISECONDS); } /** @@ -135,11 +109,10 @@ public QueryHeartbeat trackQuery(String queryId, String userDn, String system, S log.trace("Tracking query: queryId=" + queryId + ", user='" + userDn + "', system='" + system + "', queryLogic='" + queryLogic + "'"); } - clientLock.lock(); - try { - // Initialize the client if needed. - initClient(); + try (LockedZkClientDispatcher.LockedClient lockedClient = clientDispatcher.getLockedClient()) { try { + CuratorFramework client = lockedClient.getClient(); + // Verify we are not already tracking the query. String systemQueryIdPath = getSystemQueryIdPath(system, queryLogic, queryId); Stat stat = client.checkExists().forPath(systemQueryIdPath); @@ -167,12 +140,13 @@ public QueryHeartbeat trackQuery(String queryId, String userDn, String system, S } // Create ephemeral nodes for the query ID. These nodes will not persist beyond the lifetime of the client created here. - CuratorFramework client = createClient(); + CuratorFramework heartbeatClient = clientFactory.build(); + heartbeatClient.start(); List nodes = new ArrayList<>(); - nodes.add(new PersistentNode(client, CreateMode.EPHEMERAL, false, systemQueryIdPath, EMPTY_DATA, false)); + nodes.add(new PersistentNode(heartbeatClient, CreateMode.EPHEMERAL, false, systemQueryIdPath, EMPTY_DATA, false)); if (systemCountsAgainstUserLimit) { String userQueryIdPath = getUserQueryIdPath(userDn, queryLogic, queryId); - nodes.add(new PersistentNode(client, CreateMode.EPHEMERAL, false, userQueryIdPath, EMPTY_DATA, false)); + nodes.add(new PersistentNode(heartbeatClient, CreateMode.EPHEMERAL, false, userQueryIdPath, EMPTY_DATA, false)); } // Persist each node to Zookeeper. @@ -188,11 +162,7 @@ public QueryHeartbeat trackQuery(String queryId, String userDn, String system, S log.error("Failed to track query " + queryId, e); throw e; } - - } finally { - clientLock.unlock(); } - return heartbeat; } @@ -216,7 +186,10 @@ public int getTotalUserQueriesForQueryLogic(String userDn, String queryLogic) th log.trace("Fetching total queries for user='" + userDn + "', queryLogic='" + queryLogic + "'"); } - return getTotalChildrenWithLock(getUserQueryLogicPath(userDn, queryLogic)); + try (LockedZkClientDispatcher.LockedClient lockedClient = clientDispatcher.getLockedClient()) { + CuratorFramework client = lockedClient.getClient(); + return getTotalChildren(client, getUserQueryLogicPath(userDn, queryLogic)); + } } /** @@ -238,30 +211,9 @@ public int getTotalSystemQueriesForQueryLogic(String system, String queryLogic) log.trace("Fetching total queries for system='" + system + "', queryLogic='" + queryLogic + "'"); } - return getTotalChildrenWithLock(getSystemQueryLogicPath(system, queryLogic)); - } - - /** - * Obtain a lock for the client and return the total children for the given path. If the path does not exist, 0 will be returned. - * - * @param path - * the node path - * @return the total children - * @throws Exception - * if an error occurs while scanning nodes - */ - private int getTotalChildrenWithLock(String path) throws Exception { - clientLock.lock(); - try { - // Initialize the client if needed. - initClient(); - - return getTotalChildren(path); - } catch (Exception e) { - log.error("Failed to get total children for path " + path, e); - throw e; - } finally { - clientLock.unlock(); + try (LockedZkClientDispatcher.LockedClient lockedClient = clientDispatcher.getLockedClient()) { + CuratorFramework client = lockedClient.getClient(); + return getTotalChildren(client, getSystemQueryLogicPath(system, queryLogic)); } } @@ -289,7 +241,10 @@ public boolean totalUserQueriesMeetsLimit(String userDn, int queryLimit, Set quer /** * Return the total number of children for the path. If the path does not exist, 0 will be returned. * + * @param client + * the client * @param path * the path * @return the total number of children * @throws Exception * if an error occurs while scanning nodes */ - private int getTotalChildren(String path) throws Exception { + private int getTotalChildren(CuratorFramework client, String path) throws Exception { try { Stat stat = client.checkExists().forPath(path); if (stat == null) { @@ -407,10 +360,9 @@ public List getDistinctQueryLogics() { if (log.isTraceEnabled()) { log.trace("Fetching distinct query logics"); } - clientLock.lock(); - try { - // Initialize the client if needed. - initClient(); + + try (LockedZkClientDispatcher.LockedClient lockedClient = clientDispatcher.getLockedClient()) { + CuratorFramework client = lockedClient.getClient(); // If any query logics were tracked, return them. Stat stat = client.checkExists().forPath(DISTINCT_QUERY_LOGICS_CONTAINER_PATH); if (stat != null) { @@ -422,8 +374,6 @@ public List getDistinctQueryLogics() { } catch (Exception e) { log.error("Failed to fetch distinct query logics", e); throw new ActiveQueryException(e); - } finally { - clientLock.unlock(); } } @@ -516,101 +466,15 @@ private String getSystemQueryIdPath(String system, String queryLogic, String que return getSystemQueryLogicPath(system, queryLogic) + "/" + queryId; } - /** - * Initialize the zookeeper client and cleanup timer if not already initialize. Calling this method will update the last time the client was accessed to the - * current time. - */ - private void initClient() { - if (client == null) { - clientLock.lock(); - try { - // @formatter:off - client = createClient(); - if (cleanUpClientInterval > 0) { - createCleanupTimer(); - } - } finally { - clientLock.unlock(); - } - } - // Update the last time the client was accessed. - lastClientAccess = System.currentTimeMillis(); - } - - /** - * Return a new zookeeper client targeting the namespace {@value #ZOOKEEPER_NAMESPACE}. - * @return the client - */ - private CuratorFramework createClient() { - CuratorFramework client = CuratorFrameworkFactory.builder() - .namespace(ZOOKEEPER_NAMESPACE) - .connectString(zookeeperConfig) - .sessionTimeoutMs(60000) - .connectionTimeoutMs(60000) - .retryPolicy(new RetryNTimes(10, 1000)) - .build(); - - // @formatter:on - client.start(); - return client; - } - - /** - * Create the cleanup timer. - */ - private void createCleanupTimer() { - if (clientCleanupTimer == null) { - clientCleanupTimer = new Timer("Zookeeper Client Cleanup"); - } - - clientCleanupTimer.schedule(new TimerTask() { - @Override - public void run() { - if (lastClientAccess + cleanUpClientInterval <= System.currentTimeMillis()) { - cancel(); - } else if (client == null) { - cancel(); - } - } - }, cleanUpClientInterval, cleanUpClientInterval); - } - - /** - * Clean up the underlying resources used by this {@link ActiveQueryTracker}. - */ - public void cleanup() { - closeClientAndTimer(); - } - - /** - * Close the client and clean up timer, and nullify them. - */ - private void closeClientAndTimer() { - if (client != null) { - clientLock.lock(); + @Override + public void close() throws Exception { + if (clientDispatcher != null) { try { - if (clientCleanupTimer != null) { - clientCleanupTimer.cancel(); - clientCleanupTimer = null; - } - if (client != null) { - try { - client.close(); - } finally { - client = null; - } - } - } finally { - clientLock.unlock(); + clientDispatcher.close(); + } catch (Exception e) { + log.error("Failed to close client dispatcher", e); } + clientDispatcher = null; } } - - /** - * Close this {@link ActiveQueryTracker} and call {@link #cleanup()}. - */ - @Override - public void close() { - cleanup(); - } } diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/GroupLimitCache.java b/web-services/query/src/main/java/datawave/webservice/query/limit/GroupLimitCache.java index 34d088a826a..3408c7492b7 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/GroupLimitCache.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/GroupLimitCache.java @@ -18,10 +18,10 @@ public class GroupLimitCache { // The set of group limit overrides, sorted in best-match order. - private final SortedSet groupLimits; + private SortedSet groupLimits; // Internal cache for improved lookup efficiency. - private final Cache> cache; + private Cache> cache; public static GroupLimitCache of(SortedSet groupLimits, long maxCacheSize) { if (groupLimits != null && !groupLimits.isEmpty()) { @@ -91,6 +91,20 @@ public boolean isEmpty() { return this.groupLimits == null; } + /** + * Cleans up this {@link GroupLimitCache} and releases its underlying resources. + */ + public void cleanUp() { + if (groupLimits != null) { + groupLimits.clear(); + groupLimits = null; + } + if (cache != null) { + cache.cleanUp(); + cache = null; + } + } + @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) { @@ -109,4 +123,5 @@ public int hashCode() { public String toString() { return new StringJoiner(", ", GroupLimitCache.class.getSimpleName() + "[", "]").add("groupLimits=" + groupLimits).add("cache=" + cache).toString(); } + } diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableQueryLimitConfiguration.java b/web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableQueryLimitConfiguration.java new file mode 100644 index 00000000000..91b472c44a5 --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableQueryLimitConfiguration.java @@ -0,0 +1,98 @@ +package datawave.webservice.query.limit; + +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * An immutable implementation of {@link QueryLimitConfiguration} that prevents modifications and uses immutable internal members. + */ +public final class ImmutableQueryLimitConfiguration extends QueryLimitConfiguration { + + /** + * Return an immutable copy of the given {@link QueryLimitConfiguration}. + * + * @param config + * the config + */ + public ImmutableQueryLimitConfiguration(QueryLimitConfiguration config) { + super.setDefaultUserQueryLimit(config.getDefaultUserQueryLimit()); + super.setDefaultSystemQueryLimit(config.getDefaultSystemQueryLimit()); + super.setInternalCacheMaxSize(config.getInternalCacheMaxSize()); + super.setUserConfigs(copyList(config.getUserConfigs(), ImmutableUserLimitConfiguration::new)); + super.setSystemConfigs(copyList(config.getSystemConfigs(), ImmutableSystemLimitConfiguration::new)); + super.setQueryLogicGroupConfigs(copyList(config.getQueryLogicGroupConfigs(), ImmutableQueryLogicGroupLimitConfiguration::new)); + } + + /** + * Return an immutable version of the given list and its elements. + * + * @param list + * the list to copy + * @param immutableConstructor + * the constructor that will provide an immutable copy of each element + * @return the immutable list + * @param + * the element type + */ + private List copyList(List list, Function immutableConstructor) { + if (list == null) { + return List.of(); + } else { + return list.stream().map(immutableConstructor).collect(Collectors.toUnmodifiableList()); + } + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setDefaultUserQueryLimit(int defaultUserQueryLimit) { + throw new UnsupportedOperationException(); + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setDefaultSystemQueryLimit(int defaultSystemQueryLimit) { + throw new UnsupportedOperationException(); + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setInternalCacheMaxSize(long internalCacheMaxSize) { + throw new UnsupportedOperationException(); + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setUserConfigs(List userConfigs) { + throw new UnsupportedOperationException(); + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setSystemConfigs(List systemConfigs) { + throw new UnsupportedOperationException(); + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setQueryLogicGroupConfigs(List queryLogicGroupConfigs) { + throw new UnsupportedOperationException(); + } + + @Override + public String toString() { + return toString(ImmutableQueryLimitConfiguration.class); + } +} diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableQueryLogicGroupLimitConfiguration.java b/web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableQueryLogicGroupLimitConfiguration.java new file mode 100644 index 00000000000..265523c1f73 --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableQueryLogicGroupLimitConfiguration.java @@ -0,0 +1,47 @@ +package datawave.webservice.query.limit; + +/** + * An immutable implementation of {@link QueryLogicGroupLimitConfiguration} that prevents modifications and uses immutable internal members. + */ +public final class ImmutableQueryLogicGroupLimitConfiguration extends QueryLogicGroupLimitConfiguration { + + /** + * Return an immutable copy of the given {@link QueryLogicGroupLimitConfiguration}. + * + * @param config + * the config + */ + public ImmutableQueryLogicGroupLimitConfiguration(QueryLogicGroupLimitConfiguration config) { + super.setGroupName(config.getGroupName()); + super.setQueryLogicPattern(config.getQueryLogicPattern()); + super.setQueryLimit(config.getQueryLimit()); + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setGroupName(String groupName) { + throw new UnsupportedOperationException(); + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setQueryLogicPattern(String queryLogicPattern) { + throw new UnsupportedOperationException(); + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setQueryLimit(int queryLimit) { + throw new UnsupportedOperationException(); + } + + public String toString() { + return toString(ImmutableQueryLogicGroupLimitConfiguration.class); + } +} diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableSystemLimitConfiguration.java b/web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableSystemLimitConfiguration.java new file mode 100644 index 00000000000..6709344a7ed --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableSystemLimitConfiguration.java @@ -0,0 +1,59 @@ +package datawave.webservice.query.limit; + +import java.util.Map; + +/** + * An immutable implementation of {@link SystemLimitConfiguration} that prevents modifications and uses immutable internal members. + */ +public final class ImmutableSystemLimitConfiguration extends SystemLimitConfiguration { + + /** + * Return an immutable copy of the given {@link SystemLimitConfiguration}. + * + * @param config + * the config + */ + public ImmutableSystemLimitConfiguration(SystemLimitConfiguration config) { + super.setSystemPattern(config.getSystemPattern()); + super.setCountsAgainstUserLimit(config.getCountsAgainstUserLimit()); + super.setQueryLimit(config.getQueryLimit()); + super.setQueryLogicGroupLimits(config.getQueryLogicGroupLimits() == null ? null : Map.copyOf(config.getQueryLogicGroupLimits())); + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setSystemPattern(String systemPattern) { + throw new UnsupportedOperationException(); + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setCountsAgainstUserLimit(Boolean countsAgainstUserLimit) { + throw new UnsupportedOperationException(); + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setQueryLimit(Integer queryLimit) { + throw new UnsupportedOperationException(); + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setQueryLogicGroupLimits(Map queryLogicGroupLimits) { + throw new UnsupportedOperationException(); + } + + @Override + public String toString() { + return toString(ImmutableSystemLimitConfiguration.class); + } +} diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableUserLimitConfiguration.java b/web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableUserLimitConfiguration.java new file mode 100644 index 00000000000..01081d8ea97 --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableUserLimitConfiguration.java @@ -0,0 +1,51 @@ +package datawave.webservice.query.limit; + +import java.util.Map; + +/** + * An immutable implementation of {@link UserLimitConfiguration} that prevents modifications and uses immutable internal members. + */ +public final class ImmutableUserLimitConfiguration extends UserLimitConfiguration { + + /** + * Return an immutable copy of the given {@link UserLimitConfiguration}. + * + * @param config + * the config + */ + public ImmutableUserLimitConfiguration(UserLimitConfiguration config) { + super.setUserDn(config.getUserDn()); + super.setQueryLimit(config.getQueryLimit()); + super.setQueryLogicGroupLimits(config.getQueryLogicGroupLimits() == null ? Map.of() : Map.copyOf(config.getQueryLogicGroupLimits())); + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setUserDn(String userDn) { + throw new UnsupportedOperationException(); + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setQueryLimit(Integer queryLimit) { + throw new UnsupportedOperationException(); + } + + /** + * Throws {@link UnsupportedOperationException}. + */ + @Override + public void setQueryLogicGroupLimits(Map queryLogicGroupLimits) { + throw new UnsupportedOperationException(); + } + + @Override + public String toString() { + return super.toString(ImmutableUserLimitConfiguration.class); + } + +} diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfiguration.java b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfiguration.java index 4024fb3ac6c..134b876e9dc 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfiguration.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfiguration.java @@ -4,6 +4,8 @@ import java.util.Objects; import java.util.StringJoiner; +import com.fasterxml.jackson.annotation.JsonProperty; + /** * Configuration for query limits. */ @@ -12,32 +14,38 @@ public class QueryLimitConfiguration { /** * The default maximum number of active concurrent queries a user may have across all systems. */ + @JsonProperty private int defaultUserQueryLimit; /** * The default maximum number of active concurrent queries that may be running on a system. */ + @JsonProperty private int defaultSystemQueryLimit; /** * The maximum size to use for internal caches in {@link GroupLimitCache} and {@link PatternMatcher}. This value should be large enough to hold the number * of distinct query logics. */ + @JsonProperty private long internalCacheMaxSize = 200; /** * The custom user limit configurations. */ + @JsonProperty private List userConfigs; /** * The custom system limit configurations. */ + @JsonProperty private List systemConfigs; /** * The custom query logic group configurations. */ + @JsonProperty private List queryLogicGroupConfigs; public int getDefaultUserQueryLimit() { @@ -88,11 +96,24 @@ public void setQueryLogicGroupConfigs(List qu this.queryLogicGroupConfigs = queryLogicGroupConfigs; } + /** + * Return whether this {@link QueryLimitConfiguration} is considered equal to the given object. This {@code equals(Object)} implementation allows this + * instance to be equal to an object that is a subclass of {@link QueryLimitConfiguration}, such as {@link ImmutableQueryLimitConfiguration}. + * + * @param o + * the object to compare + * @return true if the object is equal to this {@link QueryLimitConfiguration}, or false otherwise + */ @Override public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) { + if (o == this) { + return true; + } + // Allow this instance to be considered equal to subclasses. + if (!(o instanceof QueryLimitConfiguration)) { return false; } + QueryLimitConfiguration that = (QueryLimitConfiguration) o; return defaultUserQueryLimit == that.defaultUserQueryLimit && defaultSystemQueryLimit == that.defaultSystemQueryLimit && internalCacheMaxSize == that.internalCacheMaxSize && Objects.equals(userConfigs, that.userConfigs) @@ -106,7 +127,19 @@ public int hashCode() { @Override public String toString() { - return new StringJoiner(", ", QueryLimitConfiguration.class.getSimpleName() + "[", "]").add("defaultUserQueryLimit=" + defaultUserQueryLimit) + return toString(QueryLimitConfiguration.class); + } + + /** + * Return a String representation of this {@link QueryLimitConfiguration} referencing the given class as the instance of this + * {@link QueryLimitConfiguration}. + * + * @param clazz + * the class + * @return the string representation + */ + protected String toString(Class clazz) { + return new StringJoiner(", ", clazz.getSimpleName() + "[", "]").add("defaultUserQueryLimit=" + defaultUserQueryLimit) .add("defaultSystemQueryLimit=" + defaultSystemQueryLimit).add("internalCacheMaxSize=" + internalCacheMaxSize) .add("userConfigs=" + userConfigs).add("systemConfigs=" + systemConfigs).add("queryLogicGroupConfigs=" + queryLogicGroupConfigs) .toString(); diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigurationValidationUtils.java b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigurationValidationUtils.java new file mode 100644 index 00000000000..d373fe4c7de --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigurationValidationUtils.java @@ -0,0 +1,230 @@ +package datawave.webservice.query.limit; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import org.apache.commons.lang3.StringUtils; + +public final class QueryLimitConfigurationValidationUtils { + + /** + * Validate the given configuration + * + * @param config + * the configuration to validate + */ + public static void validate(QueryLimitConfiguration config) { + if (config != null) { + if (config.getDefaultUserQueryLimit() < 1) { + throw new IllegalArgumentException("Default user query limit must be greater than 0"); + } + if (config.getInternalCacheMaxSize() < 1) { + throw new IllegalArgumentException("Internal cache max size must be greater than 0"); + } + + List queryLogicGroupConfigs = config.getQueryLogicGroupConfigs(); + if (queryLogicGroupConfigs != null && !queryLogicGroupConfigs.isEmpty()) { + validateQueryLogicGroupConfigs(config.getQueryLogicGroupConfigs()); + } + + List userLimitConfigs = config.getUserConfigs(); + if (userLimitConfigs != null && !userLimitConfigs.isEmpty()) { + validateUserLimitConfigs(userLimitConfigs); + } + + List systemLimitConfigs = config.getSystemConfigs(); + if (systemLimitConfigs != null && !systemLimitConfigs.isEmpty()) { + validateSystemLimitConfigs(systemLimitConfigs, config.getInternalCacheMaxSize()); + } + } + } + + /** + * Validate the given query logic group limit configurations. + * + * @param configs + * the configurations to validate + */ + public static void validateQueryLogicGroupConfigs(Collection configs) { + Set groupNames = new HashSet<>(); + for (QueryLogicGroupLimitConfiguration config : configs) { + + // Verify that a group name was given. + String groupName = config.getGroupName(); + if (StringUtils.isBlank(groupName)) { + throw new IllegalArgumentException("Query logic group limit configuration given with blank group name"); + } + + // Verify that we have not seen a configuration with the group name before. + if (groupNames.contains(groupName)) { + throw new IllegalArgumentException("Multiple query logic group configurations given with group name '" + groupName + "'"); + } else { + groupNames.add(groupName); + } + + // Verify that the query limit is not negative. + if (config.getQueryLimit() < 0) { + throw new IllegalArgumentException("Negative limit given for query logic group '" + groupName + "'"); + } + + // Verify that a query logic pattern was given. + String queryLogicPattern = config.getQueryLogicPattern(); + if (StringUtils.isBlank(queryLogicPattern)) { + throw new IllegalArgumentException("Blank query logic pattern given for query logic group '" + groupName + "'"); + } + + // Verify that the pattern compiles if it is not simply a * as is occasionally used as a wildcard in configurations. + try { + if (!queryLogicPattern.equals(QueryLimitConstants.ASTERISK)) { + Pattern.compile(queryLogicPattern); + } + } catch (PatternSyntaxException e) { + throw new IllegalArgumentException("Invalid regex in query logic pattern '" + queryLogicPattern + "' for query logic group '" + groupName + "'", + e); + } + } + } + + /** + * Validate the given user limit configurations. + * + * @param configs + * the configurations to validate + */ + public static void validateUserLimitConfigs(Collection configs) { + Set userDns = new HashSet<>(); + for (UserLimitConfiguration config : configs) { + // Verify that a user dn was given. + String userDn = config.getUserDn(); + if (StringUtils.isBlank(userDn)) { + throw new IllegalArgumentException("User query limit configuration given with blank user DN"); + } + + // Verify we have not seen a configuration with the user dn before. + if (userDns.contains(userDn)) { + throw new IllegalArgumentException("Multiple query limit configurations specified for user '" + userDn + "'"); + } else { + userDns.add(userDn); + } + + // Verify that if the user query limit was overridden, it is not negative. + if (config.getQueryLimit() != null && config.getQueryLimit() < 0) { + throw new IllegalArgumentException("Negative user query limit given for user '" + userDn + "'"); + } + + // Verify that no invalid group name patterns were provided. + Map groupLimits = config.getQueryLogicGroupLimits(); + if (groupLimits != null) { + for (Map.Entry entry : groupLimits.entrySet()) { + String groupPattern = entry.getKey(); + if (StringUtils.isBlank(groupPattern)) { + throw new IllegalArgumentException("User group query limit configuration given with blank group pattern for user '" + userDn + "'"); + } + if (!groupPattern.equals(QueryLimitConstants.ASTERISK)) { + try { + Pattern.compile(groupPattern); + } catch (PatternSyntaxException e) { + throw new IllegalArgumentException("Invalid query logic group name pattern: " + groupPattern + " given for user " + userDn, e); + } + } + Integer limit = entry.getValue(); + if (limit < 0) { + throw new IllegalArgumentException("Negative query logic group limit given for user '" + userDn + "': " + limit); + } + } + } + } + } + + /** + * Validate the given system limit configurations. + * + * @param configs + * the configurations to validate + */ + public static void validateSystemLimitConfigs(Collection configs, long maxCacheSize) { + Set systemPatterns = new HashSet<>(); + Map matcherPatterns = new HashMap<>(); + for (SystemLimitConfiguration config : configs) { + // Verify that a system pattern was given. + String systemPattern = config.getSystemPattern(); + if (StringUtils.isBlank(systemPattern)) { + throw new IllegalArgumentException("System query limit configuration specified with blank system pattern"); + } + + // Verify that the pattern compiles if it is not simply a * as is occasionally used as a wildcard in configurations. + try { + if (!systemPattern.equals(QueryLimitConstants.ASTERISK)) { + Pattern.compile(systemPattern); + } + } catch (PatternSyntaxException e) { + throw new IllegalArgumentException("Invalid regex in system pattern '" + systemPattern + "'", e); + } + + // Verify that we have not seen a configuration with the system pattern before. + if (systemPatterns.contains(systemPattern)) { + throw new IllegalArgumentException("Multiple query limit configurations specified with system pattern '" + systemPattern + "'"); + } else { + systemPatterns.add(systemPattern); + } + + // Fetch the matcher that would be used for the system pattern. + Matcher matcher = Matcher.getMatcher(systemPattern, maxCacheSize); + + // Verify that we do not have an exact-matching pattern that is equivalent to a previously seen exact-matching pattern, such as 'SYSTEM-01' vs. + // 'SYSTEM\\-01'. + if (matcher instanceof StringMatcher) { + String matcherPattern = ((StringMatcher) matcher).getValue(); + String equivalentSystemPattern = matcherPatterns.get(matcherPattern); + if (equivalentSystemPattern != null) { + throw new IllegalArgumentException( + "System pattern '" + systemPattern + "' will resolve to an exact match that is equivalent to system pattern '" + + equivalentSystemPattern + "' from another system configuration."); + } else { + matcherPatterns.put(matcherPattern, systemPattern); + } + } + + // Safeguard against allowing a configuration to potentially set whether queries on a system counts against user limits to false for all + // systems. Only allow this to be done for exact system names, or non-wildcard-only patterns. + if (QueryLimitConstants.wildcardOnlyPattern.matcher(systemPattern).matches() && !config.getCountsAgainstUserLimit()) { + throw new IllegalArgumentException("System pattern '" + systemPattern + + "' is wildcard-only and may not be used to override whether queries count against user limits to false"); + } + + // Verify that no invalid group name patterns were provided. + Map groupLimits = config.getQueryLogicGroupLimits(); + if (groupLimits != null) { + for (Map.Entry entry : groupLimits.entrySet()) { + String groupPattern = entry.getKey(); + if (StringUtils.isBlank(groupPattern)) { + throw new IllegalArgumentException( + "User group query limit configuration given with blank group pattern for system pattern '" + systemPattern + "'"); + } + if (!groupPattern.equals(QueryLimitConstants.ASTERISK)) { + try { + Pattern.compile(groupPattern); + } catch (PatternSyntaxException e) { + throw new IllegalArgumentException( + "Invalid query logic group name pattern: " + groupPattern + " given for system pattern " + systemPattern, e); + } + } + Integer limit = entry.getValue(); + if (limit < 0) { + throw new IllegalArgumentException("Negative query logic group limit given for system pattern '" + systemPattern + "': " + limit); + } + } + } + } + } + + private QueryLimitConfigurationValidationUtils() { + throw new UnsupportedOperationException(); + } +} diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigurationValidator.java b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigurationValidator.java new file mode 100644 index 00000000000..f714103f56d --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigurationValidator.java @@ -0,0 +1,14 @@ +package datawave.webservice.query.limit; + +import com.google.common.base.Preconditions; + +import datawave.webservice.zookeeper.ObjectValidator; + +public class QueryLimitConfigurationValidator implements ObjectValidator { + + @Override + public void validate(Object object) { + Preconditions.checkArgument((object instanceof QueryLimitConfiguration), "Object must be an instance of " + QueryLimitConfiguration.class.getName()); + QueryLimitConfigurationValidationUtils.validate((QueryLimitConfiguration) object); + } +} diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimiter.java b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimiter.java index 32c320029bd..a64e132ddcb 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimiter.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimiter.java @@ -5,23 +5,37 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import org.apache.log4j.Logger; import org.apache.zookeeper.server.quorum.QuorumPeerConfig; +import com.google.common.base.Preconditions; + +import datawave.webservice.zookeeper.ZkObjectPublishResult; +import datawave.webservice.zookeeper.ZkObjectPublishStatus; +import datawave.webservice.zookeeper.ZkObjectPublisher; + /** * This class is responsible for determining if any concurrent query limits are going to be exceeded for a user, system, or query logic when a new query is - * submitted. + * submitted. It is expected that only a singleton instance of {@link QueryLimiter} will be created via CDI. */ public class QueryLimiter { private static final Logger log = Logger.getLogger(QueryLimiter.class); + // The default string to use as a system name when no system is provided with a query. + public static final String EMPTY_SYSTEM_FROM = "EMPTY_SYSTEM_FROM"; + + // A lock that will guard access to the query limit configuration and the limit providers. + private final Lock configLock = new ReentrantLock(); + // The string to use to connect to zookeeper. private String zookeeperConfig; // The configuration to initialize the limit providers with. - private QueryLimitConfiguration configuration; + private ImmutableQueryLimitConfiguration configuration; // A cache to store heartbeats of active queries within. private QueryHeartbeatCache heartbeatCache; @@ -38,7 +52,11 @@ public class QueryLimiter { // The tracker responsible for interfacing with Zookeeper. private ActiveQueryTracker activeQueryTracker; - public static final String EMPTY_SYSTEM_FROM = "EMPTY_SYSTEM_FROM"; + // The publisher responsible for notifying the query limiter when there are updates to the configuration. + private ZkObjectPublisher configPublisher; + + // Whether the limiter is currently in a state where it can provide limits + private boolean canProvideLimits = false; /** * Return the zookeeper connection string. @@ -60,24 +78,141 @@ public void setZookeeperConfig(String zookeeperConfig) { } /** - * Set the configuration to use to set up this {@link QueryLimiter} + * Set the config publisher that will notify this {@link QueryLimiter} of configuration updates. + * + * @param configPublisher + * the configuration publisher + */ + public void setConfigPublisher(ZkObjectPublisher configPublisher) { + this.configPublisher = configPublisher; + } + + /** + * Update the configuration for this {@link QueryLimiter}. The configuration will be validated if indicated, and the internal configuration and limit + * providers will be recreated to reflect the new configuration. + * + * @param configuration + * the configuration to set + * @param validationRequired + * whether the configuration should be validated before updating the internal providers + */ + private void updateConfiguration(QueryLimitConfiguration configuration, boolean validationRequired) { + Preconditions.checkNotNull(configuration, "configuration must not be null"); + + configLock.lock(); + try { + // If validation is required, do so. + if (validationRequired) { + QueryLimitConfigurationValidationUtils.validate(configuration); + } + + if (log.isDebugEnabled()) { + log.debug("Updating configuration to " + configuration); + } + + try { + // Update the configuration. + this.configuration = new ImmutableQueryLimitConfiguration(configuration); + + // Recreate the query logic group provider. + if (this.queryLogicGroupLimitProvider != null) { + try { + this.queryLogicGroupLimitProvider.cleanUp(); + } catch (Exception e) { + log.warn("Failed to clean up query logic group limit provider", e); + } + // Make this null so that if recreating the provider fails for some reason, canProvideLimits() will return false. + this.queryLogicGroupLimitProvider = null; + } + this.queryLogicGroupLimitProvider = new QueryLogicGroupLimitProvider(configuration.getInternalCacheMaxSize(), + configuration.getQueryLogicGroupConfigs()); + + // Recreate the user limit provider. + if (this.userLimitProvider != null) { + try { + this.userLimitProvider.cleanUp(); + } catch (Exception e) { + log.warn("Failed to clean up user limit provider", e); + } + // Make this null so that if recreating the provider fails for some reason, canProvideLimits() will return false. + this.userLimitProvider = null; + } + this.userLimitProvider = new UserLimitProvider(configuration.getDefaultUserQueryLimit(), configuration.getInternalCacheMaxSize(), + configuration.getUserConfigs(), queryLogicGroupLimitProvider); + + // Recreate the system limit provider. + if (this.systemLimitProvider != null) { + try { + this.systemLimitProvider.cleanUp(); + } catch (Exception e) { + log.warn("Failed to clean up system limit provider", e); + } + // Make this null so that if recreating the provider fails for some reason, canProvideLimits() will return false. + this.systemLimitProvider = null; + } + this.systemLimitProvider = new SystemLimitProvider(configuration.getDefaultSystemQueryLimit(), configuration.getInternalCacheMaxSize(), + configuration.getSystemConfigs(), queryLogicGroupLimitProvider); + + log.debug("Configuration updated and internal limit providers recreated"); + } catch (Exception e) { + log.error("Failed to update configuration", e); + } + + // Update whether this limiter can provide limits. + this.canProvideLimits = this.configuration != null && this.queryLogicGroupLimitProvider != null && this.userLimitProvider != null + && this.systemLimitProvider != null; + } finally { + configLock.unlock(); + } + } + + /** + * Set the configuration for the {@link QueryLimiter} if and only if the configuration for the limiter is currently null. Throws an + * {@link IllegalStateException} otherwise. This method exists primarily to support initial CDI injection during startup, and it is expected that + * {@link #setup()} will be called to create the internal limit providers. * * @param queryLimitConfiguration * the config + * + * @throws NullPointerException + * if the new configuration is null + * @throws IllegalStateException + * if the internal {@link QueryLimitConfiguration} is not null. */ public void setConfiguration(QueryLimitConfiguration queryLimitConfiguration) { - this.configuration = queryLimitConfiguration; + Preconditions.checkNotNull(queryLimitConfiguration, "configuration must not be null"); + configLock.lock(); + try { + if (this.configuration == null) { + this.configuration = new ImmutableQueryLimitConfiguration(queryLimitConfiguration); + } else { + throw new IllegalStateException("QueryLimitConfiguration is already set, use updateConfiguration(QueryLimitConfiguration) instead"); + } + } finally { + configLock.unlock(); + } } /** - * Return the configuration used to set up this {@link QueryLimiter} + * Return the configuration currently configured for this {@link QueryLimiter}. This will be an instance of {@link ImmutableQueryLimitConfiguration}. * * @return the config */ public QueryLimitConfiguration getConfiguration() { - return configuration; + configLock.lock(); + try { + return configuration; + } finally { + configLock.unlock(); + } } + /** + * Set the {@link QueryHeartbeatCache}. + * + * @param heartbeatCache + * the heartbeat cache + */ public void setHeartbeatCache(QueryHeartbeatCache heartbeatCache) { this.heartbeatCache = heartbeatCache; } @@ -88,28 +223,45 @@ public void setHeartbeatCache(QueryHeartbeatCache heartbeatCache) { */ public void setup() { if (log.isDebugEnabled()) { - log.debug("Initializing with zookeeperConfig: '" + zookeeperConfig + "' and query limit config: " + configuration); + log.debug("Initializing with zookeeperConfig: '" + zookeeperConfig + "', and query limit config: " + configuration); } - if (this.configuration != null) { - if (this.configuration.getDefaultUserQueryLimit() < 1) { - throw new IllegalArgumentException("Default user query limit must be greater than 0"); + configLock.lock(); + try { + // Require the heartbeat cache to be set. + if (heartbeatCache == null) { + throw new IllegalStateException("No heartbeat cache set"); } - if (this.configuration.getInternalCacheMaxSize() < 1) { - throw new IllegalArgumentException("Internal cache max size must be greater than 0"); + // If no configuration was supplied from a configured bean, attempt to load a configuration from Zookeeper. + if (this.configuration == null) { + if (this.configPublisher != null) { + ZkObjectPublishResult result = configPublisher.getObjectFromZk(); + if (result.getStatus() == ZkObjectPublishStatus.SUCCESS) { + // Update the configuration and create the providers. The configuration returned by the reloader will already be validated. + updateConfiguration((QueryLimitConfiguration) result.getUpdatedObject(), false); + } else { + log.error("Failed to load configuration from zookeeper: " + result); + } + } + if (this.configuration == null) { + throw new IllegalStateException("No configuration supplied for Query Limiter either via injection or Zookeeper."); + } + } else { + // Update the configuration and create the providers. + updateConfiguration(this.configuration, true); } - this.queryLogicGroupLimitProvider = new QueryLogicGroupLimitProvider(configuration.getInternalCacheMaxSize(), - configuration.getQueryLogicGroupConfigs()); - this.userLimitProvider = new UserLimitProvider(configuration.getDefaultUserQueryLimit(), configuration.getInternalCacheMaxSize(), - configuration.getUserConfigs(), queryLogicGroupLimitProvider); - this.systemLimitProvider = new SystemLimitProvider(configuration.getDefaultSystemQueryLimit(), configuration.getInternalCacheMaxSize(), - configuration.getSystemConfigs(), queryLogicGroupLimitProvider); - } else { - this.queryLogicGroupLimitProvider = null; - this.userLimitProvider = null; - this.systemLimitProvider = null; + // If the configuration reloader is not null, add a listener so that this limiter will be provided with new configurations. Any configs provided by + // the reloader will already be validated. + if (configPublisher != null) { + configPublisher.subscribeToUpdates(((config) -> updateConfiguration((QueryLimitConfiguration) config, false))); + log.debug("QueryLimiter now listening for configuration updates from config publisher"); + } else { + log.warn("No config publisher set for QueryLimiter, limiter will not be notified of configuration updates"); + } + } finally { + configLock.unlock(); } } @@ -117,18 +269,33 @@ public void setup() { * Releases internal resources and cleans up connections and scheduled tasks. */ public void shutdown() { + log.debug("Shutting down"); + if (this.heartbeatCache != null) { try { this.heartbeatCache.shutdown(); } catch (Exception e) { - log.error("Error closing heartbeat cache", e); + log.warn("Error closing heartbeat cache", e); + } finally { + this.heartbeatCache = null; } } if (this.activeQueryTracker != null) { try { this.activeQueryTracker.close(); } catch (Exception e) { - log.error("Error closing active query tracker", e); + log.warn("Error closing active query tracker", e); + } finally { + this.activeQueryTracker = null; + } + } + if (this.configPublisher != null) { + try { + this.configPublisher.close(); + } catch (Exception e) { + log.warn("Error closing config publisher", e); + } finally { + this.configPublisher = null; } } } @@ -147,29 +314,37 @@ public void shutdown() { * if an exception occurs */ public QueryLimiterResponse checkForLimits(String userDn, String system, String queryLogic) throws Exception { - // Cast the user DN to lowercase to ensure a consistent format. - userDn = userDn.trim().toLowerCase(); + configLock.lock(); + try { + Preconditions.checkState(canProvideLimits, "Cannot check for limits, configuration or providers are not initialized"); - // Do not cast the system or query logic to lowercase, they will be getting matched against regex patterns. - queryLogic = queryLogic.trim(); + // Cast the user DN to lowercase to ensure a consistent format. + userDn = userDn.trim().toLowerCase(); - // Ensure the system is non-null if empty - if (system == null || system.isBlank()) { - system = EMPTY_SYSTEM_FROM; - } + // Do not cast the system or query logic to lowercase, they will be getting matched against regex patterns. + queryLogic = queryLogic.trim(); - if (log.isDebugEnabled()) { - log.debug("Checking limits - userDn: " + userDn + ", system: " + system + ", queryLogic: " + queryLogic); - } + // Ensure the system is non-null if empty + if (system == null || system.isBlank()) { + system = EMPTY_SYSTEM_FROM; + } - // Check if the snapshot reveals that any limits have been met. - LimitChecker checker = new LimitChecker(userDn, system, queryLogic); - checker.checkLimits(); - if (checker.metLimit) { - return QueryLimiterResponse.metLimit(checker.message); - } else { - return QueryLimiterResponse.hasNotMetLimit(); + if (log.isDebugEnabled()) { + log.debug("Checking limits - userDn: " + userDn + ", system: " + system + ", queryLogic: " + queryLogic); + } + + // Check if the snapshot reveals that any limits have been met. + LimitChecker checker = new LimitChecker(userDn, system, queryLogic); + checker.checkLimits(); + if (checker.metLimit) { + return QueryLimiterResponse.metLimit(checker.message); + } else { + return QueryLimiterResponse.hasNotMetLimit(); + } + } finally { + configLock.unlock(); } + } /** @@ -187,22 +362,30 @@ public QueryLimiterResponse checkForLimits(String userDn, String system, String * if an error occurs */ public void countQueryTowardsLimits(String queryId, String userDn, String system, String queryLogic) throws Exception { - if (log.isDebugEnabled()) { - log.debug("Start counting query " + queryId + " towards limits"); - } + configLock.lock(); + try { + Preconditions.checkState(canProvideLimits, "Cannot check for limits, configuration or providers are not initialized"); - userDn = userDn.trim().toLowerCase(); - queryLogic = queryLogic.trim(); - // Ensure the system is non-null if empty - if (system == null || system.isBlank()) { - system = EMPTY_SYSTEM_FROM; - } + if (log.isDebugEnabled()) { + log.debug("Start counting query " + queryId + " towards limits"); + } + + userDn = userDn.trim().toLowerCase(); + queryLogic = queryLogic.trim(); + // Ensure the system is non-null if empty + if (system == null || system.isBlank()) { + system = EMPTY_SYSTEM_FROM; + } - boolean systemCountsTowardsUserLimits = systemLimitProvider.countsAgainstUserLimit(system); + boolean systemCountsTowardsUserLimits = systemLimitProvider.countsAgainstUserLimit(system); - QueryHeartbeat heartbeat = getActiveQueryTracker().trackQuery(queryId, userDn, system, queryLogic, systemCountsTowardsUserLimits); - // Store the heartbeat into the cache. This acts as a means to keep the connection to Zookeeper alive for the ephemeral nodes stored in the heartbeat. - heartbeatCache.put(heartbeat); + QueryHeartbeat heartbeat = getActiveQueryTracker().trackQuery(queryId, userDn, system, queryLogic, systemCountsTowardsUserLimits); + // Store the heartbeat into the cache. This acts as a means to keep the connection to Zookeeper alive for the ephemeral nodes stored in the + // heartbeat. + heartbeatCache.put(heartbeat); + } finally { + configLock.unlock(); + } } /** diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLogicGroupLimitConfiguration.java b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLogicGroupLimitConfiguration.java index 5615181fe39..0be06b19ea5 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLogicGroupLimitConfiguration.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLogicGroupLimitConfiguration.java @@ -3,19 +3,24 @@ import java.util.Objects; import java.util.StringJoiner; +import com.fasterxml.jackson.annotation.JsonProperty; + /** * Represents a custom query limit configuration that can be configured for query logics. */ public class QueryLogicGroupLimitConfiguration { // The name of the query logic group. + @JsonProperty private String groupName; // The query logic regex pattern. + @JsonProperty private String queryLogicPattern; // The default concurrency limit for users. This applies to the total concurrent queries a user may run that originate from a query logic in the group // across all systems. + @JsonProperty private int queryLimit; public QueryLogicGroupLimitConfiguration() {} @@ -50,9 +55,22 @@ public void setQueryLimit(int queryLimit) { this.queryLimit = queryLimit; } + /** + * Return whether this {@link QueryLogicGroupLimitConfiguration} is considered equal to the given object. This {@code equals(Object)} implementation allows + * this instance to be equal to an object that is a subclass of {@link QueryLogicGroupLimitConfiguration}, such as + * {@link ImmutableQueryLogicGroupLimitConfiguration}. + * + * @param o + * the object to compare + * @return true if the object is equal to this {@link QueryLogicGroupLimitConfiguration}, or false otherwise + */ @Override public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) { + if (o == this) { + return true; + } + // Allow this instance to be considered equal to subclasses. + if (!(o instanceof QueryLogicGroupLimitConfiguration)) { return false; } QueryLogicGroupLimitConfiguration that = (QueryLogicGroupLimitConfiguration) o; @@ -66,7 +84,19 @@ public int hashCode() { @Override public String toString() { - return new StringJoiner(", ", QueryLogicGroupLimitConfiguration.class.getSimpleName() + "[", "]").add("groupName='" + groupName + "'") + return toString(QueryLogicGroupLimitConfiguration.class); + } + + /** + * Return a String representation of this {@link QueryLogicGroupLimitConfiguration} referencing the given class as the instance of this + * {@link QueryLogicGroupLimitConfiguration}. + * + * @param clazz + * the class + * @return the string representation + */ + protected String toString(Class clazz) { + return new StringJoiner(", ", clazz.getSimpleName() + "[", "]").add("groupName='" + groupName + "'") .add("queryLogicPattern='" + queryLogicPattern + "'").add("queryLimit=" + queryLimit).toString(); } } diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLogicGroupLimitProvider.java b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLogicGroupLimitProvider.java index 5c892be89d7..1c8829d55f7 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLogicGroupLimitProvider.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLogicGroupLimitProvider.java @@ -3,23 +3,22 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; -import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; import java.util.stream.Collectors; -import org.apache.commons.lang3.StringUtils; +import org.apache.log4j.Logger; /** * This class is responsible for identifying and providing limits that should be enforced for query logic groups. */ public class QueryLogicGroupLimitProvider { + private static final Logger log = Logger.getLogger(QueryLogicGroupLimitProvider.class); + private final long maxCacheSize; private Map groupsToLimits = Map.of(); @@ -29,7 +28,6 @@ public class QueryLogicGroupLimitProvider { public QueryLogicGroupLimitProvider(long maxCacheSize, Collection configs) { this.maxCacheSize = maxCacheSize; if (configs != null && !configs.isEmpty()) { - validateConfigs(configs); populateLimits(configs); } else { this.groupLimitCache = GroupLimitCache.emptyInstance(); @@ -37,52 +35,6 @@ public QueryLogicGroupLimitProvider(long maxCacheSize, Collection configs) { - Set groupNames = new HashSet<>(); - for (QueryLogicGroupLimitConfiguration config : configs) { - - // Verify that a group name was given. - String groupName = config.getGroupName(); - if (StringUtils.isBlank(groupName)) { - throw new IllegalArgumentException("Query logic group limit configuration given with blank group name"); - } - - // Verify that we have not seen a configuration with the group name before. - if (groupNames.contains(groupName)) { - throw new IllegalArgumentException("Multiple query logic group configurations given with group name '" + groupName + "'"); - } else { - groupNames.add(groupName); - } - - // Verify that the query limit is not negative. - if (config.getQueryLimit() < 0) { - throw new IllegalArgumentException("Negative limit given for query logic group '" + groupName + "'"); - } - - // Verify that a query logic pattern was given. - String queryLogicPattern = config.getQueryLogicPattern(); - if (StringUtils.isBlank(queryLogicPattern)) { - throw new IllegalArgumentException("Blank query logic pattern given for query logic group '" + groupName + "'"); - } - - // Verify that the pattern compiles if it is not simply a * as is occasionally used as a wildcard in configurations. - try { - if (!queryLogicPattern.equals(QueryLimitConstants.ASTERISK)) { - Pattern.compile(queryLogicPattern); - } - } catch (PatternSyntaxException e) { - throw new IllegalArgumentException("Invalid regex in query logic pattern '" + queryLogicPattern + "' for query logic group '" + groupName + "'", - e); - } - } - } - /** * Populate the limits to enforce for query logic groups. * @@ -161,6 +113,22 @@ public Map getGroupMatchers(Set groups) { return map; } + /** + * Clean up this {@link QueryLogicGroupLimitProvider} and release its underlying resources. + */ + public void cleanUp() { + groupsToLimits = null; + if (groupLimitCache != null) { + try { + groupLimitCache.cleanUp(); + } catch (Exception e) { + log.warn("Failed to clear groupLimitCache", e); + } finally { + groupLimitCache = null; + } + } + } + /** * This class represents a sortable matchable group limit override. */ diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/README.md b/web-services/query/src/main/java/datawave/webservice/query/limit/README.md index 5d7aa353d65..ce0b350214f 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/README.md +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/README.md @@ -47,6 +47,10 @@ When using regex patterns in the configurations above, there is the possibility 2. Partial regex (non-wildcard-only): If we cannot find an exact match, then we attempt to find all partial matches, and see if any of their limits are met, checking against the lowest limits first. 3. Wildcard-only regex: In the case of no exact or partial matches, we use the wildcard match with the lowest limit. +## Dynamic Configuration Updates + +The configuration for the `QueryLimiter` may be updated dynamically through Zookeeper. When the `QueryLimiter` is configured with a [ZkObjectPublisher](../../zookeeper/ZkObjectPublisher.java), it will subscribe to updates publisher. When the publisher receives a triggering event, it will attempt to load a new `QueryLimitConfiguration` from the configured file. See the [ZkObjectPublisher README](../../zookeeper/README.md) for more details. + ## Implementation Checking limits and marking as active/inactive is done through the [QueryLimiter](QueryLimiter.java) class. The three main methods for interacting with the query limit feature are: @@ -70,7 +74,7 @@ When a query is marked as active via `QueryLimiter.countQueryTowardsLimits()`, i `ActiveQueryTracker.trackQuery()` will return a [QueryHeartbeat](QueryHeartbeat.java) instance that contain a list of `PersistentNode` (provided by the Apache Curator library) wrappers around the ephemeral nodes listed above. The `QueryHeartbeat` will maintain the connection to Zookeeper and attempt to keep the ephemeral nodes present in Zookeeper until `QueryHeartbeat.stop()` is called. If `QueryHeartbeat.stop()` is called, or the webserver crashes, the ephemeral nodes will automatically be deleted by Zookeeper. -The following HTTP status codes have been added for responses from the webserver: +The following HTTP status codes are available for responses from the webserver: ``` 412-20 - Concurrent query limit exceeded 500-164 - Error checking concurrent query limits diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/SystemLimitConfiguration.java b/web-services/query/src/main/java/datawave/webservice/query/limit/SystemLimitConfiguration.java index b0fe82180e7..6054886f22e 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/SystemLimitConfiguration.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/SystemLimitConfiguration.java @@ -4,22 +4,28 @@ import java.util.Objects; import java.util.StringJoiner; +import com.fasterxml.jackson.annotation.JsonProperty; + /** * Represents a custom query limit configuration that can be configured for matching systems. */ public class SystemLimitConfiguration { // The system name regex pattern. + @JsonProperty private String systemPattern; // Whether queries submitted on matching systems should count against a user's query limit. + @JsonProperty private Boolean countsAgainstUserLimit; // The maximum number of queries that can run concurrently on matching systems. + @JsonProperty private Integer queryLimit; // Map of query logic group names to the maximum number of queries that can run concurrently on the system when originating from query logics that fall // within the query logic group. The names may be regex patterns. + @JsonProperty private Map queryLogicGroupLimits; public SystemLimitConfiguration() { @@ -65,11 +71,24 @@ public void setQueryLogicGroupLimits(Map queryLogicGroupLimits) this.queryLogicGroupLimits = queryLogicGroupLimits == null ? Map.of() : queryLogicGroupLimits; } + /** + * Return whether this {@link SystemLimitConfiguration} is considered equal to the given object. This {@code equals(Object)} implementation allows this + * instance to be equal to an object that is a subclass of {@link SystemLimitConfiguration}, such as {@link ImmutableSystemLimitConfiguration}. + * + * @param o + * the object to compare + * @return true if the object is equal to this {@link SystemLimitConfiguration}, or false otherwise + */ @Override public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) { + if (o == this) { + return true; + } + // Allow this instance to be considered equal to subclasses. + if (!(o instanceof SystemLimitConfiguration)) { return false; } + SystemLimitConfiguration that = (SystemLimitConfiguration) o; return Objects.equals(systemPattern, that.systemPattern) && Objects.equals(countsAgainstUserLimit, that.countsAgainstUserLimit) && Objects.equals(queryLimit, that.queryLimit) && Objects.equals(queryLogicGroupLimits, that.queryLogicGroupLimits); @@ -82,7 +101,19 @@ public int hashCode() { @Override public String toString() { - return new StringJoiner(", ", SystemLimitConfiguration.class.getSimpleName() + "[", "]").add("systemPattern='" + systemPattern + "'") + return toString(SystemLimitConfiguration.class); + } + + /** + * Return a String representation of this {@link SystemLimitConfiguration} referencing the given class as the instance of this + * {@link SystemLimitConfiguration}. + * + * @param clazz + * the class + * @return the string representation + */ + protected String toString(Class clazz) { + return new StringJoiner(", ", clazz.getSimpleName() + "[", "]").add("systemPattern='" + systemPattern + "'") .add("countsAgainstsUserLimit=" + countsAgainstUserLimit).add("queryLimit=" + queryLimit) .add("queryLogicGroupLimits=" + queryLogicGroupLimits).toString(); } diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/SystemLimitProvider.java b/web-services/query/src/main/java/datawave/webservice/query/limit/SystemLimitProvider.java index 30ef8c70574..6697d0529ee 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/SystemLimitProvider.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/SystemLimitProvider.java @@ -2,17 +2,11 @@ import java.util.Collection; import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; -import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; -import org.apache.commons.lang3.StringUtils; import org.apache.log4j.Logger; import com.github.benmanes.caffeine.cache.Cache; @@ -25,12 +19,12 @@ public class SystemLimitProvider { private static final Logger log = Logger.getLogger(SystemLimitProvider.class); - private final Cache> systemLimitCache; - private final int defaultSystemQueryLimit; private final long maxCacheSize; + private Cache> systemLimitCache; + private SortedSet sortedSystemLimits; SystemLimitProvider(int defaultSystemQueryLimit, long maxCacheSize, Collection configs, @@ -44,7 +38,6 @@ public class SystemLimitProvider { this.maxCacheSize = maxCacheSize; if (configs != null && !configs.isEmpty()) { - validateConfigs(configs); populateLimits(configs, groupLimitProvider); this.systemLimitCache = Caffeine.newBuilder().maximumSize(100).build(); } else { @@ -53,88 +46,6 @@ public class SystemLimitProvider { } } - /** - * Validate the given configurations. - * - * @param configs - * the configurations to validate - */ - private void validateConfigs(Collection configs) { - Set systemPatterns = new HashSet<>(); - Map matcherPatterns = new HashMap<>(); - for (SystemLimitConfiguration config : configs) { - // Verify that a system pattern was given. - String systemPattern = config.getSystemPattern(); - if (StringUtils.isBlank(systemPattern)) { - throw new IllegalArgumentException("System query limit configuration specified with blank system pattern"); - } - - // Verify that the pattern compiles if it is not simply a * as is occasionally used as a wildcard in configurations. - try { - if (!systemPattern.equals(QueryLimitConstants.ASTERISK)) { - Pattern.compile(systemPattern); - } - } catch (PatternSyntaxException e) { - throw new IllegalArgumentException("Invalid regex in system pattern '" + systemPattern + "'", e); - } - - // Verify that we have not seen a configuration with the system pattern before. - if (systemPatterns.contains(systemPattern)) { - throw new IllegalArgumentException("Multiple query limit configurations specified with system pattern '" + systemPattern + "'"); - } else { - systemPatterns.add(systemPattern); - } - - // Fetch the matcher that would be used for the system pattern. - Matcher matcher = Matcher.getMatcher(systemPattern, maxCacheSize); - - // Verify that we do not have an exact-matching pattern that is equivalent to a previously seen exact-matching pattern, such as 'SYSTEM-01' vs. - // 'SYSTEM\\-01'. - if (matcher instanceof StringMatcher) { - String matcherPattern = ((StringMatcher) matcher).getValue(); - String equivalentSystemPattern = matcherPatterns.get(matcherPattern); - if (equivalentSystemPattern != null) { - throw new IllegalArgumentException( - "System pattern '" + systemPattern + "' will resolve to an exact match that is equivalent to system pattern '" - + equivalentSystemPattern + "' from another system configuration."); - } else { - matcherPatterns.put(matcherPattern, systemPattern); - } - } - - // Safeguard against allowing a configuration to potentially set whether queries on a system counts against user limits to false for all - // systems. Only allow this to be done for exact system names, or non-wildcard-only patterns. - if (QueryLimitConstants.wildcardOnlyPattern.matcher(systemPattern).matches() && !config.getCountsAgainstUserLimit()) { - throw new IllegalArgumentException("System pattern '" + systemPattern - + "' is wildcard-only and may not be used to override whether queries count against user limits to false"); - } - - // Verify that no invalid group name patterns were provided. - Map groupLimits = config.getQueryLogicGroupLimits(); - if (groupLimits != null) { - for (Map.Entry entry : groupLimits.entrySet()) { - String groupPattern = entry.getKey(); - if (StringUtils.isBlank(groupPattern)) { - throw new IllegalArgumentException( - "User group query limit configuration given with blank group pattern for system pattern '" + systemPattern + "'"); - } - if (!groupPattern.equals(QueryLimitConstants.ASTERISK)) { - try { - Pattern.compile(groupPattern); - } catch (PatternSyntaxException e) { - throw new IllegalArgumentException( - "Invalid query logic group name pattern: " + groupPattern + " given for system pattern " + systemPattern, e); - } - } - Integer limit = entry.getValue(); - if (limit < 0) { - throw new IllegalArgumentException("Negative query logic group limit given for system pattern '" + systemPattern + "': " + limit); - } - } - } - } - } - /** * Populate the limits to enforce for systems. * @@ -238,6 +149,22 @@ public boolean countsAgainstUserLimit(String system) { return customLimit.map(SystemLimits::countsAgainstUserLimit).orElse(true); } + /** + * Clean up this {@link SystemLimitProvider} and release its underlying resources. + */ + public void cleanUp() { + if (systemLimitCache != null) { + try { + systemLimitCache.invalidateAll(); + } catch (Exception e) { + log.error("Failed to clear systemLimitCache", e); + } finally { + systemLimitCache = null; + } + } + sortedSystemLimits = null; + } + /** * This class represents a sortable system pattern and its limit configuration. */ diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/UserLimitConfiguration.java b/web-services/query/src/main/java/datawave/webservice/query/limit/UserLimitConfiguration.java index 5f624ed3c07..edd22adf5fc 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/UserLimitConfiguration.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/UserLimitConfiguration.java @@ -4,18 +4,23 @@ import java.util.Objects; import java.util.StringJoiner; +import com.fasterxml.jackson.annotation.JsonProperty; + /** * Represents a custom query limit configuration that can be configured for a single user. */ public class UserLimitConfiguration { // The user DN. + @JsonProperty private String userDn; // The user's concurrent query limit. This applies to the total number of queries the user may run across all systems. + @JsonProperty private Integer queryLimit; // Map of query logic group names to the user's concurrent query limit for the group. The names may be regex patterns. + @JsonProperty private Map queryLogicGroupLimits; public UserLimitConfiguration() { @@ -52,9 +57,21 @@ public void setQueryLogicGroupLimits(Map queryLogicGroupLimits) this.queryLogicGroupLimits = queryLogicGroupLimits == null ? Map.of() : queryLogicGroupLimits; } + /** + * Return whether this {@link UserLimitConfiguration} is considered equal to the given object. This {@code equals(Object)} implementation allows this + * instance to be equal to an object that is a subclass of {@link UserLimitConfiguration}, such as {@link ImmutableUserLimitConfiguration}. + * + * @param o + * the object to compare + * @return true if the object is equal to this {@link UserLimitConfiguration}, or false otherwise + */ @Override public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) { + if (o == this) { + return true; + } + // Allow this instance to be considered equal to subclasses. + if (!(o instanceof UserLimitConfiguration)) { return false; } UserLimitConfiguration that = (UserLimitConfiguration) o; @@ -69,7 +86,18 @@ public int hashCode() { @Override public String toString() { - return new StringJoiner(", ", UserLimitConfiguration.class.getSimpleName() + "[", "]").add("userDn='" + userDn + "'").add("queryLimit=" + queryLimit) + return toString(UserLimitConfiguration.class); + } + + /** + * Return a String representation of this {@link UserLimitConfiguration} referencing the given class as the instance of this {@link UserLimitConfiguration}. + * + * @param clazz + * the class + * @return the string representation + */ + protected String toString(Class clazz) { + return new StringJoiner(", ", clazz.getSimpleName() + "[", "]").add("userDn='" + userDn + "'").add("queryLimit=" + queryLimit) .add("queryLogicGroupLimits=" + queryLogicGroupLimits).toString(); } } diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/UserLimitProvider.java b/web-services/query/src/main/java/datawave/webservice/query/limit/UserLimitProvider.java index da6d8e40219..b87df4e332e 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/UserLimitProvider.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/UserLimitProvider.java @@ -2,14 +2,9 @@ import java.util.Collection; import java.util.HashMap; -import java.util.HashSet; import java.util.Map; -import java.util.Set; import java.util.SortedSet; -import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; -import org.apache.commons.lang3.StringUtils; import org.apache.log4j.Logger; /** @@ -30,64 +25,12 @@ public class UserLimitProvider { this.defaultUserQueryLimit = defaultUserQueryLimit; this.maxCacheSize = maxCacheSize; if (configs != null && !configs.isEmpty()) { - validateConfigs(configs); populateLimits(configs, groupLimitProvider); } else { this.customLimits = Map.of(); } } - /** - * Validate the given configurations. - * - * @param configs - * the configurations to validate - */ - private void validateConfigs(Collection configs) { - Set userDns = new HashSet<>(); - for (UserLimitConfiguration config : configs) { - // Verify that a user dn was given. - String userDn = config.getUserDn(); - if (StringUtils.isBlank(userDn)) { - throw new IllegalArgumentException("User query limit configuration given with blank user DN"); - } - - // Verify we have not seen a configuration with the user dn before. - if (userDns.contains(userDn)) { - throw new IllegalArgumentException("Multiple query limit configurations specified for user '" + userDn + "'"); - } else { - userDns.add(userDn); - } - - // Verify that if the user query limit was overridden, it is not negative. - if (config.getQueryLimit() != null && config.getQueryLimit() < 0) { - throw new IllegalArgumentException("Negative user query limit given for user '" + userDn + "'"); - } - - // Verify that no invalid group name patterns were provided. - Map groupLimits = config.getQueryLogicGroupLimits(); - if (groupLimits != null) { - for (Map.Entry entry : groupLimits.entrySet()) { - String groupPattern = entry.getKey(); - if (StringUtils.isBlank(groupPattern)) { - throw new IllegalArgumentException("User group query limit configuration given with blank group pattern for user '" + userDn + "'"); - } - if (!groupPattern.equals(QueryLimitConstants.ASTERISK)) { - try { - Pattern.compile(groupPattern); - } catch (PatternSyntaxException e) { - throw new IllegalArgumentException("Invalid query logic group name pattern: " + groupPattern + " given for user " + userDn, e); - } - } - Integer limit = entry.getValue(); - if (limit < 0) { - throw new IllegalArgumentException("Negative query logic group limit given for user '" + userDn + "': " + limit); - } - } - } - } - } - /** * Populate the limits to enforce for users. * @@ -139,4 +82,11 @@ public boolean hasCustomLimits(String userDn) { public UserLimits getCustomLimits(String userDn) { return customLimits.get(userDn); } + + /** + * Clean up this {@link UserLimitProvider} and release its underlying resources. + */ + public void cleanUp() { + customLimits = null; + } } diff --git a/web-services/query/src/main/java/datawave/webservice/zookeeper/LockedZkClientDispatcher.java b/web-services/query/src/main/java/datawave/webservice/zookeeper/LockedZkClientDispatcher.java new file mode 100644 index 00000000000..16686b5a0d4 --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/zookeeper/LockedZkClientDispatcher.java @@ -0,0 +1,235 @@ +package datawave.webservice.zookeeper; + +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.curator.framework.CuratorFramework; +import org.apache.curator.framework.CuratorFrameworkFactory; +import org.apache.log4j.Logger; + +import com.google.common.base.Preconditions; + +/** + * A class that provides the ability to access a maintained {@link CuratorFramework} client that is guarded by a lock from access by multiple threads at the + * same time. Access to the underlying client is provided via {@link LockedZkClientDispatcher#getLockedClient()}, and is intended to be used within the context + * of a try-with-resources statement. For example: + * + *
+ * LockedZkClientDispatcher dispatcher = new LockedZkClientDispatcher(clientFactory, cleanupTaskInterval, maxElapsedAccessTime, timeUnit);
+ * // Obtain guarded access to the client.
+ * try (LockedZkClientDispatcher.LockedClient lockedClient = dispatcher.getLockedClient()) {
+ *      CuratorFramework client = lockedClient.getClient();
+ *      // Do things with the client.
+ * }
+ * // The lock will automatically be released after the try statement. Note: you should never call {@link CuratorFramework#close()} on the provided client.
+ * Clean up of the client will be handled by the dispatcher.
+ * 
+ * + * If {@link LockedZkClientDispatcher#getLockedClient()} is not used with a try-with-resources statement, care must be taken to ensure + * {@link LockedClient#close()} is called when you are finished with the client to release the lock. The underlying client will be cleaned up and resources + * released if the {@link LockedZkClientDispatcher} is created with a non-zero, positive maxElapsedAccessTime, and the time since + * {@link LockedZkClientDispatcher#getLockedClient()} meets or exceeds the max elapsed time. + */ +public class LockedZkClientDispatcher implements AutoCloseable { + + private static final Logger log = Logger.getLogger(LockedZkClientDispatcher.class); + + /** + * The {@link CuratorFramework} factory. + */ + protected final CuratorFrameworkFactory.Builder clientFactory; + + /** + * The interval in milliseconds between checks for client access timeouts. + */ + protected final long cleanupTaskInterval; + + /** + * The max time in milliseconds that can elapse since the last client access before cleanup will trigger on the next cleanup task. + */ + protected final long maxElapsedAccessTime; + + /** + * The lock that guards access to the client. + */ + protected final ReentrantLock clientLock = new ReentrantLock(); + + /** + * The underlying client. + */ + protected CuratorFramework client; + + /** + * The thread pool that will check if the client should be cleaned up. + */ + protected ScheduledThreadPoolExecutor executor; + + /** + * The system time in milliseconds that {@link #initClient()} was last called. + */ + protected long lastClientAccess = 0L; + + public LockedZkClientDispatcher(CuratorFrameworkFactory.Builder clientFactory, long cleanupTaskInterval, long maxElapsedAccessTime, TimeUnit timeUnit) { + Preconditions.checkNotNull(clientFactory, "clientFactory must not be null"); + Preconditions.checkNotNull(timeUnit, "timeUnit must not be null"); + + this.clientFactory = clientFactory; + this.maxElapsedAccessTime = timeUnit.toMillis(maxElapsedAccessTime); + this.cleanupTaskInterval = timeUnit.toMillis(cleanupTaskInterval); + } + + /** + * Returns a {@link LockedClient} that has locked access to the underlying {@link CuratorFramework} of this {@link LockedZkClientDispatcher}. The underlying + * client will be non-null and started. This method is intended to be used with a try-with-resources statement. If not used in that manner, you MUST call + * {@link LockedClient#close()} to release the client lock once you are done with it. + * + * @return the new locked client + */ + public LockedClient getLockedClient() { + // Lock access to the client. + clientLock.lock(); + // Ensure the client is initialized. + initClient(); + // Return a locked client. + return new LockedClient(client, clientLock); + } + + /** + * Initialize the underlying client if necessary, and set {@link #lastClientAccess} to the current system time. + */ + private void initClient() { + if (client == null) { + clientLock.lock(); + try { + // Create the client and start it. + client = clientFactory.build(); + client.start(); + + // If we have a finite timeout, create the cleanup task. + if (maxElapsedAccessTime > 0) { + createCleanupTask(); + } + } finally { + clientLock.unlock(); + } + } + // Update the last-accessed time. + lastClientAccess = System.currentTimeMillis(); + } + + /** + * Initialize the executor service if necessary, and add tasks that will check if we've reached the client timeout at the specified cleanup task intervals. + */ + private void createCleanupTask() { + if (executor == null) { + executor = new ScheduledThreadPoolExecutor(1); + } + + Runnable task = new Runnable() { + @Override + public void run() { + // If the max elapsed timeout has been reached, clean up the client. + if (System.currentTimeMillis() - lastClientAccess >= maxElapsedAccessTime) { + cleanupClient(); + } else { + // Otherwise, schedule another task to check again after the designated interval. + executor.schedule(this, cleanupTaskInterval, TimeUnit.MILLISECONDS); + } + } + }; + + // Schedule the task. + executor.schedule(task, cleanupTaskInterval, TimeUnit.MILLISECONDS); + } + + /** + * Clean up the client. + */ + private void cleanupClient() { + if (client != null) { + clientLock.lock(); + try { + if (client != null) { + try { + client.close(); + } catch (Exception e) { + log.warn("Failed to close client", e); + } finally { + client = null; + } + } + } finally { + clientLock.unlock(); + } + } + } + + /** + * Clean up the client and the executor service. + */ + private void cleanupClientAndExecutor() { + clientLock.lock(); + try { + if (executor != null) { + try { + executor.shutdown(); + } catch (Exception e) { + log.warn("Failed to shutdown executor", e); + } + executor = null; + } + cleanupClient(); + } finally { + clientLock.unlock(); + } + } + + /** + * Release the underlying resources held by this {@link LockedZkClientDispatcher}. The underlying {@link CuratorFramework} and executor service will be + * stopped and nullified. + * + * @throws Exception + * if an error occurs during cleanup. + */ + @Override + public void close() throws Exception { + cleanupClientAndExecutor(); + } + + /** + * An {@link AutoCloseable} that holds a reference to the client provided by an instance of {@link LockedZkClientDispatcher}, and the client's associated + * lock. The lock will be unlocked when {@link #close()} is called. + */ + public static class LockedClient implements AutoCloseable { + + private final CuratorFramework client; + private final Lock lock; + + private LockedClient(CuratorFramework client, Lock lock) { + this.client = client; + this.lock = lock; + } + + /** + * Return the guarded {@link CuratorFramework} client. + * + * @return the client + */ + public CuratorFramework getClient() { + return client; + } + + /** + * Release the client lock. + * + * @throws Exception + * if an error occurs + */ + @Override + public void close() throws Exception { + lock.unlock(); + } + } +} diff --git a/web-services/query/src/main/java/datawave/webservice/zookeeper/ObjectValidator.java b/web-services/query/src/main/java/datawave/webservice/zookeeper/ObjectValidator.java new file mode 100644 index 00000000000..5d6a0058a5b --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/zookeeper/ObjectValidator.java @@ -0,0 +1,16 @@ +package datawave.webservice.zookeeper; + +/** + * An interface for defining a validator that can be provided to a {@link ZkObjectPublisher} for pre-validating any updated objects before publishing them to + * subscribers. + */ +public interface ObjectValidator { + + /** + * Validate the given object. Any implementations should throw an exception if the provided object is not considered valid. + * + * @param object + * the object to validate + */ + void validate(Object object) throws Exception; +} diff --git a/web-services/query/src/main/java/datawave/webservice/zookeeper/README.md b/web-services/query/src/main/java/datawave/webservice/zookeeper/README.md new file mode 100644 index 00000000000..129ced13078 --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/zookeeper/README.md @@ -0,0 +1,36 @@ +# ZkObjectPublisher + +The class [ZkObjectPublisher](ZkObjectPublisher.java) provides the ability to trigger and publish updates of a configured class instance to any subscribers using Zookeeper to listen for updates and triggering events. A publisher instance can be configured with the following: +* `namespace`: The unique namespace for the ZkObjectPublisher. **It is critical that this namespace is unique to any configured ZkObjectPublisher instances** on the same server in order to prevent multiple publishers from writing to the same `//attempts/` node in Zookeeper. +* `zookeeperConfig`: The zookeeper connect string, or a filepath of a zookeeper configuration file. +* `hdfsConfigUrls`: A comma-delimited list of hadoop configuration files. +* `objectClass`: The class of the object type the publisher will deserialize and publish. +* `objectValidators`: All validators that a successfully deserialized instance of `objectClass` will be supplied to before being supplied to all subscribers. + +A ZkObjectPublisher will attempt to reload and publish a new instance of its configured class when one of the following happens: + +* The node `//path` is created or modified with non-empty data. +* The node `//trigger` is created, modified, or deleted. + +Upon receiving a trigger event, the publisher will attempt to read and deserialize an instance of the configured class from the filepath stored in the data of the node `//path`. The filepath is expected to point to an XML, JSON, or YAML file, and must conform to one of the following URI schemes: +* A URL: `http://path/to/file` or `https://path/to/file` +* An HDFS file: `hdfs://path/to/file` +* A local file: `file://path/to/file` or `/path/to/file` + +If an instance of the class is successfully deserialized from the file, it will be validated against any configured object validators. Afterward it will be provided to all subscribers that have subscribed to the publisher via `ZkObjectPublisher.subscribeToUpdates(Consumer)`. The status of any triggered attempt will be recorded under the node `//attempts/`. Upon a success, the children of that node will follow the structure: + +```text +/status # The data will be SUCCESS +/cause # The data will be one of the values of the enum ZkObjectPublishCause +/time # The data will be an ISO-8601 string representing the time of the publish attempt +``` +If an error occurs, either when loading an instance of the class from the file, or when providing the new instance to subscribers, the children will follow the structure: +```text +/status # The data will be RELOAD_ERROR or SUBSCRIBER_ERROR +/cause # The data will be one of the values of the enum ZkObjectPublishCause +/time # The data will be an ISO-8601 string representing the time of the publish attempt +/errors # A node containing error_N nodes where N is a number ranging from 0 to one less than the total errors +/errors/error_N/message # A short description of the error +/errors/error_N/stacktrace # The stack trace of the error's exception, if any. If no exception was caught, this node will not exist. +``` +The nodes under `//attempts/` will always reflect the latest reload attempt. diff --git a/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishCause.java b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishCause.java new file mode 100644 index 00000000000..5ccd792ff92 --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishCause.java @@ -0,0 +1,24 @@ +package datawave.webservice.zookeeper; + +public enum ZkObjectPublishCause { + /** + * Indicates the triggering event was the creation of the node {@value ZkObjectPublisher#NODE_PATH} with non-empty data. + */ + PATH_NODE_CREATED, + /** + * Indicates the triggering event was the modification of the node {@value ZkObjectPublisher#NODE_PATH} with non-empty data. + */ + PATH_NODE_MODIFIED, + /** + * Indicates the triggering event was the creation of the node {@value ZkObjectPublisher#NODE_TRIGGER}. + */ + TRIGGER_NODE_CREATED, + /** + * Indicates the triggering event was the modification of the node {@value ZkObjectPublisher#NODE_TRIGGER}. + */ + TRIGGER_NODE_MODIFIED, + /** + * Indicates the triggering event was the deletion of the node {@value ZkObjectPublisher#NODE_TRIGGER}. + */ + TRIGGER_NODE_DELETED +} diff --git a/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishError.java b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishError.java new file mode 100644 index 00000000000..f16077a35f9 --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishError.java @@ -0,0 +1,34 @@ +package datawave.webservice.zookeeper; + +/** + * Represents an error that occurred when attempting to load a new updated object via a {@link ZkObjectPublisher}. + */ +public class ZkObjectPublishError { + + /** + * A short description of the error. + */ + private final String message; + + /** + * The associated exception for the error, if any. + */ + private final Exception exception; + + public ZkObjectPublishError(String message, Exception exception) { + this.message = message; + this.exception = exception; + } + + public String getMessage() { + return message; + } + + public Exception getException() { + return exception; + } + + public boolean hasException() { + return exception != null; + } +} diff --git a/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishResult.java b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishResult.java new file mode 100644 index 00000000000..2a675cf9ff3 --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishResult.java @@ -0,0 +1,79 @@ +package datawave.webservice.zookeeper; + +import java.time.Instant; +import java.util.List; +import java.util.StringJoiner; +import java.util.stream.Collectors; + +/** + * Represents a result from {@link ZkObjectPublisher#getObjectFromZk()}. + */ +public class ZkObjectPublishResult { + + /** + * The updated object. This will be null if no object update could be successfully loaded. + */ + private final Object updatedObject; + + /** + * The status of loading the object. + */ + private final ZkObjectPublishStatus status; + + /** + * A list of any errors that occurred while trying to load the results. + */ + private final List errors; + + /** + * The time that loading the object was attempted. + */ + private final Instant time; + + public static ZkObjectPublishResult success(Instant time, Object pojo) { + return new ZkObjectPublishResult(pojo, ZkObjectPublishStatus.SUCCESS, null, time); + } + + public static ZkObjectPublishResult error(Instant time, String message) { + return new ZkObjectPublishResult(null, ZkObjectPublishStatus.RELOAD_ERROR, List.of(new ZkObjectPublishError(message, null)), time); + } + + public static ZkObjectPublishResult error(Instant time, String message, Exception exception) { + return new ZkObjectPublishResult(null, ZkObjectPublishStatus.RELOAD_ERROR, List.of(new ZkObjectPublishError(message, exception)), time); + } + + public static ZkObjectPublishResult subscriberErrors(Instant time, Object pojo, List exceptions) { + List errors = exceptions.stream().map((e) -> new ZkObjectPublishError("Exception thrown by listener: " + e.getMessage(), e)) + .collect(Collectors.toList()); + return new ZkObjectPublishResult(pojo, ZkObjectPublishStatus.SUBSCRIBER_ERROR, errors, time); + } + + public ZkObjectPublishResult(Object updatedObject, ZkObjectPublishStatus status, List errors, Instant time) { + this.updatedObject = updatedObject; + this.status = status; + this.errors = errors != null ? List.copyOf(errors) : List.of(); + this.time = time; + } + + public Object getUpdatedObject() { + return updatedObject; + } + + public ZkObjectPublishStatus getStatus() { + return status; + } + + public List getErrors() { + return errors; + } + + public Instant getTime() { + return time; + } + + @Override + public String toString() { + return new StringJoiner(", ", ZkObjectPublishResult.class.getSimpleName() + "[", "]").add("updatedObject=" + updatedObject).add("status=" + status) + .add("errors=" + errors).add("time=" + time).toString(); + } +} diff --git a/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishStatus.java b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishStatus.java new file mode 100644 index 00000000000..c3f727117f5 --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishStatus.java @@ -0,0 +1,18 @@ +package datawave.webservice.zookeeper; + +public enum ZkObjectPublishStatus { + /** + * Indicates an object update was successfully loaded from Zookeeper and, if triggered by a trigger event, successfully published to all subscribers. + */ + SUCCESS, + + /** + * Indicates an error occurred when trying to load an object update from Zookeeper. + */ + RELOAD_ERROR, + + /** + * Indicates an object update was successfully loaded from Zookeeper, but one or more subscribers threw an error when provided the updated object. + */ + SUBSCRIBER_ERROR +} diff --git a/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublisher.java b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublisher.java new file mode 100644 index 00000000000..65f65ebdee4 --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublisher.java @@ -0,0 +1,791 @@ +package datawave.webservice.zookeeper; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.URI; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.curator.framework.CuratorFramework; +import org.apache.curator.framework.CuratorFrameworkFactory; +import org.apache.curator.framework.recipes.cache.CuratorCache; +import org.apache.curator.framework.recipes.cache.CuratorCacheListener; +import org.apache.curator.retry.RetryNTimes; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileSystem; +import org.apache.log4j.Logger; +import org.apache.zookeeper.data.Stat; +import org.awaitility.Awaitility; +import org.awaitility.core.ConditionTimeoutException; + +import com.ctc.wstx.stax.WstxInputFactory; +import com.ctc.wstx.stax.WstxOutputFactory; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.format.DataFormatDetector; +import com.fasterxml.jackson.core.format.DataFormatMatcher; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import com.google.common.base.Preconditions; + +import datawave.util.StringUtils; + +/** + * A publisher that can be triggered to deserialize and publish updates of a configured class to subscribers. The publisher leverages Zookeeper and is triggered + * by changes to Zookeeper nodes. The publisher will be triggered to reload an instance of the configured object when: + *
    + *
  • The node {@code //path} is created or modified with non-empty data.
  • + *
  • The node {@code //trigger} is created, modified, or deleted.
  • + *
+ * Upon receiving a trigger event, the publisher will attempt to read and deserialize an instance of the configured class from the filepath stored in the data + * of the node {@code //path}. The filepath is expected to be XML, JSON, or YAML, and must conform to one of the following URI schemes: + *
    + *
  • A URL: {@code http://path/to/file} or {@code https://path/to/file}.
  • + *
  • An HDFS file: {@code hdfs://path/to/file}.
  • + *
  • A local file: {@code file://path/to/file} or {@code /path/to/file}.
  • + *
+ * If an instance of the class is successfully deserialized from the file, it will be validated against any configured object validators. Afterward it will be + * provided to all subscribers that have subscribed to the publisher via {@link ZkObjectPublisher#subscribeToUpdates(Consumer)}. The status of any triggered + * attempt will be recorded under the node {@code //attempts/}. Upon a success, the children nodes will follow the structure: + * + *
+ * /status # The data will be {@link ZkObjectPublishStatus#SUCCESS}
+ * /cause  # The data will be one of {@link ZkObjectPublishCause}
+ * /time   # The data will be an ISO-8601 string representing the time of the publish attempt
+ * 
+ * + * If an error occurs, either when loading an instance of the class from the file, or when providing the new instance to subscribers, the children will follow + * the structure: + * + *
+ * /status                     # The data will be {@link ZkObjectPublishStatus#RELOAD_ERROR} or {@link ZkObjectPublishStatus#SUBSCRIBER_ERROR}
+ * /cause                      # The data will be one of {@link ZkObjectPublishCause}
+ * /time                       # The data will be an ISO-8601 string representing the time of the publish attempt
+ * /errors                     # A node containing error_N nodes where N is a number ranging from 0 to one less than the total errors
+ * /errors/error_N/message     # A short description of the error
+ * /errors/error_N/stacktrace  # The stack trace of the error's exception, if any. If no exception was caught, this node will not exist.
+ * 
+ * + * The nodes under {@code //attempts/} will always reflect the latest reload attempt. + *

+ * NOTE: It is crucial that separate {@link ZkObjectPublisher} instances on the same server are created with unique namespaces in order to + * prevent the same {@code //attempts/} node and its children from being modified by multiple publishers. + */ +public class ZkObjectPublisher { + + private static final Logger log = Logger.getLogger(ZkObjectPublisher.class); + + public static final String NODE_PATH = "/path"; + public static final String NODE_TRIGGER = "/trigger"; + public static final String NODE_ATTEMPTS = "/attempts"; + public static final String NODE_CAUSE = "/cause"; + public static final String NODE_STATUS = "/status"; + public static final String NODE_ERRORS = "/errors"; + public static final String NODE_ERROR_BASE = "/error_"; + public static final String NODE_MESSAGE = "/message"; + public static final String NODE_STACKTRACE = "/stacktrace"; + public static final String NODE_TIME = "/time"; + + /** + * Mapper for JSON files. + */ + private static final JsonMapper jsonMapper = new JsonMapper(); + + /** + * Mapper for XML files. + */ + // Ensure the mapper is created with factories that support the StAX2 API. + private static final XmlMapper xmlMapper = new XmlMapper(new WstxInputFactory(), new WstxOutputFactory()); + + /** + * Mapper for YAML files. + */ + private static final YAMLMapper yamlMapper = new YAMLMapper(); + + /** + * Map of format names to the mappers. + */ + // @formatter:off + private static final Map formatToMapper = Map.of( + jsonMapper.getFactory().getFormatName(), jsonMapper, + xmlMapper.getFactory().getFormatName(), xmlMapper, + yamlMapper.getFactory().getFormatName(), yamlMapper + ); + // @formatter:on + + /** + * Helper class to detect the format of a file. + */ + private static final DataFormatDetector formatDetector = new DataFormatDetector(jsonMapper.getFactory(), xmlMapper.getFactory(), yamlMapper.getFactory()); + + /** + * The finalized path for the node {@code /attempts/}. + */ + private final String baseAttemptNode; + + /** + * The finalized path for the node {@code /attempts//cause}. + */ + private final String attemptCauseNode; + + /** + * The finalized path for the node {@code /attempts//status}. + */ + private final String attemptStatusNode; + + /** + * The finalized path for the node {@code /attempts//errors}. + */ + private final String attemptErrorsNode; + + /** + * The finalized path for the node {@code /attempts//time}. + */ + private final String attemptTimeNode; + + private final String namespace; + private final Configuration hadoopConfig; + private final Class objectClass; + private final List objectValidators; + + /** + * The list of subscribers that should be supplied with new objects after successful reloads. + */ + private List> subscribers = new CopyOnWriteArrayList<>(); + + /** + * A {@link CuratorCache} that will listen for creates and modifications of the node {@code /path}. + */ + private CuratorCache pathCache; + + /** + * A {@link CuratorCache} that will listen for creates, modifications, and deletions of the node {@code /trigger} + */ + private CuratorCache triggerCache; + + /** + * A boolean that will be set to true when {@link #pathCache} is initialized. + */ + private final AtomicBoolean pathCacheInitialized = new AtomicBoolean(false); + + /** + * A boolean that will be set to true when {@link #triggerCache} is initialized. + */ + private final AtomicBoolean triggerCacheInitialized = new AtomicBoolean(false); + + /** + * The lock that must be obtained by any task calling {@link #triggerReload(ZkObjectPublishCause)} in order to perform a reload. + */ + private final Lock reloadLock = new ReentrantLock(); + + /** + * An executor that runs 1 task, and keeps at most 1 in the queue. If a 3rd task arrives, the one in the queue is discarded for the new one. If a bunch of + * reloads occur, we are only interested in supplying listeners with the latest reload attempt. + */ + // @formatter:off + private ThreadPoolExecutor executor = new ThreadPoolExecutor( + 1, // Use a core pool size of 1. + 1, // The maximum pool size is 1. + 0L, TimeUnit.MILLISECONDS, // Keep alive time of 1 ms for idle threads. + new ArrayBlockingQueue<>(1), // Only allow 1 task to be queued at a time. + new ThreadPoolExecutor.DiscardOldestPolicy()); // If a new task is submitted, discard any task present in the queue. + // @formatter:on + + /** + * The client dispatcher. + */ + private LockedZkClientDispatcher clientDispatcher; + + public ZkObjectPublisher(String namespace, String zookeeperConfig, String hdfsConfigUrls, Class objectClass, List objectValidators) { + Preconditions.checkArgument((namespace != null && !namespace.isBlank()), "namespace must not be null or blank"); + Preconditions.checkArgument((zookeeperConfig != null && !zookeeperConfig.isBlank()), "zookeeperConfig must not be null or blank"); + Preconditions.checkNotNull(objectClass, "objectClass must not be null"); + + if (log.isDebugEnabled()) { + log.debug(addLogPrefix("Initializing with namespace=" + namespace + ", zookeeperConfig=" + zookeeperConfig + ", hdfsConfigUrls=" + hdfsConfigUrls + + ", " + "objectClass=" + objectClass.getName() + ", pojoValidators=" + objectValidators)); + } + + this.namespace = namespace.trim(); + this.objectClass = objectClass; + this.objectValidators = objectValidators == null ? List.of() : List.copyOf(objectValidators); + + String zkConnectString; + try { + // If the zookeeper config points to a file, extract the hosts from it. + zkConnectString = ZkUtils.getQuorumPeerConfig(zookeeperConfig); + } catch (Exception e) { + throw new RuntimeException(addLogPrefix("Failed to extract zookeeper connect string from config " + zookeeperConfig), e); + } + + try { + // Load any provided hadoop configurations. + this.hadoopConfig = new Configuration(); + if (hdfsConfigUrls != null && !hdfsConfigUrls.isBlank()) { + for (String url : StringUtils.split(hdfsConfigUrls, ",")) { + hadoopConfig.addResource(new URL(url)); + } + } + } catch (Exception e) { + throw new RuntimeException(addLogPrefix("Failed to load hadoop configuration from URLs " + hdfsConfigUrls), e); + } + + // Construct the finalized attempt node paths to be relative to the server IP address. + String serverIpAddress; + try { + serverIpAddress = InetAddress.getLocalHost().getHostAddress(); + } catch (Exception e) { + throw new RuntimeException(addLogPrefix("Failed to get local host address"), e); + } + baseAttemptNode = NODE_ATTEMPTS + "/" + serverIpAddress; + attemptCauseNode = baseAttemptNode + NODE_CAUSE; + attemptStatusNode = baseAttemptNode + NODE_STATUS; + attemptErrorsNode = baseAttemptNode + NODE_ERRORS; + attemptTimeNode = baseAttemptNode + NODE_TIME; + + // @formatter:off + CuratorFrameworkFactory.Builder clientFactory = CuratorFrameworkFactory.builder() + .namespace(this.namespace) + .connectString(zkConnectString) + .sessionTimeoutMs(60000) + .connectionTimeoutMs(60000) + .retryPolicy(new RetryNTimes(10, 1000)); + // @formatter:on + + clientDispatcher = new LockedZkClientDispatcher(clientFactory, 120000, 120000, TimeUnit.MILLISECONDS); + this.pathCache = createCache(NODE_PATH, clientFactory, () -> createPathCacheListener(pathCacheInitialized)); + this.triggerCache = createCache(NODE_TRIGGER, clientFactory, () -> createTriggerCacheListener(triggerCacheInitialized)); + } + + /** + * Create and return a new {@link CuratorCache} that will watch for events concerning the given node, and supply them to the listener returned by the + * listener supplier. + * + * @param node + * the node + * @param listenerSupplier + * the listener supplier + * @return the new cache + */ + private CuratorCache createCache(String node, CuratorFrameworkFactory.Builder clientFactory, Supplier listenerSupplier) { + try { + CuratorFramework client = clientFactory.build(); + client.start(); + CuratorCache cache = CuratorCache.build(client, node, CuratorCache.Options.SINGLE_NODE_CACHE); + // Add the desired listeners to the cache. + CuratorCacheListener cacheListener = listenerSupplier.get(); + cache.listenable().addListener(cacheListener); + // Start the cache. + cache.start(); + return cache; + } catch (Exception e) { + log.error(addLogPrefix("Failed to create curator cache for path node " + node), e); + throw new RuntimeException(addLogPrefix("Failed to create curator cache for path " + node), e); + } + } + + /** + * Create and return a {@link CuratorCacheListener} that will listen for creations and modifications of the node {@code /path}, and trigger a + * configuration reload if the updated {@code /path} node has non-empty data. The listener will also set the given boolean to true when its + * wrapping {@link CuratorCache} is initialized. + * + * @param initFlag + * a flag to set to true when an initialized event is received by the listener + * @return the cache listener + */ + private CuratorCacheListener createPathCacheListener(AtomicBoolean initFlag) { + // @formatter:off + return CuratorCacheListener.builder() + .afterInitialized() // Ignore any events that occurred before the cache was initialized. + .forInitialized(() -> initFlag.set(true)) // Indicate when the cache is initialized. + .forCreates((node) -> { + byte[] data = node.getData(); + // Only trigger a reload attempt if the data is not empty. + if (data != null && data.length > 0) { + executor.submit(()-> triggerReload(ZkObjectPublishCause.PATH_NODE_CREATED)); + } + }) + .forChanges((oldNode, newNode) -> { + byte[] newData = newNode.getData(); + // Only trigger a reload attempt if the data is not empty. + if(newData != null && newData.length > 0) { + executor.submit(()-> triggerReload(ZkObjectPublishCause.PATH_NODE_MODIFIED)); + } + + }).build(); + // @formatter:on + } + + /** + * Create and return a {@link CuratorCacheListener} that will listen for creations, modifications, and deletions of the node {@code /trigger}, and trigger a + * configuration reload. The listener will also set the given boolean to true when its wrapping {@link CuratorCache} is initialized. + * + * @param initFlag + * a flag to set to true when an initialized event is received by the listener + */ + private CuratorCacheListener createTriggerCacheListener(AtomicBoolean initFlag) { + // @formatter:off + return CuratorCacheListener.builder() + .afterInitialized() // Ignore any events that occurred before the cache was initialized. + .forInitialized(() -> initFlag.set(true)) // Indicate when the cache is initialized. + .forCreates((node) -> executor.submit(()-> triggerReload(ZkObjectPublishCause.TRIGGER_NODE_CREATED))) + .forChanges((oldNode, newNode) -> executor.submit(() -> triggerReload(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED))) + .forDeletes((node) -> executor.submit(() -> triggerReload(ZkObjectPublishCause.TRIGGER_NODE_DELETED))) + .build(); + // @formatter:on + } + + /** + * Wait until the underlying node caches are initialized for at most the given timeout. + * + * @param timeout + * the timeout + * @param unit + * the time unit + * @throws ConditionTimeoutException + * if the caches are not initialized before the timeout + */ + public void awaitCacheInitialization(long timeout, TimeUnit unit) throws ConditionTimeoutException { + Awaitility.await().atMost(timeout, unit).until(this::areCachesInitialized); + } + + /** + * Return whether the underlying node caches are initialized and ready to listen for events. + * + * @return true if all underlying caches are initialized, or false otherwise + */ + public boolean areCachesInitialized() { + return pathCacheInitialized.get() && triggerCacheInitialized.get(); + } + + /** + * Trigger a POJO reload. If a POJO is reloaded, it will be provided to any listeners configured for this {@link ZkObjectPublisher}. + * + * @param cause + * the triggering cause + */ + private void triggerReload(ZkObjectPublishCause cause) { + if (log.isDebugEnabled()) { + log.debug(addLogPrefix("Reload triggered by " + cause)); + } + + // Obtain the reload lock. + reloadLock.lock(); + try { + Instant attemptTime = Instant.now(); + // Attempt to load a new POJO instance. + ZkObjectPublishResult result = getObjectFromZk(); + + // If we successfully loaded a valid subscriber, pass it to any subscribers registered with this updater. + if (result.getStatus() == ZkObjectPublishStatus.SUCCESS) { + if (!subscribers.isEmpty()) { + List subscriberExceptions = new ArrayList<>(); + for (Consumer subscriber : subscribers) { + try { + subscriber.accept(result.getUpdatedObject()); + } catch (Exception e) { + // If an exception is thrown by a subscriber, log it and record it in the status. + log.warn(addLogPrefix("Exception thrown by subscriber " + subscriber), e); + subscriberExceptions.add(e); + } + } + if (log.isDebugEnabled()) { + log.debug(addLogPrefix("Supplied object update to all subscribers")); + } + if (!subscriberExceptions.isEmpty()) { + result = ZkObjectPublishResult.subscriberErrors(result.getTime(), result.getUpdatedObject(), subscriberExceptions); + } + } else { + log.debug(addLogPrefix("No subscribers registered to be supplied updates")); + } + } + + if (log.isDebugEnabled()) { + log.debug(addLogPrefix("Update of " + objectClass.getName() + " completed with attemptTime:=" + attemptTime + ", trigger=" + cause + ", " + + "status=" + result.getStatus() + ", errors=" + result.getErrors())); + } + + // Update the attempt nodes for the latest attempt. + updateAttemptNodes(cause, result.getStatus(), result.getErrors(), attemptTime); + } catch (Exception e) { + log.error(addLogPrefix("Failed to reload object"), e); + throw new RuntimeException("Failed to reload object", e); + } finally { + reloadLock.unlock(); + } + } + + /** + * Make the following changes underneath the namespace configured for this {@link ZkObjectPublisher}. All nodes listed here will be created if they do not + * exist: + *
    + *
  • Set the data for the node {@code /attempts//status} to the bytes of the string form of the given {@link ZkObjectPublishStatus}.
  • + *
  • Set the data for the node {@code /attempts//cause} to the bytes of the string form of the given {@link ZkObjectPublishCause}.
  • + *
  • Set the data for the node {@code /attempts//time} to the bytes of the string form of the given {@link Instant}.
  • + *
  • Depending on the list of error messages provided, make the following changes to {@code /attempts//errors}: + *
      + *
    • If the error messages list is empty, delete the node {@code /attempts//errors} and its children.
    • + *
    • If the error messages list is not empty, set the children of the node {@code /attempts//errors} such that there is one child for + * each error message, with the path {@code error_X} where X equals the index of the error message in the list, and data is set to the bytes of the error + * message.
    • + *
    + *
  • + *
+ * + * @param cause + * the triggering event for the reload + * @param status + * the status + * @param errors + * the errors + * @param time + * the time of the attempt + * @throws Exception + * if an error occurs on Zookeeper + */ + private void updateAttemptNodes(ZkObjectPublishCause cause, ZkObjectPublishStatus status, List errors, Instant time) + throws Exception { + try (LockedZkClientDispatcher.LockedClient lockedClient = clientDispatcher.getLockedClient()) { + CuratorFramework client = lockedClient.getClient(); + // Ensure the base /reload node is created. + client.createContainers(baseAttemptNode); + + setData(client, attemptCauseNode, cause.toString().getBytes()); + setData(client, attemptStatusNode, status.toString().getBytes()); + updateErrorsNode(client, errors); + setData(client, attemptTimeNode, time.toString().getBytes()); + } + } + + /** + * Update the node {@code /attempts//errors} to reflect the contents of the given error list. + * + * @param client + * the client + * @param errors + * the errors + * @throws Exception + * if an error occurs in Zookeeper + */ + private void updateErrorsNode(CuratorFramework client, List errors) throws Exception { + Stat stat = client.checkExists().forPath(attemptErrorsNode); + if (stat != null) { + client.delete().deletingChildrenIfNeeded().forPath(attemptErrorsNode); + } + if (!errors.isEmpty()) { + client.create().forPath(attemptErrorsNode); + for (int i = 0; i < errors.size(); i++) { + ZkObjectPublishError error = errors.get(i); + + String errorNode = attemptErrorsNode + NODE_ERROR_BASE + i; + client.create().forPath(errorNode); + + String messageNode = errorNode + NODE_MESSAGE; + setData(client, messageNode, error.getMessage().getBytes()); + + if (error.hasException()) { + String stacktraceNode = errorNode + NODE_STACKTRACE; + setData(client, stacktraceNode, ExceptionUtils.getStackTrace(error.getException()).getBytes()); + } + } + } + } + + /** + * Set the data for the given node. + * + * @param node + * the path to the node. + * @param data + * the data to set + * @throws Exception + * if an error occurs on Zookeeper + */ + private void setData(CuratorFramework client, String node, byte[] data) throws Exception { + Stat stat = client.checkExists().forPath(node); + if (stat == null) { + client.create().forPath(node, data); + } else { + client.setData().forPath(node, data); + } + } + + /** + * Attempt to load a new POJO from the path specified in the data of the node {@value NODE_PATH} under the zookeeper namespace configured for this + * {@link ZkObjectPublisher}. The path may point to an http, hdfs, or local file. Note that an invocation of this method will not result in the object being + * supplied to any subscribers, nor will the attempt result be recorded to Zookeeper. + * + * @return the result + */ + public ZkObjectPublishResult getObjectFromZk() { + if (log.isDebugEnabled()) { + log.debug(addLogPrefix("Attempting to load new instance of " + objectClass.getName() + " from filepath in " + NODE_PATH)); + } + + Instant attemptTime = Instant.now(); + try (LockedZkClientDispatcher.LockedClient lockedClient = clientDispatcher.getLockedClient()) { + CuratorFramework client = lockedClient.getClient(); + + // Verify that the path node exists. + Stat stat = client.checkExists().forPath(NODE_PATH); + if (stat == null) { + if (log.isDebugEnabled()) { + log.debug(addLogPrefix("Node " + NODE_PATH + " does not exist, skipping reload")); + } + return ZkObjectPublishResult.error(attemptTime, "Node does not exist: " + namespace + NODE_PATH); + } + + // Fetch the path from the path node. + byte[] pathBytes = client.getData().forPath(NODE_PATH); + + // Verify we have a non-blank path. + if (pathBytes == null) { + if (log.isDebugEnabled()) { + log.debug(addLogPrefix("Node " + NODE_PATH + " does not have any data, skipping reload")); + } + return ZkObjectPublishResult.error(attemptTime, "File path not set in data for node " + namespace + NODE_PATH); + } + + String path = new String(pathBytes); + if (path.isBlank()) { + if (log.isDebugEnabled()) { + log.debug(addLogPrefix("Node " + NODE_PATH + " does not have a non-blank filepath, skipping reload")); + } + return ZkObjectPublishResult.error(attemptTime, "Blank filepath set in data for node " + namespace + NODE_PATH); + } + + // Trim the path of any leading/trailing whitespace. + path = path.trim(); + + // Read the contents of the file. + byte[] contents; + try { + contents = getFileContents(path); + } catch (NoSuchFileException e) { + log.error(addLogPrefix("Failed to read contents from file " + path), e); + return ZkObjectPublishResult.error(attemptTime, "File not found: " + path, e); + } catch (Exception e) { + log.error(addLogPrefix("Failed to read contents from file " + path), e); + return ZkObjectPublishResult.error(attemptTime, "Failed to read contents from file " + path + ": " + e.getMessage(), e); + } + + // Determine the format (XML, JSON, YAML) and use the corresponding mapper to deserialize the contents. + Object pojo; + DataFormatMatcher format = formatDetector.findFormat(contents); + if (format.hasMatch()) { + JsonFactory factory = format.getMatch(); + try { + // Deserialize the POJO using the associated mapper for the format. + pojo = formatToMapper.get(factory.getFormatName()).readValue(contents, objectClass); + } catch (Exception e) { + log.error(addLogPrefix("Failed to deserialize file " + path + " to a " + objectClass.getName()), e); + return ZkObjectPublishResult.error(attemptTime, "Failed to deserialize file to a " + objectClass.getName(), e); + } + } else { + // If we do not have a match for a supported mapper, return an error. + if (log.isDebugEnabled()) { + log.debug(addLogPrefix("File " + path + " could not be detected as XML, JSON, or YAML, skipping reload")); + } + return ZkObjectPublishResult.error(attemptTime, "File " + path + " must be XML, JSON, or YAML"); + } + + // If we successfully deserialize the POJO, validate it against all configured validators. + for (ObjectValidator validator : objectValidators) { + try { + validator.validate(pojo); + } catch (Exception e) { + if (log.isDebugEnabled()) { + log.debug(addLogPrefix("Reloaded " + objectClass.getName() + " failed validation, skipping reload"), e); + } + return ZkObjectPublishResult.error(attemptTime, "Reloaded " + objectClass.getName() + " failed validation: " + e.getMessage(), e); + } + } + + return ZkObjectPublishResult.success(attemptTime, pojo); + } catch (Exception e) { + log.error(addLogPrefix("Failed to reload new instance of " + objectClass.getName() + " from Zookeeper"), e); + throw new RuntimeException("Failed to load new instance of " + objectClass.getName() + " from Zookeeper", e); + } + } + + /** + * Return an {@link InputStream} for the file on the given path. The path will be examined and handled based on the following schemes: + *
    + *
  • {@code http://} or {@code https://}: The path will be treated as a URL.
  • + *
  • {@code hdfs://}: The path will be treated as an HDFS filepath.
  • + *
  • {@code file://} or none: The path will be treated as a local filepath.
  • + *
+ * + * @param path + * the path + * @return the new {@link InputStream} + * @throws IOException + * if an {@link InputStream} cannot be created + */ + private byte[] getFileContents(String path) throws IOException { + URI uri = URI.create(path); + String scheme = uri.getScheme(); + if (scheme != null) { + if (scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https")) { + return getContentFromURL(path); + } else if (scheme.equalsIgnoreCase("hdfs")) { + return getContentFromHdfs(uri.getPath()); + } else if (scheme.equalsIgnoreCase("file")) { + return getContentFromLocalFs(uri.getPath()); + } else { + throw new IOException("Unsupported URI scheme: " + scheme); + } + } else { + return getContentFromLocalFs(uri.getPath()); + } + } + + /** + * Return the contents of the file on the given URL. + * + * @param path + * the URL + * @return the {@link InputStream} + * @throws IOException + * if an {@link InputStream} cannot be created + */ + private byte[] getContentFromURL(String path) throws IOException { + if (log.isDebugEnabled()) { + log.debug(addLogPrefix("Attempting to read file from URL: " + path)); + } + URL url = new URL(path); + try (InputStream is = url.openStream()) { + return IOUtils.toByteArray(is); + } + } + + /** + * Return the contents of the file on HDFS. + * + * @param path + * the filepath + * @return the {@link InputStream} + * @throws IOException + * if an {@link InputStream} cannot be created + */ + private byte[] getContentFromHdfs(String path) throws IOException { + if (log.isDebugEnabled()) { + log.debug(addLogPrefix("Attempting to read file from HDFS: " + path)); + } + FileSystem fileSystem = FileSystem.get(hadoopConfig); + try (InputStream is = fileSystem.open(new org.apache.hadoop.fs.Path(path))) { + return IOUtils.toByteArray(is); + } + } + + /** + * Return the contents of the file on the local filesystem. + * + * @param path + * the filepath + * @return the {@link InputStream} + * @throws IOException + * if an {@link InputStream} cannot be created + */ + private byte[] getContentFromLocalFs(String path) throws IOException { + if (log.isDebugEnabled()) { + log.debug(addLogPrefix("Attempting to read file from local filesystem: " + path)); + } + try (InputStream is = Files.newInputStream(Path.of(path), StandardOpenOption.READ)) { + return IOUtils.toByteArray(is); + } + } + + /** + * Add a {@link Consumer} that, when a new POJO is loaded a path specified in Zookeeper, will be provided that configuration. + * + * @param subscriber + * the subscriber + */ + public void subscribeToUpdates(Consumer subscriber) { + this.subscribers.add(subscriber); + } + + /** + * Clean up resources used by this {@link ZkObjectPublisher}. Performs the following tasks: + *
    + *
  • Close the curator caches for the nodes {@value #NODE_PATH} and @value #NODE_TRIGGER}.
  • + *
  • Shut down the executor service that executes reload tasks.
  • + *
  • Clear the subscribers list.
  • + *
  • Close the locked client dispatcher.
  • + *
+ */ + public void close() { + if (pathCache != null) { + try { + pathCache.close(); + } catch (Exception e) { + log.warn(addLogPrefix("Failed to close path cache"), e); + } finally { + pathCache = null; + } + } + if (triggerCache != null) { + try { + triggerCache.close(); + } catch (Exception e) { + log.warn(addLogPrefix("Failed to close trigger cache"), e); + } finally { + triggerCache = null; + } + } + if (executor != null) { + try { + executor.shutdown(); + } catch (Exception e) { + log.warn(addLogPrefix("Failed to close executor"), e); + } finally { + executor = null; + } + } + + if (subscribers != null) { + try { + subscribers.clear(); + } catch (Exception e) { + log.warn(addLogPrefix("Failed to clear subscribers"), e); + } finally { + subscribers = null; + } + } + + if (clientDispatcher != null) { + try { + clientDispatcher.close(); + } catch (Exception e) { + log.warn(addLogPrefix("Failed to close client dispatcher"), e); + } finally { + clientDispatcher = null; + } + } + } + + private String addLogPrefix(String message) { + return namespace + ": " + message; + } +} diff --git a/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkUtils.java b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkUtils.java new file mode 100644 index 00000000000..ad56939c4d5 --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkUtils.java @@ -0,0 +1,68 @@ +package datawave.webservice.zookeeper; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.apache.zookeeper.server.quorum.QuorumPeer; +import org.apache.zookeeper.server.quorum.QuorumPeerConfig; + +/** + * Utility class for Zookeeper operations. + */ +public final class ZkUtils { + + public static final byte[] EMPTY_DATA = new byte[0]; + + /** + * Return a formatted Zookeeper connect string that can be used to connect to a running Zookeeper server. + * + * @param config + * the configuration file/string + * @return the configuration + * @throws QuorumPeerConfig.ConfigException + * if the argument is a file that cannot be parsed as a zookeeper config file + */ + public static String getQuorumPeerConfig(String config) throws QuorumPeerConfig.ConfigException { + URI zookeeperConfigFile; + try { + URI uri = new URI(config); + // Create the path differently depending on whether the config is a filepath with a URI scheme or not. This is important to avoid errors when trying + // to determine if the config points to a file. + Path path = uri.getScheme() != null ? Paths.get(uri) : Paths.get(config); + if (!Files.isRegularFile(path)) { + return config; + } + zookeeperConfigFile = uri; + } catch (Exception e) { + // The config argument does not point to an existing file. Try it as is. + return config; + } + + // If the config points to an existing file, attempt to parse it as a zookeeper config file. + QuorumPeerConfig zooConfig = new QuorumPeerConfig(); + zooConfig.parse(zookeeperConfigFile.getPath()); + StringBuilder sb = new StringBuilder(); + + int port = zooConfig.getClientPortAddress().getPort(); + + // If there are any servers in the config, add their client addresses. + for (QuorumPeer.QuorumServer server : zooConfig.getServers().values()) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append(server.addr.getReachableOrOne().getHostName()).append(':').append(port); + } + + // If no server addresses were added, use the hostname of the client port address. + if (sb.length() == 0) { + sb.append(zooConfig.getClientPortAddress().getHostName()).append(':').append(port); + } + return sb.toString(); + } + + private ZkUtils() { + throw new UnsupportedOperationException(); + } +} diff --git a/web-services/query/src/test/java/datawave/webservice/query/cache/QueryExpirationBeanTest.java b/web-services/query/src/test/java/datawave/webservice/query/cache/QueryExpirationBeanTest.java index 63d4485c6f9..8e17c2ec558 100644 --- a/web-services/query/src/test/java/datawave/webservice/query/cache/QueryExpirationBeanTest.java +++ b/web-services/query/src/test/java/datawave/webservice/query/cache/QueryExpirationBeanTest.java @@ -12,6 +12,7 @@ import org.junit.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; import datawave.core.common.connection.AccumuloConnectionFactory; import datawave.core.query.logic.QueryLogic; @@ -49,12 +50,13 @@ public void setUp() throws Exception { queryLimiter = Mockito.mock(QueryLimiter.class); this.bean = new QueryExpirationBean(); - bean.setQueryCache(queryCache); - bean.setQueryExpirationProperties(properties); - bean.setConnectionFactory(connectionFactory); - bean.setCreatedQueryLogicCacheBean(queryLogicCache); - bean.setQueryMetricsBean(metricsBean); - bean.setQueryLimiter(queryLimiter); + + ReflectionTestUtils.setField(bean, "queryCache", queryCache); + ReflectionTestUtils.setField(bean, "config", properties); + ReflectionTestUtils.setField(bean, "connectionFactory", connectionFactory); + ReflectionTestUtils.setField(bean, "queryLogicCacheBean", queryLogicCache); + ReflectionTestUtils.setField(bean, "metricsBean", metricsBean); + ReflectionTestUtils.setField(bean, "queryLimiter", queryLimiter); bean.init(); } diff --git a/web-services/query/src/test/java/datawave/webservice/query/limit/ActiveQueryTrackerTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/ActiveQueryTrackerTest.java index 404249e109c..9831fdb4e45 100644 --- a/web-services/query/src/test/java/datawave/webservice/query/limit/ActiveQueryTrackerTest.java +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/ActiveQueryTrackerTest.java @@ -26,7 +26,7 @@ class ActiveQueryTrackerTest { @BeforeEach void setUp() throws Exception { server = new TestingServer(); - tracker = new ActiveQueryTracker(server.getConnectString(), 120000); + tracker = new ActiveQueryTracker(server.getConnectString(), 120000L); } @AfterEach diff --git a/web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableQueryLimitConfigurationTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableQueryLimitConfigurationTest.java new file mode 100644 index 00000000000..1d941de4803 --- /dev/null +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableQueryLimitConfigurationTest.java @@ -0,0 +1,84 @@ +package datawave.webservice.query.limit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class ImmutableQueryLimitConfigurationTest { + + /** + * Verify that an instance of {@link ImmutableUserLimitConfiguration} created from a {@link UserLimitConfiguration} is considered equal to its mutable + * equivalent, has the same hashcode, and cannot be modified. + */ + @Test + void testImmutableCopy() { + // Create the config to copy. + QueryLimitConfiguration config = new QueryLimitConfiguration(); + config.setDefaultUserQueryLimit(100); + config.setDefaultSystemQueryLimit(1000); + config.setInternalCacheMaxSize(200); + + UserLimitConfiguration userLimitConfig1 = new UserLimitConfiguration(); + userLimitConfig1.setUserDn("CN=User A, C=US"); + userLimitConfig1.setQueryLimit(25); + userLimitConfig1.setQueryLogicGroupLimits(Map.of("EdgeGroup.*", 20, "EventGroup", 50)); + UserLimitConfiguration userLimitConfig2 = new UserLimitConfiguration(); + userLimitConfig2.setUserDn("CN=User B, C=US"); + userLimitConfig2.setQueryLimit(100); + userLimitConfig2.setQueryLogicGroupLimits(Map.of("EdgeGroup.*", 15, "EventGroup", 25)); + config.setUserConfigs(List.of(userLimitConfig1, userLimitConfig2)); + + SystemLimitConfiguration systemLimitConfig1 = new SystemLimitConfiguration(); + systemLimitConfig1.setSystemPattern("Artemis.*"); + systemLimitConfig1.setQueryLimit(2000); + systemLimitConfig1.setCountsAgainstUserLimit(false); + systemLimitConfig1.setQueryLogicGroupLimits(Map.of("EdgeGroup.*", 300, "EventGroup", 500)); + SystemLimitConfiguration systemLimitConfig2 = new SystemLimitConfiguration(); + systemLimitConfig2.setSystemPattern("Athena.*"); + systemLimitConfig2.setQueryLimit(1500); + systemLimitConfig2.setCountsAgainstUserLimit(true); + systemLimitConfig2.setQueryLogicGroupLimits(Map.of("EdgeGroup.*", 600, "EventGroup", 800)); + config.setSystemConfigs(List.of(systemLimitConfig1, systemLimitConfig2)); + + QueryLogicGroupLimitConfiguration queryLogicGroupLimitConfig1 = new QueryLogicGroupLimitConfiguration(); + queryLogicGroupLimitConfig1.setGroupName("EgdeGroupA"); + queryLogicGroupLimitConfig1.setQueryLimit(25); + queryLogicGroupLimitConfig1.setQueryLogicPattern("Edge.*QueryLogic"); + QueryLogicGroupLimitConfiguration queryLogicGroupLimitConfig2 = new QueryLogicGroupLimitConfiguration(); + queryLogicGroupLimitConfig2.setGroupName("EventGroupA"); + queryLogicGroupLimitConfig2.setQueryLimit(30); + queryLogicGroupLimitConfig2.setQueryLogicPattern("Event.*QueryLogic"); + config.setQueryLogicGroupConfigs(List.of(queryLogicGroupLimitConfig1, queryLogicGroupLimitConfig2)); + + // Create the immutable copy. + ImmutableQueryLimitConfiguration immutable = new ImmutableQueryLimitConfiguration(config); + + // Verify they have the same hashcode. + assertEquals(config.hashCode(), immutable.hashCode()); + + // Verify the two configurations are considered equal. + assertEquals(config, immutable); + + // Verify that the setter methods cannot be invoked. + assertThrows(UnsupportedOperationException.class, () -> immutable.setDefaultUserQueryLimit(75)); + assertThrows(UnsupportedOperationException.class, () -> immutable.setDefaultSystemQueryLimit(2000)); + assertThrows(UnsupportedOperationException.class, () -> immutable.setInternalCacheMaxSize(300)); + assertThrows(UnsupportedOperationException.class, () -> immutable.setUserConfigs(List.of())); + assertThrows(UnsupportedOperationException.class, () -> immutable.setSystemConfigs(List.of())); + assertThrows(UnsupportedOperationException.class, () -> immutable.setQueryLogicGroupConfigs(List.of())); + + // Verify the string representations are equal other than the class names. + String mutableString = config.toString(); + String immutableString = immutable.toString(); + + assertTrue(mutableString.startsWith(QueryLimitConfiguration.class.getSimpleName())); + assertTrue(immutableString.startsWith(ImmutableQueryLimitConfiguration.class.getSimpleName())); + assertEquals(mutableString, immutableString.replaceAll("Immutable", "")); + } + +} diff --git a/web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableQueryLogicGroupLimitConfigurationTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableQueryLogicGroupLimitConfigurationTest.java new file mode 100644 index 00000000000..7916f3677cd --- /dev/null +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableQueryLogicGroupLimitConfigurationTest.java @@ -0,0 +1,46 @@ +package datawave.webservice.query.limit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class ImmutableQueryLogicGroupLimitConfigurationTest { + + /** + * Verify that an instance of {@link ImmutableQueryLogicGroupLimitConfiguration} created from a {@link QueryLogicGroupLimitConfiguration} is considered + * equal to its mutable equivalent, has the same hashcode, and cannot be modified. + */ + @Test + void testImmutableCopy() { + // Create the config to copy. + QueryLogicGroupLimitConfiguration config = new QueryLogicGroupLimitConfiguration(); + config.setGroupName("EgdeGroupA"); + config.setQueryLimit(25); + config.setQueryLogicPattern("Edge.*QueryLogic"); + + // Create the immutable copy. + ImmutableQueryLogicGroupLimitConfiguration immutable = new ImmutableQueryLogicGroupLimitConfiguration(config); + + // Verify they have the same hashcode. + assertEquals(config.hashCode(), immutable.hashCode()); + + // Verify the two configurations are considered equal. + assertEquals(config, immutable); + + // Verify that the setter methods cannot be invoked. + assertThrows(UnsupportedOperationException.class, () -> immutable.setGroupName("Other Name")); + assertThrows(UnsupportedOperationException.class, () -> immutable.setQueryLimit(10)); + assertThrows(UnsupportedOperationException.class, () -> immutable.setQueryLogicPattern("Event.*QueryLogic")); + + // Verify the string representations are equal other than the class name. + String mutableString = config.toString(); + String immutableString = immutable.toString(); + + assertTrue(mutableString.startsWith(QueryLogicGroupLimitConfiguration.class.getSimpleName())); + assertTrue(immutableString.startsWith(ImmutableQueryLogicGroupLimitConfiguration.class.getSimpleName())); + assertEquals(mutableString.substring(QueryLogicGroupLimitConfiguration.class.getSimpleName().length()), + immutableString.substring(ImmutableQueryLogicGroupLimitConfiguration.class.getSimpleName().length())); + } +} diff --git a/web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableSystemLimitConfigurationTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableSystemLimitConfigurationTest.java new file mode 100644 index 00000000000..b0f0d6ea7ed --- /dev/null +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableSystemLimitConfigurationTest.java @@ -0,0 +1,50 @@ +package datawave.webservice.query.limit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class ImmutableSystemLimitConfigurationTest { + + /** + * Verify that an instance of {@link ImmutableSystemLimitConfiguration} created from a {@link SystemLimitConfiguration} is considered equal to its mutable + * equivalent, has the same hashcode, and cannot be modified. + */ + @Test + void testImmutableCopy() { + // Create the config to copy. + SystemLimitConfiguration config = new SystemLimitConfiguration(); + config.setSystemPattern("Artemis.*"); + config.setQueryLimit(2000); + config.setCountsAgainstUserLimit(false); + config.setQueryLogicGroupLimits(Map.of("EdgeGroup.*", 300, "EventGroup", 500)); + + // Create the immutable copy. + ImmutableSystemLimitConfiguration immutable = new ImmutableSystemLimitConfiguration(config); + + // Verify they have the same hashcode. + assertEquals(config.hashCode(), immutable.hashCode()); + + // Verify the two configurations are considered equal. + assertEquals(config, immutable); + + // Verify that the setter methods cannot be invoked. + assertThrows(UnsupportedOperationException.class, () -> immutable.setSystemPattern("Athena.*")); + assertThrows(UnsupportedOperationException.class, () -> immutable.setQueryLimit(1500)); + assertThrows(UnsupportedOperationException.class, () -> immutable.setCountsAgainstUserLimit(true)); + assertThrows(UnsupportedOperationException.class, () -> immutable.setQueryLogicGroupLimits(Map.of())); + + // Verify the string representations are equal other than the class name. + String mutableString = config.toString(); + String immutableString = immutable.toString(); + + assertTrue(mutableString.startsWith(SystemLimitConfiguration.class.getSimpleName())); + assertTrue(immutableString.startsWith(ImmutableSystemLimitConfiguration.class.getSimpleName())); + assertEquals(mutableString.substring(SystemLimitConfiguration.class.getSimpleName().length()), + immutableString.substring(ImmutableSystemLimitConfiguration.class.getSimpleName().length())); + } +} diff --git a/web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableUserLimitConfigurationTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableUserLimitConfigurationTest.java new file mode 100644 index 00000000000..da15e04d76b --- /dev/null +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableUserLimitConfigurationTest.java @@ -0,0 +1,48 @@ +package datawave.webservice.query.limit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class ImmutableUserLimitConfigurationTest { + + /** + * Verify that an instance of {@link ImmutableUserLimitConfiguration} created from a {@link UserLimitConfiguration} is considered equal to its mutable + * equivalent, has the same hashcode, and cannot be modified. + */ + @Test + void testImmutableCopy() { + // Create the config to copy. + UserLimitConfiguration config = new UserLimitConfiguration(); + config.setUserDn("CN=User A, C=US"); + config.setQueryLimit(25); + config.setQueryLogicGroupLimits(Map.of("EdgeGroup.*", 20, "EventGroup", 50)); + + // Create the immutable copy. + ImmutableUserLimitConfiguration immutable = new ImmutableUserLimitConfiguration(config); + + // Verify they have the same hashcode. + assertEquals(config.hashCode(), immutable.hashCode()); + + // Verify the two configurations are considered equal. + assertEquals(config, immutable); + + // Verify that the setter methods cannot be invoked. + assertThrows(UnsupportedOperationException.class, () -> immutable.setUserDn("otherDn")); + assertThrows(UnsupportedOperationException.class, () -> immutable.setQueryLimit(10)); + assertThrows(UnsupportedOperationException.class, () -> immutable.setQueryLogicGroupLimits(Map.of())); + + // Verify the string representations are equal other than the class name. + String mutableString = config.toString(); + String immutableString = immutable.toString(); + + assertTrue(mutableString.startsWith(UserLimitConfiguration.class.getSimpleName())); + assertTrue(immutableString.startsWith(ImmutableUserLimitConfiguration.class.getSimpleName())); + assertEquals(mutableString.substring(UserLimitConfiguration.class.getSimpleName().length()), + immutableString.substring(ImmutableUserLimitConfiguration.class.getSimpleName().length())); + } +} diff --git a/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationTest.java new file mode 100644 index 00000000000..589397fdce1 --- /dev/null +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationTest.java @@ -0,0 +1,265 @@ +package datawave.webservice.query.limit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.ctc.wstx.stax.WstxInputFactory; +import com.ctc.wstx.stax.WstxOutputFactory; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; + +/** + * Tests to verify that there are no issues serializing/deserializing a {@link QueryLimitConfiguration} using jackson. + */ +class QueryLimitConfigurationTest { + + // @formatter:off + private static final String CONFIG_AS_JSON = + "{\n" + + " \"defaultUserQueryLimit\" : 100,\n" + + " \"defaultSystemQueryLimit\" : 1000,\n" + + " \"internalCacheMaxSize\" : 200,\n" + + " \"userConfigs\" : [ {\n" + + " \"userDn\" : \"CN=User A, C=US\",\n" + + " \"queryLimit\" : 50,\n" + + " \"queryLogicGroupLimits\" : {\n" + + " \"EdgeQueryLogic\" : 15,\n" + + " \"EventQueryLogic\" : 5\n" + + " }\n" + + " }, {\n" + + " \"userDn\" : \"CN=User B, C=US\",\n" + + " \"queryLimit\" : 25,\n" + + " \"queryLogicGroupLimits\" : {\n" + + " \"EventQueryLogic\" : 10\n" + + " }\n" + " } ],\n" + + " \"systemConfigs\" : [ {\n" + + " \"systemPattern\" : \".*Athena\",\n" + + " \"countsAgainstUserLimit\" : true,\n" + + " \"queryLimit\" : 2000,\n" + + " \"queryLogicGroupLimits\" : {\n" + + " \"EdgeQueryLogic\" : 800,\n" + + " \"EventQueryLogic\" : 500\n" + + " }\n" + + " }, {\n" + + " \"systemPattern\" : \".*Artemis\",\n" + + " \"countsAgainstUserLimit\" : false,\n" + + " \"queryLimit\" : 1500,\n" + + " \"queryLogicGroupLimits\" : {\n" + + " \"EventQueryLogic\" : 600\n" + + " }\n" + + " } ],\n" + + " \"queryLogicGroupConfigs\" : [ {\n" + + " \"groupName\" : \"DefaultEdgeQueryLogicLimit\",\n" + + " \"queryLogicPattern\" : \"Edge.*QueryLogic\",\n" + + " \"queryLimit\" : 50\n" + + " }, {\n" + + " \"groupName\" : \"DefaultEventQueryLogicLimit\",\n" + + " \"queryLogicPattern\" : \"Event.*QueryLogic\",\n" + + " \"queryLimit\" : 25\n" + + " } ]\n" + + "}"; + // @formatter:on + + // @formatter:off + private static final String CONFIG_AS_XML = + "\n" + + "\n" + + " 100\n" + + " 1000\n" + + " 200\n" + + " \n" + + " \n" + + " CN=User A, C=US\n" + + " 50\n" + + " \n" + + " 15\n" + + " 5\n" + + " \n" + + " \n" + + " \n" + + " CN=User B, C=US\n" + + " 25\n" + + " \n" + + " 10\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " .*Athena\n" + + " true\n" + + " 2000\n" + + " \n" + + " 800\n" + + " 500\n" + + " \n" + + " \n" + + " \n" + + " .*Artemis\n" + + " false\n" + + " 1500\n" + + " \n" + + " 600\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " DefaultEdgeQueryLogicLimit\n" + + " Edge.*QueryLogic\n" + + " 50\n" + + " \n" + + " \n" + + " DefaultEventQueryLogicLimit\n" + + " Event.*QueryLogic\n" + + " 25\n" + + " \n" + + " \n" + + "\n"; + // @formatter:on + + // @formatter:off + private static final String CONFIG_AS_YAML = + "---\n" + + "defaultUserQueryLimit: 100\n" + + "defaultSystemQueryLimit: 1000\n" + + "internalCacheMaxSize: 200\n" + + "userConfigs:\n" + + "- userDn: \"CN=User A, C=US\"\n" + + " queryLimit: 50\n" + + " queryLogicGroupLimits:\n" + + " EdgeQueryLogic: 15\n" + + " EventQueryLogic: 5\n" + + "- userDn: \"CN=User B, C=US\"\n" + + " queryLimit: 25\n" + + " queryLogicGroupLimits:\n" + + " EventQueryLogic: 10\n" + + "systemConfigs:\n" + + "- systemPattern: \".*Athena\"\n" + + " countsAgainstUserLimit: true\n" + + " queryLimit: 2000\n" + + " queryLogicGroupLimits:\n" + + " EdgeQueryLogic: 800\n" + + " EventQueryLogic: 500\n" + + "- systemPattern: \".*Artemis\"\n" + + " countsAgainstUserLimit: false\n" + + " queryLimit: 1500\n" + + " queryLogicGroupLimits:\n" + + " EventQueryLogic: 600\n" + + "queryLogicGroupConfigs:\n" + + "- groupName: \"DefaultEdgeQueryLogicLimit\"\n" + + " queryLogicPattern: \"Edge.*QueryLogic\"\n" + + " queryLimit: 50\n" + + "- groupName: \"DefaultEventQueryLogicLimit\"\n" + + " queryLogicPattern: \"Event.*QueryLogic\"\n" + + " queryLimit: 25\n"; + // @formatter:on + + private QueryLimitConfiguration config; + + @BeforeEach + void setUp() { + UserLimitConfiguration userLimit1 = new UserLimitConfiguration(); + userLimit1.setUserDn("CN=User A, C=US"); + userLimit1.setQueryLimit(50); + Map userLimit1Map = new LinkedHashMap<>(); + userLimit1Map.put("EdgeQueryLogic", 15); + userLimit1Map.put("EventQueryLogic", 5); + userLimit1.setQueryLogicGroupLimits(userLimit1Map); + + UserLimitConfiguration userLimit2 = new UserLimitConfiguration(); + userLimit2.setUserDn("CN=User B, C=US"); + userLimit2.setQueryLimit(25); + userLimit2.setQueryLogicGroupLimits(Map.of("EventQueryLogic", 10)); + + SystemLimitConfiguration systemLimit1 = new SystemLimitConfiguration(); + systemLimit1.setSystemPattern(".*Athena"); + systemLimit1.setQueryLimit(2000); + systemLimit1.setCountsAgainstUserLimit(true); + Map systemLimit1Map = new LinkedHashMap<>(); + systemLimit1Map.put("EdgeQueryLogic", 800); + systemLimit1Map.put("EventQueryLogic", 500); + systemLimit1.setQueryLogicGroupLimits(systemLimit1Map); + + SystemLimitConfiguration systemLimit2 = new SystemLimitConfiguration(); + systemLimit2.setSystemPattern(".*Artemis"); + systemLimit2.setQueryLimit(1500); + systemLimit2.setCountsAgainstUserLimit(false); + systemLimit2.setQueryLogicGroupLimits(Map.of("EventQueryLogic", 600)); + + QueryLogicGroupLimitConfiguration queryLogicGroupLimit1 = new QueryLogicGroupLimitConfiguration(); + queryLogicGroupLimit1.setGroupName("DefaultEdgeQueryLogicLimit"); + queryLogicGroupLimit1.setQueryLimit(50); + queryLogicGroupLimit1.setQueryLogicPattern("Edge.*QueryLogic"); + + QueryLogicGroupLimitConfiguration queryLogicGroupLimit2 = new QueryLogicGroupLimitConfiguration(); + queryLogicGroupLimit2.setGroupName("DefaultEventQueryLogicLimit"); + queryLogicGroupLimit2.setQueryLimit(25); + queryLogicGroupLimit2.setQueryLogicPattern("Event.*QueryLogic"); + + config = new QueryLimitConfiguration(); + config.setDefaultUserQueryLimit(100); + config.setDefaultSystemQueryLimit(1000); + config.setUserConfigs(List.of(userLimit1, userLimit2)); + config.setSystemConfigs(List.of(systemLimit1, systemLimit2)); + config.setQueryLogicGroupConfigs(List.of(queryLogicGroupLimit1, queryLogicGroupLimit2)); + } + + private static final JsonMapper jsonMapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + + // Non-builder creation required to make pretty print work. + private static final XmlMapper xmlMapper = new XmlMapper(new WstxInputFactory(), new WstxOutputFactory()); + + private static final YAMLMapper yamlMapper = new YAMLMapper(); + + static { + yamlMapper.enable(SerializationFeature.INDENT_OUTPUT); + } + + @BeforeAll + static void beforeAll() { + xmlMapper.enable(SerializationFeature.INDENT_OUTPUT); + xmlMapper.enable(ToXmlGenerator.Feature.WRITE_XML_DECLARATION); + } + + @Test + void testJsonSerialization() throws JsonProcessingException { + assertEquals(CONFIG_AS_JSON, jsonMapper.writeValueAsString(config)); + } + + @Test + void testJsonDeserialization() throws JsonProcessingException { + assertEquals(config, jsonMapper.readValue(CONFIG_AS_JSON, QueryLimitConfiguration.class)); + } + + @Test + void testXmlSerialization() throws JsonProcessingException { + assertEquals(CONFIG_AS_XML, xmlMapper.writeValueAsString(config)); + } + + @Test + void testXmlDeserialization() throws JsonProcessingException { + assertEquals(config, xmlMapper.readValue(CONFIG_AS_XML, QueryLimitConfiguration.class)); + } + + @Test + void testYamlSerialization() throws JsonProcessingException { + assertEquals(CONFIG_AS_YAML, yamlMapper.writeValueAsString(config)); + } + + @Test + void testYamlDeserialization() throws JsonProcessingException { + assertEquals(config, yamlMapper.readValue(CONFIG_AS_YAML, QueryLimitConfiguration.class)); + } +} diff --git a/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationValidationUtilsTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationValidationUtilsTest.java new file mode 100644 index 00000000000..b84687c29b2 --- /dev/null +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationValidationUtilsTest.java @@ -0,0 +1,269 @@ +package datawave.webservice.query.limit; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link QueryLimitConfigurationValidationUtils}. + */ +class QueryLimitConfigurationValidationUtilsTest { + + /** + * Tests for {@link QueryLimitConfigurationValidationUtils#validate(QueryLimitConfiguration)}. + */ + @Nested + class QueryLimitConfigurationValidationTests { + + /** + * Verify the default user query limit cannot be less than 1. + */ + @Test + void testDefaultUserQueryLimitLessThanOne() { + QueryLimitConfiguration config = new QueryLimitConfiguration(); + config.setDefaultUserQueryLimit(0); + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validate(config)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Default user query limit must be greater than 0"); + } + + /** + * Verify the default internal max cache size cannot be less than 1. + */ + @Test + void testDefaultQueryLimitLessThanOne() { + QueryLimitConfiguration config = new QueryLimitConfiguration(); + config.setDefaultUserQueryLimit(100); + config.setDefaultSystemQueryLimit(5000); + config.setInternalCacheMaxSize(0); + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validate(config)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Internal cache max size must be greater than 0"); + } + } + + /** + * Tests for {@link QueryLimitConfigurationValidationUtils#validateQueryLogicGroupConfigs(Collection)}. + */ + @Nested + class QueryLogicGroupLimitConfigurationValidationTests { + private final List configs = new ArrayList<>(); + + @BeforeEach + void setUp() { + configs.clear(); + } + + /** + * Verify that configurations with blank group names are forbidden. + */ + @Test + void testConfigWithBlankGroupName() { + givenConfig(" ", "TLDQueryLogic", 50); + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateQueryLogicGroupConfigs(configs)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Query logic group limit configuration given with blank group name"); + } + + /** + * Verify that multiple configurations with the same group name are forbidden. + */ + @Test + void testMultipleConfigsWithSameGroupName() { + givenConfig("TLD", "TLDQueryLogic", 50); + givenConfig("TLD", "TLD*", 25); + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateQueryLogicGroupConfigs(configs)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Multiple query logic group configurations given with group name 'TLD'"); + } + + /** + * Verify that configurations with negative limits are forbidden. + */ + @Test + void testConfigWithNegativeLimit() { + givenConfig("TLD", "TLDQueryLogic", -1); + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateQueryLogicGroupConfigs(configs)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Negative limit given for query logic group 'TLD'"); + } + + /** + * Verify that configurations with blank query logic patterns are forbidden. + */ + @Test + void testConfigWithBlankQueryLogicPattern() { + givenConfig("TLD", " ", 50); + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateQueryLogicGroupConfigs(configs)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Blank query logic pattern given for query logic group 'TLD'"); + } + + /** + * Verify that query logic patterns that cannot be compiled are forbidden. + */ + @Test + void testConfigWithUncompilableQueryLogicPattern() { + givenConfig("TLD", "TLD[", 50); + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateQueryLogicGroupConfigs(configs)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Invalid regex in query logic pattern 'TLD[' for query logic group 'TLD'"); + } + + private void givenConfig(String groupName, String queryLogicPattern, int queryLimit) { + QueryLogicGroupLimitConfiguration config = new QueryLogicGroupLimitConfiguration(groupName, queryLogicPattern, queryLimit); + configs.add(config); + } + } + + /** + * Tests for {@link QueryLimitConfigurationValidationUtils#validateUserLimitConfigs(Collection)}. + */ + @Nested + class UserLimitConfigurationValidationTests { + private final List configs = new ArrayList<>(); + + @BeforeEach + void setUp() { + configs.clear(); + } + + /** + * Verify that blank user DNs are forbidden. + */ + @Test + void testConfigWithBlankDn() { + givenUserConfig(" ", null); + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateUserLimitConfigs(configs)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("User query limit configuration given with blank user DN"); + } + + /** + * Verify that multiple configurations with the same user DN are forbidden. + */ + @Test + void testMultipleConfigsWithSameUserDn() { + givenUserConfig("cn=test user, c=us", 100); + givenUserConfig("cn=test user, c=us", 200); + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateUserLimitConfigs(configs)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Multiple query limit configurations specified for user 'cn=test user, c=us'"); + } + + /** + * Verify that negative query limits are forbidden. + */ + @Test + void testConfigWithNegativeLimit() { + givenUserConfig("cn=test user, c=us", -1); + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateUserLimitConfigs(configs)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Negative user query limit given for user 'cn=test user, c=us'"); + } + + private void givenUserConfig(String userDn, Integer queryLimit) { + this.configs.add(new UserLimitConfiguration(userDn, queryLimit, null)); + } + } + + /** + * Tests for {@link QueryLimitConfigurationValidationUtils#validateSystemLimitConfigs(Collection, long)}. + */ + @Nested + class SystemLimitConfigurationValidationTests { + + private final List configs = new ArrayList<>(); + + @BeforeEach + void setUp() { + configs.clear(); + } + + /** + * Verify that configurations with blank system patterns are forbidden. + */ + @Test + void testConfigWithBlankSystemPattern() { + givenSystemConfig(" ", 10, true, null); + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateSystemLimitConfigs(configs, 200)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("System query limit configuration specified with blank system pattern"); + } + + /** + * Verify that configurations with regex system patterns that cannot be compiled are forbidden. + */ + @Test + void testConfigWithUncompilableSystemPattern() { + givenSystemConfig("SYS[", 10, true, null); + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateSystemLimitConfigs(configs, 200)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Invalid regex in system pattern 'SYS['"); + } + + /** + * Verify that multiple configurations using the same system patterns are forbidden. + */ + @Test + void testMultipleConfigsWithSameSystemPattern() { + givenSystemConfig("SYSTEM_01*", 10, true, null); + givenSystemConfig("SYSTEM_01*", 10, true, null); + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateSystemLimitConfigs(configs, 200)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Multiple query limit configurations specified with system pattern 'SYSTEM_01*'"); + } + + /** + * Verify that configurations with conflicting system patterns that are equivalent exact matches are forbidden. + */ + @Test + void testEquivalentExactMatchPatterns() { + givenSystemConfig("SYSTEM_01", 10, true, null); // Literals only. + givenSystemConfig("SYSTEM\\_01", 10, true, null); // Literals and escaped literals. + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateSystemLimitConfigs(configs, 200)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("System pattern 'SYSTEM\\_01' will resolve to an exact match that is equivalent to system pattern 'SYSTEM_01' from " + + "another system configuration."); + } + + /** + * Verify that configurations with an implied wildcard system pattern {@code *} that are configured to not apply to user limits are forbidden. + */ + @Test + void testImpliedWildcardSystemPatternThatDoesNotApplyToUserLimit() { + givenSystemConfig("*", 10, false, null); + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateSystemLimitConfigs(configs, 200)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("System pattern '*' is wildcard-only and may not be used to override whether queries count against user limits to false"); + } + + /** + * Verify that configurations with an explicit wildcard system pattern that are configured to not apply to user limits are forbidden. + */ + @Test + void testExplicitWildcardSystemPatternThatDoesNotApplyToUserLimit() { + givenSystemConfig(".*", 10, false, null); + + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateSystemLimitConfigs(configs, 200)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("System pattern '.*' is wildcard-only and may not be used to override whether queries count against user limits to false"); + } + + private void givenSystemConfig(String systemPattern, Integer queryLimit, Boolean countsAgainstUserLimit, Map queryLogicGroupLimits) { + SystemLimitConfiguration config = new SystemLimitConfiguration(systemPattern, countsAgainstUserLimit, queryLimit, queryLogicGroupLimits); + configs.add(config); + } + + } +} diff --git a/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimiterConcurrencyTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimiterConcurrencyTest.java index b91c65a1f95..434b7ebb904 100644 --- a/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimiterConcurrencyTest.java +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimiterConcurrencyTest.java @@ -33,6 +33,9 @@ import com.google.common.collect.ArrayListMultimap; import com.google.common.util.concurrent.ThreadFactoryBuilder; +/** + * Contains tests that effectively stress test the {@link QueryLimiter} when multiple threads are recording new active queries. + */ class QueryLimiterConcurrencyTest { private static final Logger log = Logger.getLogger(QueryLimiterConcurrencyTest.class); @@ -57,7 +60,7 @@ class QueryLimiterConcurrencyTest { private static final long SIMULTANEOUS_QUERY_CREATION_ALLOWANCE = 250L; // Set this to true to print the attempt results for debugging purposes. - private static final boolean printAttempts = true; + private static final boolean printAttempts = false; private static ExecutorService executor; diff --git a/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimiterSpringTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimiterSpringTest.java index 6a40d753966..d77bb1d62c2 100644 --- a/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimiterSpringTest.java +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimiterSpringTest.java @@ -50,4 +50,5 @@ void testCreation() { expectedGroupConfigs.add(new QueryLogicGroupLimitConfiguration("GROUP_2", "LOGIC2.*", 25)); assertThat(config.getQueryLogicGroupConfigs()).isEqualTo(expectedGroupConfigs); } + } diff --git a/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimiterTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimiterTest.java index df679268f27..8a34b80bc47 100644 --- a/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimiterTest.java +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimiterTest.java @@ -2,24 +2,41 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.apache.curator.framework.CuratorFramework; +import org.apache.curator.framework.CuratorFrameworkFactory; +import org.apache.curator.retry.RetryNTimes; import org.apache.curator.test.TestingServer; +import org.apache.zookeeper.server.quorum.QuorumPeerConfig; +import org.awaitility.Awaitility; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import datawave.webservice.zookeeper.ZkObjectPublisher; + /** * Test cases for testing the functionality of {@link QueryLimiter}. */ class QueryLimiterTest { + private static final String PUBLISHER_NAMESPACE = "QueryLimitConfig"; + private static final String userA = "cn=testuserA, c=us"; private static final String userB = "cn=testuserB, c=us"; private static final String system1 = "SYSTEM-01"; @@ -27,11 +44,39 @@ class QueryLimiterTest { private static final String tldQueryLogic = "TLDQueryLogic"; private static final String eventQueryLogic = "EventQueryLogic"; + private static String validJsonFile; + private final Map systemToLimiter = new HashMap<>(); private QueryHeartbeatCache heartbeatCache; private QueryLimitConfiguration config; private TestingServer server; + @BeforeAll + static void beforeAll() throws Exception { + ClassLoader classLoader = QueryLimiterTest.class.getClassLoader(); + validJsonFile = getAbsolutePath(classLoader, "queryLimits/valid_config.json"); + } + + /** + * Return the absolute path for the given file as resolved by the classloader. + * + * @param classLoader + * the classloader + * @param relativePath + * the relative path + * @return the absolute path + * @throws URISyntaxException + * if the URL cannot be converted to a URI + */ + private static String getAbsolutePath(ClassLoader classLoader, String relativePath) throws URISyntaxException { + URL url = classLoader.getResource(relativePath); + if (url != null) { + return Paths.get(url.toURI()).toAbsolutePath().toString(); + } else { + throw new NullPointerException("Null URL returned for relative path '" + relativePath); + } + } + @BeforeEach void setUp() throws Exception { server = new TestingServer(); @@ -43,18 +88,17 @@ void tearDown() throws IOException { heartbeatCache.shutdown(); systemToLimiter.clear(); config = null; - if (server != null) { - server.close(); - } + server.close(); } /** - * Verify {@link QueryLimiter#setup()} throws an exception if given a default user query limit less than 1. + * Verify {@link QueryLimiter#setup()} throws an exception if the query validation fails when a configuration is initially supplied via injection. */ @Test - void testDefaultUserQueryLimitLessThanOne() { + void testConfigurationFailsValidation() { QueryLimiter limiter = new QueryLimiter(); limiter.setZookeeperConfig(server.getConnectString()); + limiter.setHeartbeatCache(heartbeatCache); QueryLimitConfiguration config = new QueryLimitConfiguration(); config.setDefaultUserQueryLimit(0); @@ -64,20 +108,24 @@ void testDefaultUserQueryLimitLessThanOne() { } /** - * Verify {@link QueryLimiter#setup()} throws an exception if given a default internal max cache size less than 1. + * Verify that {@link QueryLimiter#setup()} will load a configuration from zookeeper if one was not supplied via injection. */ @Test - void testDefaultQueryLimitLessThanOne() { + void testInitialConfigurationSuppliedByZookeeper() throws Exception { + ZkObjectPublisher publisher = new ZkObjectPublisher(PUBLISHER_NAMESPACE, server.getConnectString(), null, QueryLimitConfiguration.class, + List.of(new QueryLimitConfigurationValidator())); + QueryLimiter limiter = new QueryLimiter(); limiter.setZookeeperConfig(server.getConnectString()); + limiter.setConfigPublisher(publisher); + limiter.setHeartbeatCache(heartbeatCache); - QueryLimitConfiguration config = new QueryLimitConfiguration(); - config.setDefaultUserQueryLimit(100); - config.setDefaultSystemQueryLimit(5000); - config.setInternalCacheMaxSize(0); - limiter.setConfiguration(config); + try (CuratorFramework client = createReloaderClient()) { + client.create().forPath("/path", validJsonFile.getBytes()); + } - assertThatThrownBy(limiter::setup).isInstanceOf(IllegalArgumentException.class).hasMessage("Internal cache max size must be greater than 0"); + limiter.setup(); + assertNotNull(limiter.getConfiguration()); } /** @@ -461,14 +509,52 @@ void testCreatingQueryAfterStoppingQueryThatMetLimit() throws Exception { assertLimitNotMet(userA, system1, tldQueryLogic); } - private QueryLimiter getLimiter(String system) { + /** + * Verify that when a valid configuration is reloaded by the internal {@link ZkObjectPublisher}, the {@link QueryLimiter} is updated. + */ + @Test + void testConfigurationReload() throws Exception { + QueryLimitConfiguration config = new QueryLimitConfiguration(); + config.setDefaultSystemQueryLimit(100); + config.setDefaultUserQueryLimit(5); + givenConfig(config); + + QueryLimiter limiter = getLimiter(system1); + + // Sleep one second to allow for reloader set up. + Thread.sleep(TimeUnit.SECONDS.toMillis(1)); + + // Create the path node. This should trigger a configuration reload that is passed to the limiter. + try (CuratorFramework client = createReloaderClient()) { + client.create().forPath("/path", validJsonFile.getBytes()); + } + + // Wait until we see a configuration from the limiter that does not match the original config. + try { + Awaitility.await().atMost(5, TimeUnit.SECONDS).until(() -> limiter.getConfiguration().getDefaultSystemQueryLimit() != 100); + } catch (Exception e) { + fail("Timeout exceeded while waiting for limiter to be updated with new configuration."); + } + + // The configuration loaded by the JSON file has a default user query limit of 100, and default system limit of 1000. Verify we see these changes + // reflected in each limiter. + QueryLimitConfiguration updatedConfig = limiter.getConfiguration(); + assertEquals(100, updatedConfig.getDefaultUserQueryLimit()); + assertEquals(1000, updatedConfig.getDefaultSystemQueryLimit()); + } + + private QueryLimiter getLimiter(String system) throws QuorumPeerConfig.ConfigException { if (systemToLimiter.containsKey(system)) { return systemToLimiter.get(system); } else { + ZkObjectPublisher publisher = new ZkObjectPublisher(PUBLISHER_NAMESPACE, server.getConnectString(), null, QueryLimitConfiguration.class, + List.of(new QueryLimitConfigurationValidator())); + QueryLimiter limiter = new QueryLimiter(); limiter.setZookeeperConfig(server.getConnectString()); limiter.setConfiguration(config); limiter.setHeartbeatCache(heartbeatCache); + limiter.setConfigPublisher(publisher); limiter.setup(); systemToLimiter.put(system, limiter); return limiter; @@ -503,4 +589,18 @@ private void assertLimitMet(String userDn, String system, String queryLogic, Str private void givenConfig(QueryLimitConfiguration config) { this.config = config; } + + private CuratorFramework createReloaderClient() { + // @formatter:off + CuratorFramework client = CuratorFrameworkFactory.builder() + .namespace(PUBLISHER_NAMESPACE) + .connectString(server.getConnectString()) + .sessionTimeoutMs(60000) + .connectionTimeoutMs(60000) + .retryPolicy(new RetryNTimes(10, 1000)) + .build(); + // @formatter:on + client.start(); + return client; + } } diff --git a/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLogicGroupLimitProviderTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLogicGroupLimitProviderTest.java index bba318faf0a..8bf8dc87551 100644 --- a/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLogicGroupLimitProviderTest.java +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLogicGroupLimitProviderTest.java @@ -1,7 +1,6 @@ package datawave.webservice.query.limit; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.ArrayList; import java.util.List; @@ -22,61 +21,6 @@ void tearDown() { configs.clear(); } - /** - * Verify that configurations with blank group names are forbidden. - */ - @Test - void testConfigWithBlankGroupName() { - givenConfig(" ", "TLDQueryLogic", 50); - - assertThatThrownBy(this::initProvider).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Query logic group limit configuration given with blank group name"); - } - - /** - * Verify that multiple configurations with the same group name are forbidden. - */ - @Test - void testMultipleConfigsWithSameGroupName() { - givenConfig("TLD", "TLDQueryLogic", 50); - givenConfig("TLD", "TLD*", 25); - - assertThatThrownBy(this::initProvider).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Multiple query logic group configurations given with group name 'TLD'"); - } - - /** - * Verify that configurations with negative limits are forbidden. - */ - @Test - void testConfigWithNegativeLimit() { - givenConfig("TLD", "TLDQueryLogic", -1); - - assertThatThrownBy(this::initProvider).isInstanceOf(IllegalArgumentException.class).hasMessage("Negative limit given for query logic group 'TLD'"); - } - - /** - * Verify that configurations with blank query logic patterns are forbidden. - */ - @Test - void testConfigWithBlankQueryLogicPattern() { - givenConfig("TLD", " ", 50); - - assertThatThrownBy(this::initProvider).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Blank query logic pattern given for query logic group 'TLD'"); - } - - /** - * Verify that query logic patterns that cannot be compiled are forbidden. - */ - @Test - void testConfigWithUncompilableQueryLogicPattern() { - givenConfig("TLD", "TLD[", 50); - - assertThatThrownBy(this::initProvider).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Invalid regex in query logic pattern 'TLD[' for query logic group 'TLD'"); - } - /** * Verify that we support {@code *} as a wildcard pattern. */ diff --git a/web-services/query/src/test/java/datawave/webservice/query/limit/SystemLimitProviderTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/SystemLimitProviderTest.java index 3ca3932571a..d8fd862f2ab 100644 --- a/web-services/query/src/test/java/datawave/webservice/query/limit/SystemLimitProviderTest.java +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/SystemLimitProviderTest.java @@ -1,7 +1,6 @@ package datawave.webservice.query.limit; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.ArrayList; import java.util.List; @@ -27,74 +26,6 @@ void tearDown() { groupConfigs.clear(); } - /** - * Verify that configurations with blank system patterns are forbidden. - */ - @Test - void testConfigWithBlankSystemPattern() { - givenSystemConfig(" ", 10, true, null); - - assertThatThrownBy(this::initProvider).isInstanceOf(IllegalArgumentException.class) - .hasMessage("System query limit configuration specified with blank system pattern"); - } - - /** - * Verify that configurations with regex system patterns that cannot be compiled are forbidden. - */ - @Test - void testConfigWithUncompilableSystemPattern() { - givenSystemConfig("SYS[", 10, true, null); - - assertThatThrownBy(this::initProvider).isInstanceOf(IllegalArgumentException.class).hasMessage("Invalid regex in system pattern 'SYS['"); - } - - /** - * Verify that multiple configurations using the same system patterns are forbidden. - */ - @Test - void testMultipleConfigsWithSameSystemPattern() { - givenSystemConfig("SYSTEM_01*", 10, true, null); - givenSystemConfig("SYSTEM_01*", 10, true, null); - - assertThatThrownBy(this::initProvider).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Multiple query limit configurations specified with system pattern 'SYSTEM_01*'"); - } - - /** - * Verify that configurations with conflicting system patterns that are equivalent exact matches are forbidden. - */ - @Test - void testEquivalentExactMatchPatterns() { - givenSystemConfig("SYSTEM_01", 10, true, null); // Literals only. - givenSystemConfig("SYSTEM\\_01", 10, true, null); // Literals and escaped literals. - - assertThatThrownBy(this::initProvider).isInstanceOf(IllegalArgumentException.class) - .hasMessage("System pattern 'SYSTEM\\_01' will resolve to an exact match that is equivalent to system pattern 'SYSTEM_01' from " - + "another system configuration."); - } - - /** - * Verify that configurations with an implied wildcard system pattern {@code *} that are configured to not apply to user limits are forbidden. - */ - @Test - void testImpliedWildcardSystemPatternThatDoesNotApplyToUserLimit() { - givenSystemConfig("*", 10, false, null); - - assertThatThrownBy(this::initProvider).isInstanceOf(IllegalArgumentException.class) - .hasMessage("System pattern '*' is wildcard-only and may not be used to override whether queries count against user limits to false"); - } - - /** - * Verify that configurations with an explicit wildcard system pattern that are configured to not apply to user limits are forbidden. - */ - @Test - void testExplicitWildcardSystemPatternThatDoesNotApplyToUserLimit() { - givenSystemConfig(".*", 10, false, null); - - assertThatThrownBy(this::initProvider).isInstanceOf(IllegalArgumentException.class) - .hasMessage("System pattern '.*' is wildcard-only and may not be used to override whether queries count against user limits to false"); - } - /** * Verify that we are able to get the correct default system query limit. */ diff --git a/web-services/query/src/test/java/datawave/webservice/query/limit/UserLimitProviderTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/UserLimitProviderTest.java index a498bbf705e..6986cf4f234 100644 --- a/web-services/query/src/test/java/datawave/webservice/query/limit/UserLimitProviderTest.java +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/UserLimitProviderTest.java @@ -1,7 +1,6 @@ package datawave.webservice.query.limit; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.ArrayList; import java.util.List; @@ -25,40 +24,6 @@ void tearDown() { groupConfigs.clear(); } - /** - * Verify that blank user DNs are forbidden. - */ - @Test - void testConfigWithBlankDn() { - givenUserConfig(" ", null, null); - - assertThatThrownBy(this::initProvider).isInstanceOf(IllegalArgumentException.class) - .hasMessage("User query limit configuration given with blank user DN"); - } - - /** - * Verify that multiple configurations with the same user DN are forbidden. - */ - @Test - void testMultipleConfigsWithSameUserDn() { - givenUserConfig("cn=test user, c=us", 100, null); - givenUserConfig("cn=test user, c=us", 200, null); - - assertThatThrownBy(this::initProvider).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Multiple query limit configurations specified for user 'cn=test user, c=us'"); - } - - /** - * Verify that negative query limits are forbidden. - */ - @Test - void testConfigWithNegativeLimit() { - givenUserConfig("cn=test user, c=us", -1, null); - - assertThatThrownBy(this::initProvider).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Negative user query limit given for user 'cn=test user, c=us'"); - } - /** * Verify that when a user config is provided that does not override any configurable limits, the user config is not retained. */ diff --git a/web-services/query/src/test/java/datawave/webservice/zookeeper/LockedZkClientDispatcherTest.java b/web-services/query/src/test/java/datawave/webservice/zookeeper/LockedZkClientDispatcherTest.java new file mode 100644 index 00000000000..fb4e0d25153 --- /dev/null +++ b/web-services/query/src/test/java/datawave/webservice/zookeeper/LockedZkClientDispatcherTest.java @@ -0,0 +1,241 @@ +package datawave.webservice.zookeeper; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.curator.framework.CuratorFramework; +import org.apache.curator.framework.CuratorFrameworkFactory; +import org.apache.curator.retry.RetryNTimes; +import org.apache.curator.test.TestingServer; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link LockedZkClientDispatcher}. + */ +class LockedZkClientDispatcherTest { + + private TestingServer server; + + @BeforeEach + void setUp() throws Exception { + server = new TestingServer(); + } + + @AfterEach + void tearDown() throws IOException { + if (server != null) { + server.close(); + } + } + + /** + * Verify that when a {@link LockedZkClientDispatcher} created with an infinite maxElapsedAccessTime (0 or negative), the executor service is not created + * when {@link LockedZkClientDispatcher#getLockedClient()} is called. + */ + @Test + void testDispatcherWithInfiniteMaxElapsedAccessTime() throws Exception { + ExposedDispatcher dispatcher = new ExposedDispatcher(createClientFactory(), -1, TimeUnit.MILLISECONDS); + // Verify the inner client and executor service are null before the first call to getLockedClient(); + assertNull(dispatcher.getClient()); + assertNull(dispatcher.getExecutor()); + + LockedZkClientDispatcher.LockedClient lockedClient = dispatcher.getLockedClient(); + + // Verify the lastClientAccess time was updated. + assertNotEquals(0L, dispatcher.getLastClientAccess()); + + // Verify the cleanup executor service is not created. + assertNull(dispatcher.getExecutor()); + + // Verify the client was initialized and accessible via the lockedClient. + assertNotNull(dispatcher.getClient()); + assertSame(dispatcher.getClient(), lockedClient.getClient()); + + // Verify the client lock is locked. + assertTrue(dispatcher.getClientLock().isLocked()); + + lockedClient.close(); + + // Verify the client lock is unlocked after closing the locked client. + assertFalse(dispatcher.getClientLock().isLocked()); + + // Close the client. + dispatcher.close(); + + // Verify the client and executor are null. + assertNull(dispatcher.getClient()); + assertNull(dispatcher.getExecutor()); + } + + /** + * Verify that when a {@link LockedZkClientDispatcher} created with an finite maxElapsedAccessTime (0 or negative), the executor service is created when + * {@link LockedZkClientDispatcher#getLockedClient()} is called. + */ + @Test + void testDispatcherWithFiniteMaxElapsedAccessTime() throws Exception { + ExposedDispatcher dispatcher = new ExposedDispatcher(createClientFactory(), 120000, TimeUnit.MILLISECONDS); + // Verify the inner client and executor service are null before the first call to getLockedClient(); + assertNull(dispatcher.getClient()); + assertNull(dispatcher.getExecutor()); + + LockedZkClientDispatcher.LockedClient lockedClient = dispatcher.getLockedClient(); + + // Verify the lastClientAccess time was updated. + assertNotEquals(0L, dispatcher.getLastClientAccess()); + + // Verify the cleanup executor service was created, and a task was added to it to handle cleanup. + ScheduledThreadPoolExecutor executor = dispatcher.getExecutor(); + assertNotNull(executor); + assertFalse(executor.getQueue().isEmpty()); + + // Verify the client was initialized and accessible via the lockedClient. + assertNotNull(dispatcher.getClient()); + assertSame(dispatcher.getClient(), lockedClient.getClient()); + + // Verify the client lock is locked. + assertTrue(dispatcher.getClientLock().isLocked()); + + lockedClient.close(); + + // Verify the client lock is unlocked after closing the locked client. + assertFalse(dispatcher.getClientLock().isLocked()); + + // Close the client. + dispatcher.close(); + + // Verify the client and executor are null. + assertNull(dispatcher.getClient()); + assertNull(dispatcher.getExecutor()); + } + + /** + * Verify that as long as the max elapsed access time is not met, the executor service will reschedule its cleanup task. + */ + @Test + void testCleanupTaskReschedulesSelfIfNotTimedOut() throws Exception { + ExposedDispatcher dispatcher = new ExposedDispatcher(createClientFactory(), 500, TimeUnit.MILLISECONDS); + + // The task should get rescheduled at least 4 times. + for (int i = 0; i < 10; i++) { + try (LockedZkClientDispatcher.LockedClient ignored = dispatcher.getLockedClient()) { + Thread.sleep(250); + } + } + + // Get the total completed + long taskCompletedCount = dispatcher.getExecutor().getCompletedTaskCount(); + + // Verify the cleanup task was executed between 4-6 times. + assertTrue(taskCompletedCount >= 4 && taskCompletedCount < 6); + dispatcher.close(); + } + + /** + * Verify that when the max elapsed time is met, the client is cleaned up by the executor service. + */ + @Test + void testClientIsCleanedUpWhenMaxElapsedAccessTimeIsReached() throws Exception { + ExposedDispatcher dispatcher = new ExposedDispatcher(createClientFactory(), 500, TimeUnit.MILLISECONDS); + // Verify the client and executor service are initialized after the first call to get the client. + try (LockedZkClientDispatcher.LockedClient ignored = dispatcher.getLockedClient()) { + assertNotNull(dispatcher.getClient()); + assertNotNull(dispatcher.getExecutor()); + } + + try { + // Attempt to wait until the client is null. This may take a little bit due to the call to CuratorFramework.close(). + Awaitility.await().atMost(5, TimeUnit.SECONDS).until(() -> dispatcher.getClient() == null); + } catch (Exception e) { + fail("Expected client to be cleaned up within 5 seconds"); + } + + // Verify the executor is not null and does not have any tasks in the queue. + ScheduledThreadPoolExecutor executor = dispatcher.getExecutor(); + assertNotNull(executor); + assertTrue(executor.getQueue().isEmpty()); + + dispatcher.close(); + } + + /** + * Verify there are no issues creating a new client after an old one has been cleaned up. + */ + @Test + void testGetClientAfterCleanUp() throws Exception { + ExposedDispatcher dispatcher = new ExposedDispatcher(createClientFactory(), 500, TimeUnit.MILLISECONDS); + // Verify the client and executor service are initialized after the first call to get the client. + try (LockedZkClientDispatcher.LockedClient ignored = dispatcher.getLockedClient()) { + assertNotNull(dispatcher.getClient()); + assertNotNull(dispatcher.getExecutor()); + } + + try { + // Attempt to wait until the client is null. This may take a little bit due to the call to CuratorFramework.close(). + Awaitility.await().atMost(5, TimeUnit.SECONDS).until(() -> dispatcher.getClient() == null); + } catch (Exception e) { + fail("Expected client to be cleaned up within 5 seconds"); + } + + // Verify the executor is not null and does not have any tasks in the queue. + ScheduledThreadPoolExecutor executor = dispatcher.getExecutor(); + assertNotNull(executor); + assertTrue(executor.getQueue().isEmpty()); + + // Get a fresh client. + try (LockedZkClientDispatcher.LockedClient ignored = dispatcher.getLockedClient()) { + // Verify the client is not null. + assertNotNull(dispatcher.getClient()); + + // Verify the executor service is not null, and has a task in the queue to watch for the next elapsed time. + executor = dispatcher.getExecutor(); + assertNotNull(executor); + assertFalse(executor.getQueue().isEmpty()); + } + + dispatcher.close(); + } + + private CuratorFrameworkFactory.Builder createClientFactory() { + return CuratorFrameworkFactory.builder().connectString(server.getConnectString()).sessionTimeoutMs(60000).connectionTimeoutMs(60000) + .retryPolicy(new RetryNTimes(10, 1000)); + } + + /** + * An implementation of {@link LockedZkClientDispatcher} that exposes its inner members for testing. + */ + public static class ExposedDispatcher extends LockedZkClientDispatcher { + + public ExposedDispatcher(CuratorFrameworkFactory.Builder clientFactory, long maxElapsedAccessTime, TimeUnit timeUnit) { + super(clientFactory, maxElapsedAccessTime, maxElapsedAccessTime, timeUnit); + } + + public CuratorFramework getClient() { + return client; + } + + public ReentrantLock getClientLock() { + return clientLock; + } + + public ScheduledThreadPoolExecutor getExecutor() { + return executor; + } + + public long getLastClientAccess() { + return lastClientAccess; + } + } +} diff --git a/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherSpringTest.java b/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherSpringTest.java new file mode 100644 index 00000000000..27098dd6494 --- /dev/null +++ b/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherSpringTest.java @@ -0,0 +1,39 @@ +package datawave.webservice.zookeeper; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.IOException; + +import org.apache.curator.test.TestingServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(locations = "classpath:TestZkObjectPublisherFactory.xml") +class ZkObjectPublisherSpringTest { + + private TestingServer server; + + @Autowired + private ZkObjectPublisher publisher; + + @BeforeEach + void setUp() throws Exception { + server = new TestingServer(); + } + + @Test + void testCreation() { + assertNotNull(publisher); + } + + @AfterEach + void tearDown() throws IOException { + server.stop(); + } +} diff --git a/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherTest.java b/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherTest.java new file mode 100644 index 00000000000..754b1121d48 --- /dev/null +++ b/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherTest.java @@ -0,0 +1,863 @@ +package datawave.webservice.zookeeper; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.apache.curator.framework.CuratorFramework; +import org.apache.curator.framework.CuratorFrameworkFactory; +import org.apache.curator.retry.RetryNTimes; +import org.apache.curator.test.TestingServer; +import org.apache.zookeeper.data.Stat; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import datawave.webservice.query.limit.QueryLimitConfiguration; +import datawave.webservice.query.limit.QueryLimitConfigurationValidator; + +public class ZkObjectPublisherTest { + + private static final String NAMESPACE = "QueryLimitConfig"; + private static final Logger log = LoggerFactory.getLogger(ZkObjectPublisherTest.class); + + private static String validJsonFile; + private static String validXmlFile; + private static String validYamlFile; + private static String invalidConfigFile; + private static String nonConfigFile; + private static String unsupportedFormatFile; + + private ZkObjectPublisher publisher; + private final List configs = new ArrayList<>(); + private TestingServer server; + private CuratorFramework client; + + private static String causeNode; + private static String statusNode; + private static String errorsNode; + private static String timeNode; + + private Instant testStartTime; + + @BeforeAll + static void beforeAll() throws Exception { + String serverIpAddress = InetAddress.getLocalHost().getHostAddress(); + causeNode = "/attempts/" + serverIpAddress + "/cause"; + statusNode = "/attempts/" + serverIpAddress + "/status"; + errorsNode = "/attempts/" + serverIpAddress + "/errors"; + timeNode = "/attempts/" + serverIpAddress + "/time"; + + ClassLoader classLoader = ZkObjectPublisherTest.class.getClassLoader(); + validJsonFile = getAbsolutePath(classLoader, "queryLimits/valid_config.json"); + validXmlFile = getAbsolutePath(classLoader, "queryLimits/valid_config.xml"); + validYamlFile = getAbsolutePath(classLoader, "queryLimits/valid_config.yaml"); + invalidConfigFile = getAbsolutePath(classLoader, "queryLimits/invalid_config.yaml"); + nonConfigFile = getAbsolutePath(classLoader, "queryLimits/non_config.yaml"); + unsupportedFormatFile = getAbsolutePath(classLoader, "queryLimits/unsupported_format.toml"); + } + + /** + * Return the absolute path for the given file as resolved by the classloader. + * + * @param classLoader + * the classloader + * @param relativePath + * the relative path + * @return the absolute path + * @throws URISyntaxException + * if the URL cannot be converted to a URI + */ + private static String getAbsolutePath(ClassLoader classLoader, String relativePath) throws URISyntaxException { + URL url = classLoader.getResource(relativePath); + if (url != null) { + return Paths.get(url.toURI()).toAbsolutePath().toString(); + } else { + throw new NullPointerException("Null URL returned for relative path '" + relativePath); + } + } + + @BeforeEach + void setUp() throws Exception { + testStartTime = Instant.now(); + configs.clear(); + publisher = null; + server = new TestingServer(); + server.start(); + // @formatter:off + client = CuratorFrameworkFactory.builder().namespace(NAMESPACE) + .connectString(server.getConnectString()) + .sessionTimeoutMs(300000) + .connectionTimeoutMs(60000) + .retryPolicy(new RetryNTimes(10, 1000)) + .build(); + // @formatter:on + client.start(); + } + + @AfterEach + void tearDown() throws IOException { + if (publisher != null) { + publisher.close(); + } + if (client != null) { + client.close(); + } + if (server != null) { + server.close(); + } + } + + /** + * Verify that invalid constructor args will result in exceptions. + */ + @Test + void testInvalidConstructorArgs() { + assertThatThrownBy(() -> new ZkObjectPublisher(null, null, null, QueryLimitConfiguration.class, null)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("namespace must not be null or blank"); + assertThatThrownBy(() -> new ZkObjectPublisher(" ", null, null, QueryLimitConfiguration.class, null)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("namespace must not be null or blank"); + assertThatThrownBy(() -> new ZkObjectPublisher("QueryLimitConfig", null, null, QueryLimitConfiguration.class, null)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("zookeeperConfig must not be null or blank"); + assertThatThrownBy(() -> new ZkObjectPublisher("QueryLimitConfig", " ", null, QueryLimitConfiguration.class, null)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("zookeeperConfig must not be null or blank"); + assertThatThrownBy(() -> new ZkObjectPublisher("QueryLimitConfig", server.getConnectString(), null, null, null)) + .isInstanceOf(NullPointerException.class).hasMessage("objectClass must not be null"); + } + + /** + * Verify that the {@link ZkObjectPublisher} can read a {@link QueryLimitConfiguration} from a JSON file on the local file system. + */ + @Test + void testReloadValidJsonFromLocalFilesystem() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", validJsonFile); + createOrUpdateNode("/trigger", "changeme"); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Trigger a reload. + createOrUpdateNode("/trigger", ""); + + // Wait for the configuration to be loaded. + waitForConfigurationToBeSupplied(); + + // Verify that a single configuration was supplied to the listener. + assertEquals(1, configs.size()); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.SUCCESS); + assertErrorsNodeDoesNotExist(); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that the {@link ZkObjectPublisher} can read a {@link QueryLimitConfiguration} from an XML file on the local file system. + */ + @Test + void testReloadValidXmlFromLocalFilesystem() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", validXmlFile); + createOrUpdateNode("/trigger", "changeme"); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Trigger a reload. + createOrUpdateNode("/trigger", ""); + + // Wait for the configuration to be loaded. + waitForConfigurationToBeSupplied(); + + // Verify that a single configuration was supplied to the listener. + assertEquals(1, configs.size()); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.SUCCESS); + assertErrorsNodeDoesNotExist(); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that the {@link ZkObjectPublisher} can read a {@link QueryLimitConfiguration} from a YAML file on the local file system. + */ + @Test + void testReloadValidYamlFromLocalFilesystem() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", validYamlFile); + createOrUpdateNode("/trigger", "changeme"); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Trigger a reload. + createOrUpdateNode("/trigger", ""); + + // Wait for the configuration to be loaded. + waitForConfigurationToBeSupplied(); + + // Verify that a single configuration was supplied to the listener. + assertEquals(1, configs.size()); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.SUCCESS); + assertErrorsNodeDoesNotExist(); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that if the file path is prefixed with {@code file://}, {@link ZkObjectPublisher} will attempt to read the file from the local file system. + */ + @Test + void testFilePrefixWillReloadFromLocalFilesystem() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", "file://" + validJsonFile); + createOrUpdateNode("/trigger", "changeme"); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Trigger a reload. + createOrUpdateNode("/trigger", ""); + + // Wait for the configuration to be loaded. + waitForConfigurationToBeSupplied(); + + // Verify that a single configuration was supplied to the listener. + assertEquals(1, configs.size()); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.SUCCESS); + assertErrorsNodeDoesNotExist(); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that if the node {@code /trigger} is created, {@link ZkObjectPublisher} will reload the configuration. + */ + @Test + void testReloadTriggeredByTriggerNodeCreation() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", validJsonFile); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Trigger a reload. + createOrUpdateNode("/trigger", ""); + + // Wait for the configuration to be loaded. + waitForConfigurationToBeSupplied(); + + // Verify that a single configuration was supplied to the listener. + assertEquals(1, configs.size()); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.TRIGGER_NODE_CREATED); + assertStatus(ZkObjectPublishStatus.SUCCESS); + assertErrorsNodeDoesNotExist(); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that if the node {@code /trigger} is deleted, {@link ZkObjectPublisher} will reload the configuration. + */ + @Test + void testReloadTriggeredByTriggerNodeDeleted() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", validJsonFile); + createOrUpdateNode("/trigger", "changeme"); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Trigger a reload. + client.delete().forPath("/trigger"); + + // Wait for the configuration to be loaded. + waitForConfigurationToBeSupplied(); + + // Verify that a single configuration was supplied to the listener. + assertEquals(1, configs.size()); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.TRIGGER_NODE_DELETED); + assertStatus(ZkObjectPublishStatus.SUCCESS); + assertErrorsNodeDoesNotExist(); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that if the node {@code /path} is created with empty data, {@link ZkObjectPublisher} will not reload a configuration. + */ + @Test + void testReloadNotTriggeredByPathNodeCreationWithEmptyData() throws Exception { + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Create the /path node. + createOrUpdateNode("/path", ""); + + // Verify a configuration is never supplied to the listener. + assertThrows(Exception.class, () -> Awaitility.await().atMost(1, TimeUnit.SECONDS).until(() -> !configs.isEmpty())); + + // Verify the status nodes are never created. + assertNull(client.checkExists().forPath(causeNode)); + assertNull(client.checkExists().forPath(statusNode)); + assertNull(client.checkExists().forPath(errorsNode)); + assertNull(client.checkExists().forPath(timeNode)); + } + + /** + * Verify that if the node {@code /path} is modified with empty data, {@link ZkObjectPublisher} will not reload a configuration. + */ + @Test + void testReloadNotTriggeredByPathNodeModificationWithEmptyData() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", validJsonFile); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Modify the /path node. + createOrUpdateNode("/path", ""); + + // Verify a configuration is never supplied to the listener. + assertThrows(Exception.class, () -> Awaitility.await().atMost(1, TimeUnit.SECONDS).until(() -> !configs.isEmpty())); + + // Verify the status nodes are never created. + assertNull(client.checkExists().forPath(causeNode)); + assertNull(client.checkExists().forPath(statusNode)); + assertNull(client.checkExists().forPath(errorsNode)); + assertNull(client.checkExists().forPath(timeNode)); + } + + /** + * Verify that if the node {@code /path} is created with non-empty data, {@link ZkObjectPublisher} will reload a configuration. + */ + @Test + void testReloadTriggeredByPathNodeCreationWithNonEmptyData() throws Exception { + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Create the /path node. + createOrUpdateNode("/path", validJsonFile); + + // Wait for the configuration to be loaded. + waitForConfigurationToBeSupplied(); + + // Verify that a single configuration was supplied to the listener. + assertEquals(1, configs.size()); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.PATH_NODE_CREATED); + assertStatus(ZkObjectPublishStatus.SUCCESS); + assertErrorsNodeDoesNotExist(); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that if the node {@code /path} is modified with non-empty data, {@link ZkObjectPublisher} will reload a configuration. + */ + @Test + void testReloadTriggeredByPathModificationWithNonEmptyData() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", validJsonFile); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Modify the /path node. + createOrUpdateNode("/path", validXmlFile); + + // Wait for the configuration to be loaded. + waitForConfigurationToBeSupplied(); + + // Verify that a single configuration was supplied to the listener. + assertEquals(1, configs.size()); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.PATH_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.SUCCESS); + assertErrorsNodeDoesNotExist(); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify we do not load a configuration when the node {@code /path} does not exist. + */ + @Test + void testNonExistentPathNode() throws Exception { + createOrUpdateNode("/trigger", "changeme"); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Trigger a reload. + createOrUpdateNode("/trigger", ""); + + // Verify a configuration is never supplied to the listener. + waitToCheckConfigurationIsNotSupplied(); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify that a configuration was not supplied to the listener. + assertTrue(configs.isEmpty()); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.RELOAD_ERROR); + assertTotalErrors(1); + assertErrorMessage(0, "Node does not exist: QueryLimitConfig/path"); + assertErrorDoesNotHaveStackTrace(0); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify we do not load a configuration when the node {@code /path} has an empty filepath. + */ + @Test + void testPathNodeWithEmptyFilepath() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", null); + createOrUpdateNode("/trigger", "changeme"); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Trigger a reload. + createOrUpdateNode("/trigger", ""); + + // Verify a configuration is never supplied to the listener. + waitToCheckConfigurationIsNotSupplied(); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify that a configuration was not supplied to the listener. + assertTrue(configs.isEmpty()); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.RELOAD_ERROR); + assertTotalErrors(1); + assertErrorMessage(0, "Blank filepath set in data for node QueryLimitConfig/path"); + assertErrorDoesNotHaveStackTrace(0); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify we do not load a configuration when the node {@code /path} has a blank filepath. + */ + @Test + void testPathNodeWithBlankFilepath() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", " "); + createOrUpdateNode("/trigger", "changeme"); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Trigger a reload. + createOrUpdateNode("/trigger", ""); + + // Verify a configuration is never supplied to the listener. + waitToCheckConfigurationIsNotSupplied(); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify that a configuration was not supplied to the listener. + assertTrue(configs.isEmpty()); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.RELOAD_ERROR); + assertTotalErrors(1); + assertErrorMessage(0, "Blank filepath set in data for node QueryLimitConfig/path"); + assertErrorDoesNotHaveStackTrace(0); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify we do not load a configuration when the node {@code /path} has a filepath with an unsupported URI scheme. + */ + @Test + void testFileWithInvalidURIScheme() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", "ftp://i/do/not/exist"); + createOrUpdateNode("/trigger", "changeme"); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Trigger a reload. + createOrUpdateNode("/trigger", ""); + + // Verify a configuration is never supplied to the listener. + waitToCheckConfigurationIsNotSupplied(); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify that a configuration was not supplied to the listener. + assertTrue(configs.isEmpty()); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.RELOAD_ERROR); + assertTotalErrors(1); + assertErrorMessage(0, "Failed to read contents from file ftp://i/do/not/exist: Unsupported URI scheme: ftp"); + assertErrorStackTraceBeginsWith(0, "java.io.IOException: Unsupported URI scheme: ftp"); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify we do not load a configuration when the node {@code /path} has a filepath for a file that does not exist. + */ + @Test + void testNonExistentFile() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", "i/do/not/exist"); + createOrUpdateNode("/trigger", "changeme"); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Trigger a reload. + createOrUpdateNode("/trigger", ""); + + // Verify a configuration is never supplied to the listener. + waitToCheckConfigurationIsNotSupplied(); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify that a configuration was not supplied to the listener. + assertTrue(configs.isEmpty()); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.RELOAD_ERROR); + assertTotalErrors(1); + assertErrorMessage(0, "File not found: i/do/not/exist"); + assertErrorStackTraceBeginsWith(0, "java.nio.file.NoSuchFileException: i/do/not/exist"); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify we do not load a configuration when the node {@code /path} points to a file with unsupported syntax. + */ + @Test + void testUnsupportedSyntax() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", unsupportedFormatFile); + createOrUpdateNode("/trigger", "changeme"); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Trigger a reload. + createOrUpdateNode("/trigger", ""); + + // Verify a configuration is never supplied to the listener. + waitToCheckConfigurationIsNotSupplied(); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify that a configuration was not supplied to the listener. + assertTrue(configs.isEmpty()); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.RELOAD_ERROR); + assertTotalErrors(1); + assertErrorMessage(0, "File " + unsupportedFormatFile + " must be XML, JSON, or YAML"); + assertErrorDoesNotHaveStackTrace(0); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify we do not load a configuration when the node {@code /path} points to a file that cannot be deserialized as a {@link QueryLimitConfiguration}. + */ + @Test + void testNonQueryLimitConfigurationFile() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", nonConfigFile); + createOrUpdateNode("/trigger", "changeme"); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Trigger a reload. + createOrUpdateNode("/trigger", ""); + + // Verify a configuration is never supplied to the listener. + waitToCheckConfigurationIsNotSupplied(); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify that a configuration was not supplied to the listener. + assertTrue(configs.isEmpty()); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.RELOAD_ERROR); + assertTotalErrors(1); + assertErrorMessage(0, "Failed to deserialize file to a datawave.webservice.query.limit.QueryLimitConfiguration"); + assertErrorStackTraceBeginsWith(0, "com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field \"property1\""); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify we do not load a configuration when the node {@code /path} points to a file that deserializes to a {@link QueryLimitConfiguration} that fails + * validation. + */ + @Test + void testInvalidQueryLimitConfigurationFile() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", invalidConfigFile); + createOrUpdateNode("/trigger", "changeme"); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Trigger a reload. + createOrUpdateNode("/trigger", ""); + + // Verify a configuration is never supplied to the listener. + waitToCheckConfigurationIsNotSupplied(); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify that a configuration was not supplied to the listener. + assertTrue(configs.isEmpty()); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.RELOAD_ERROR); + assertTotalErrors(1); + assertErrorMessage(0, + "Reloaded datawave.webservice.query.limit.QueryLimitConfiguration failed validation: Default user query limit must be greater than 0"); + assertErrorStackTraceBeginsWith(0, "java.lang.IllegalArgumentException: Default user query limit must be greater than 0"); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that if exceptions are thrown by subscribers after supplying them with a new configuration, the errors are captured and recorded. + */ + @Test + void testExceptionsThrownBySubscribers() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", validJsonFile); + createOrUpdateNode("/trigger", "changeme"); + + // Create the publisher and start listening for trigger events. + createPublisher(); + + // Add listeners to the publisher that will throw a variety of exceptions. + publisher.subscribeToUpdates(configuration -> { + throw new NullPointerException("Something bad happened!"); + }); + publisher.subscribeToUpdates(configuration -> { + throw new IllegalArgumentException("I don't like this configuration."); + }); + publisher.subscribeToUpdates(configuration -> { + throw new UnsupportedOperationException("Why do I even exist?"); + }); + + // Trigger a reload. + createOrUpdateNode("/trigger", ""); + + // Wait for the configuration to be loaded. + waitForConfigurationToBeSupplied(); + + // Verify that a single configuration was supplied to the listener that does not throw an exception. + assertEquals(1, configs.size()); + + // Wait for the attempt time node to be created. This is the last attempt node that is created/updated after a reload and is our indicator that we can + // verify the attempt nodes. + waitForAttemptTimeNodeToBeCreated(); + + // Verify the attempt nodes were updated correctly. + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.SUBSCRIBER_ERROR); + assertTotalErrors(3); + assertErrorMessage(0, "Exception thrown by listener: Something bad happened!"); + assertErrorStackTraceBeginsWith(0, "java.lang.NullPointerException: Something bad happened!"); + assertErrorMessage(1, "Exception thrown by listener: I don't like this configuration."); + assertErrorStackTraceBeginsWith(1, "java.lang.IllegalArgumentException: I don't like this configuration."); + assertErrorMessage(2, "Exception thrown by listener: Why do I even exist?"); + assertErrorStackTraceBeginsWith(2, "java.lang.UnsupportedOperationException: Why do I even exist?"); + assertTimeNodeHasRecentTime(); + } + + /** + * Wait with a timeout until we + */ + private void waitForConfigurationToBeSupplied() { + try { + Awaitility.await().atMost(5, TimeUnit.SECONDS).until(() -> !configs.isEmpty()); + } catch (Exception e) { + fail("Timeout exceeded while waiting for config to be loaded: " + e.getMessage()); + } + } + + /** + * Wait a length of time and fail if we are supplied a configuration in that time period. If this check fails sometimes, increase the timeout. + */ + private void waitToCheckConfigurationIsNotSupplied() { + assertThrows(Exception.class, () -> Awaitility.await().atMost(2, TimeUnit.SECONDS).until(() -> !configs.isEmpty()), + "Expected configuration to never be supplied to listener"); + } + + /** + * Wait with a timeout until we know the node {@code attempts//time} exists. + */ + private void waitForAttemptTimeNodeToBeCreated() { + try { + Awaitility.await().atMost(4, TimeUnit.SECONDS).until(() -> client.checkExists().forPath(timeNode) != null); + } catch (Exception e) { + fail("Timeout exceeded while waiting for node " + timeNode + " to be created: " + e.getMessage()); + } + } + + private void assertCause(ZkObjectPublishCause trigger) throws Exception { + assertData(causeNode, trigger.toString()); + } + + private void assertStatus(ZkObjectPublishStatus status) throws Exception { + assertData(statusNode, status.toString()); + } + + private void assertErrorsNodeDoesNotExist() throws Exception { + Stat stat = client.checkExists().forPath(errorsNode); + assertNull(stat, "Expected node " + errorsNode + " to not exist"); + } + + private void assertTotalErrors(int expected) throws Exception { + Stat stat = client.checkExists().forPath(errorsNode); + assertEquals(expected, stat.getNumChildren(), "Expected node " + errorsNode + " to have " + expected + " children"); + } + + private void assertErrorMessage(int errorIndex, String message) throws Exception { + assertData(errorsNode + "/error_" + errorIndex + "/message", message); + } + + private void assertErrorStackTraceBeginsWith(int errorIndex, String prefix) throws Exception { + String path = errorsNode + "/error_" + errorIndex + "/stacktrace"; + Stat stat = client.checkExists().forPath(path); + assertNotNull(stat, "Expected node " + path + " to exist"); + String data = new String(client.getData().forPath(path)); + assertTrue(data.startsWith(prefix)); + } + + private void assertErrorDoesNotHaveStackTrace(int errorIndex) throws Exception { + String path = errorsNode + "/error_" + errorIndex + "/stacktrace"; + Stat stat = client.checkExists().forPath(path); + assertNull(stat, "Expected node " + path + " to not exist"); + } + + private void assertTimeNodeHasRecentTime() throws Exception { + Stat stat = client.checkExists().forPath(timeNode); + assertNotNull(stat, "Expected node " + timeNode + " to exist"); + String actualData = new String(client.getData().forPath(timeNode)); + Instant timeNodeInstant = Instant.parse(actualData); + assertTrue(timeNodeInstant.isAfter(testStartTime)); + } + + private void assertData(String path, String expectedData) throws Exception { + Stat stat = client.checkExists().forPath(path); + assertNotNull(stat, "Expected node " + path + " to exist"); + String actualData = new String(client.getData().forPath(path)); + assertEquals(expectedData, actualData, "Expected data for node " + path + " to be '" + expectedData + "'"); + } + + private void createPublisher() { + publisher = new ZkObjectPublisher(NAMESPACE, server.getConnectString(), null, QueryLimitConfiguration.class, + List.of(new QueryLimitConfigurationValidator())); + try { + publisher.awaitCacheInitialization(5, TimeUnit.SECONDS); + } catch (Exception e) { + throw new RuntimeException("Publisher caches failed to initialize before timeout", e); + } + publisher.subscribeToUpdates((pojo) -> configs.add((QueryLimitConfiguration) pojo)); + } + + private void createOrUpdateNode(String node, String dataStr) throws Exception { + Stat stat = client.checkExists().forPath(node); + byte[] data = dataStr == null ? new byte[0] : dataStr.getBytes(); + if (stat == null) { + client.create().forPath(node, data); + } else { + client.setData().forPath(node, data); + } + } +} diff --git a/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkUtilsTest.java b/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkUtilsTest.java new file mode 100644 index 00000000000..c17544dfb42 --- /dev/null +++ b/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkUtilsTest.java @@ -0,0 +1,140 @@ +package datawave.webservice.zookeeper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.util.Properties; + +import org.apache.zookeeper.server.quorum.QuorumPeer; +import org.apache.zookeeper.server.quorum.QuorumPeerConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junitpioneer.jupiter.SetSystemProperty; + +class ZkUtilsTest { + + @TempDir + File tempDir; + + /** + * Verify that when {@link ZkUtils#getQuorumPeerConfig(String)} is given a non-filepath argument, the original argument is returned. + */ + @Test + void testGetQuorumPeerConfigGivenNonFilePath() throws QuorumPeerConfig.ConfigException { + assertEquals("localhost:2181", ZkUtils.getQuorumPeerConfig("localhost:2181")); + } + + /** + * Verify that when {@link ZkUtils#getQuorumPeerConfig(String)} is given a filepath that does not point to an existing file, the original argument is + * returned. + */ + @Test + void testGetQuorumPeerConfigGivenNonExistentFile() throws QuorumPeerConfig.ConfigException { + assertEquals("/i/do/not/exist/zookeeper.cfg", ZkUtils.getQuorumPeerConfig("/i/do/not/exist/zookeeper.cfg")); + } + + /** + * Verify that when {@link ZkUtils#getQuorumPeerConfig(String)} is given an invalid zookeeper config file, an exception is thrown. + */ + @Test + void testGetQuorumPeerConfigGivenInvalidConfigFile() throws IOException { + Properties properties = new Properties(); + properties.put("tickTime", "2000"); + + String path = createZookeeperCfgFile(properties); + assertThrows(QuorumPeerConfig.ConfigException.class, () -> ZkUtils.getQuorumPeerConfig(path)); + } + + /** + * Verify that when {@link ZkUtils#getQuorumPeerConfig(String)} is given a valid zookeeper config path with the URI scheme {@code file://}, it is able to + * load and read the file. + */ + @Test + void testGetQuorumPeerConfigGivenPathWithLocalFileScheme() throws QuorumPeerConfig.ConfigException, IOException { + Properties properties = new Properties(); + properties.put("tickTime", "2000"); + properties.put("dataDir", "/var/zookeeper"); + properties.put("initLimit", "2"); + properties.put("syncLimit", "5"); + properties.put("clientPort", "2181"); + + String path = createZookeeperCfgFile(properties); + assertEquals("0.0.0.0:2181", ZkUtils.getQuorumPeerConfig("file://" + path)); + } + + /** + * Verify that when {@link ZkUtils#getQuorumPeerConfig(String)} is given a valid zookeeper config a client port address, the default client port address + * {@code 0.0.0.0} is returned. + */ + @Test + void testGetQuorumPeerConfigGivenValidConfigFileWithoutClientPortAddress() throws QuorumPeerConfig.ConfigException, IOException { + Properties properties = new Properties(); + properties.put("tickTime", "2000"); + properties.put("dataDir", "/var/zookeeper"); + properties.put("initLimit", "2"); + properties.put("syncLimit", "5"); + properties.put("clientPort", "2181"); + + String path = createZookeeperCfgFile(properties); + assertEquals("0.0.0.0:2181", ZkUtils.getQuorumPeerConfig(path)); + } + + /** + * Verify that when {@link ZkUtils#getQuorumPeerConfig(String)} is given a valid zookeeper config without servers, the default client port address is + * returned. + */ + @Test + void testGetQuorumPeerConfigGivenValidConfigFileWithoutServers() throws QuorumPeerConfig.ConfigException, IOException { + Properties properties = new Properties(); + properties.put("tickTime", "2000"); + properties.put("dataDir", "/var/zookeeper"); + properties.put("initLimit", "2"); + properties.put("syncLimit", "5"); + properties.put("clientPort", "2181"); + properties.put("clientPortAddress", "192.168.1.50"); + + String path = createZookeeperCfgFile(properties); + InetSocketAddress clientSocketAddress = new InetSocketAddress(InetAddress.getByName("192.168.1.50"), 2181); + String expected = clientSocketAddress.getHostName() + ":2181"; + + assertEquals(expected, ZkUtils.getQuorumPeerConfig(path)); + } + + /** + * Verify that when {@link ZkUtils#getQuorumPeerConfig(String)} is given a valid zookeeper config with servers, a comma-delimited list of the server client + * port addresses are returned. + */ + @SetSystemProperty(key = QuorumPeer.CONFIG_KEY_MULTI_ADDRESS_ENABLED, value = "true") + @Test + void testGetQuorumPeerConfigGivenValidConfigFileWithServers() throws QuorumPeerConfig.ConfigException, IOException { + // In order to not trigger a QuorumPeer exception, we need to create a myid file with one of the server IDs in it. + File myidFile = new File(tempDir, "myid"); + Files.writeString(myidFile.toPath(), "1"); + + // Make the dataDir property point to the temp dir so that QuorumPeer can find the myid file. + Properties properties = new Properties(); + properties.put("tickTime", "2000"); + properties.put("dataDir", "/var/zookeeper"); + properties.put("initLimit", "2"); + properties.put("syncLimit", "5"); + properties.put("clientPort", "2181"); + properties.put("server.1", "zoo1:2888:3888|client1:2888:3888"); + properties.put("server.2", "zoo2:2888:3888|client2:2888:3888"); + properties.setProperty("dataDir", tempDir.getAbsolutePath()); + + String path = createZookeeperCfgFile(properties); + assertEquals("client1:2181,client2:2181", ZkUtils.getQuorumPeerConfig(path)); + } + + private String createZookeeperCfgFile(Properties properties) throws IOException { + File file = new File(tempDir, "zookeeper.cfg"); + properties.store(new FileOutputStream(file), null); + return file.getAbsolutePath(); + } +} diff --git a/web-services/query/src/test/resources/TestZkObjectPublisherFactory.xml b/web-services/query/src/test/resources/TestZkObjectPublisherFactory.xml new file mode 100644 index 00000000000..a21f8631f04 --- /dev/null +++ b/web-services/query/src/test/resources/TestZkObjectPublisherFactory.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + diff --git a/web-services/query/src/test/resources/log4j.properties b/web-services/query/src/test/resources/log4j.properties index e60151be4e4..45f23ed228f 100644 --- a/web-services/query/src/test/resources/log4j.properties +++ b/web-services/query/src/test/resources/log4j.properties @@ -9,7 +9,9 @@ log4j.appender.THREAD_TRACER=org.apache.log4j.ConsoleAppender log4j.appender.THREAD_TRACER.layout=org.apache.log4j.PatternLayout log4j.appender.THREAD_TRACER.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss.SSS} %-5p (%t) [%C{1}:%M] %m%n -# Uncomment the following to see up to TRACE level events for the limit package with the thread name included. +log4j.logger.datawave.webservice.zookeeper=DEBUG +log4j.logger.datawave.webservice.query.limit=DEBUG +# Comment out the previous line and uncomment the following to see up to TRACE level events for the limit package with the thread name included. # Useful for debugging issues with datawave.webservice.query.limit.QueryLimiterConcurrencyTest -#log4j.logger.datawave.webservice.query.limit=TRACE, THREAD_TRACER +#log4j.logger.datawave.webservice.query.limit.QueryLimiterConcurrencyTest=TRACE, THREAD_TRACER #log4j.additivity.datawave.webservice.query.limit=false diff --git a/web-services/query/src/test/resources/queryLimits/different_valid_config.json b/web-services/query/src/test/resources/queryLimits/different_valid_config.json new file mode 100644 index 00000000000..ac45e4bdb7e --- /dev/null +++ b/web-services/query/src/test/resources/queryLimits/different_valid_config.json @@ -0,0 +1,27 @@ +{ + "defaultUserQueryLimit" : 50, + "defaultSystemQueryLimit" : 2000, + "internalCacheMaxSize" : 200, + "userConfigs" : [ { + "userDn" : "CN=User A, C=US", + "queryLimit" : 25, + "queryLogicGroupLimits" : { + "EdgeQueryLogic" : 20, + "EventQueryLogic" : 20 + } + }], + "systemConfigs" : [ { + "systemPattern" : "Olympus.*Athena", + "countsAgainstUserLimit" : true, + "queryLimit" : 1500, + "queryLogicGroupLimits" : { + "EdgeQueryLogic" : 500, + "EventQueryLogic" : 500 + } + }], + "queryLogicGroupConfigs" : [ { + "groupName" : "DefaultEdgeQueryLogicLimit", + "queryLogicPattern" : "Edge.*QueryLogic", + "queryLimit" : 150 + }] +} diff --git a/web-services/query/src/test/resources/queryLimits/invalid_config.yaml b/web-services/query/src/test/resources/queryLimits/invalid_config.yaml new file mode 100644 index 00000000000..db136b9e486 --- /dev/null +++ b/web-services/query/src/test/resources/queryLimits/invalid_config.yaml @@ -0,0 +1,5 @@ +--- +# Represents a QueryLimitConfiguration that should fail validation. +"defaultUserQueryLimit": -10 +"defaultSystemQueryLimit": 200 +"internalCacheMaxSize": 300 diff --git a/web-services/query/src/test/resources/queryLimits/non_config.yaml b/web-services/query/src/test/resources/queryLimits/non_config.yaml new file mode 100644 index 00000000000..497116022d5 --- /dev/null +++ b/web-services/query/src/test/resources/queryLimits/non_config.yaml @@ -0,0 +1,4 @@ +--- +# Represents an object that cannot be deserialized to a QueryLimitConfiguration. +"property1": "value" +"property2": "value" diff --git a/web-services/query/src/test/resources/queryLimits/unsupported_format.toml b/web-services/query/src/test/resources/queryLimits/unsupported_format.toml new file mode 100644 index 00000000000..d026d408741 --- /dev/null +++ b/web-services/query/src/test/resources/queryLimits/unsupported_format.toml @@ -0,0 +1,4 @@ +# Represents a QueryLimitConfiguration in a format that is not supported. +defaultUserQueryLimit = 100 +defaultSystemQueryLimit = 1000 +internalCacheMaxSize = 300 diff --git a/web-services/query/src/test/resources/queryLimits/valid_config.json b/web-services/query/src/test/resources/queryLimits/valid_config.json new file mode 100644 index 00000000000..63d7ac9dc76 --- /dev/null +++ b/web-services/query/src/test/resources/queryLimits/valid_config.json @@ -0,0 +1,44 @@ +{ + "defaultUserQueryLimit" : 100, + "defaultSystemQueryLimit" : 1000, + "internalCacheMaxSize" : 200, + "userConfigs" : [ { + "userDn" : "CN=User A, C=US", + "queryLimit" : 50, + "queryLogicGroupLimits" : { + "EdgeQueryLogic" : 15, + "EventQueryLogic" : 5 + } + }, { + "userDn" : "CN=User B, C=US", + "queryLimit" : 25, + "queryLogicGroupLimits" : { + "EventQueryLogic" : 10 + } + } ], + "systemConfigs" : [ { + "systemPattern" : ".*Athena", + "countsAgainstUserLimit" : true, + "queryLimit" : 2000, + "queryLogicGroupLimits" : { + "EdgeQueryLogic" : 800, + "EventQueryLogic" : 500 + } + }, { + "systemPattern" : ".*Artemis", + "countsAgainstUserLimit" : false, + "queryLimit" : 2000, + "queryLogicGroupLimits" : { + "EventQueryLogic" : 600 + } + } ], + "queryLogicGroupConfigs" : [ { + "groupName" : "DefaultEdgeQueryLogicLimit", + "queryLogicPattern" : "Edge.*QueryLogic", + "queryLimit" : 50 + }, { + "groupName" : "DefaultEventQueryLogicLimit", + "queryLogicPattern" : "Event.*QueryLogic", + "queryLimit" : 25 + } ] +} diff --git a/web-services/query/src/test/resources/queryLimits/valid_config.xml b/web-services/query/src/test/resources/queryLimits/valid_config.xml new file mode 100644 index 00000000000..19f67dc3649 --- /dev/null +++ b/web-services/query/src/test/resources/queryLimits/valid_config.xml @@ -0,0 +1,55 @@ + + + + 100 + 1000 + 200 + + + CN=User A, C=US + 50 + + 15 + 5 + + + + CN=User B, C=US + 25 + + 10 + + + + + + .*Athena + true + 2000 + + 800 + 500 + + + + .*Artemis + false + 2000 + + 600 + + + + + + DefaultEdgeQueryLogicLimit + Edge.*QueryLogic + 50 + + + DefaultEventQueryLogicLimit + Event.*QueryLogic + 25 + + + diff --git a/web-services/query/src/test/resources/queryLimits/valid_config.yaml b/web-services/query/src/test/resources/queryLimits/valid_config.yaml new file mode 100644 index 00000000000..d95477499bc --- /dev/null +++ b/web-services/query/src/test/resources/queryLimits/valid_config.yaml @@ -0,0 +1,35 @@ +--- +# Represents a QueryLimitConfiguration. +defaultUserQueryLimit: 100 +defaultSystemQueryLimit: 1000 +internalCacheMaxSize: 200 +userConfigs: + - userDn: "CN=User A, C=US" + queryLimit: 50 + queryLogicGroupLimits: + EdgeQueryLogic: 15 + EventQueryLogic: 5 + - userDn: "CN=User B, C=US" + queryLimit: 25 + queryLogicGroupLimits: + EventQueryLogic: 10 +systemConfigs: + - systemPattern: ".*Athena" + countsAgainstUserLimit: true + queryLimit: 2000 + queryLogicGroupLimits: + EdgeQueryLogic: 800 + EventQueryLogic: 500 + - systemPattern: ".*Artemis" + countsAgainstUserLimit: false + queryLimit: 2000 + queryLogicGroupLimits: + EventQueryLogic: 600 +queryLogicGroupConfigs: + - groupName: "DefaultEdgeQueryLogicLimit" + queryLogicPattern: "Edge.*QueryLogic" + queryLimit: 50 + - groupName: "DefaultEventQueryLogicLimit" + queryLogicPattern: "Event.*QueryLogic" + queryLimit: 25 +