From f407e151f82cffb592909ddc625d5d572cd2533b Mon Sep 17 00:00:00 2001 From: Laura Schanno Date: Fri, 1 May 2026 18:31:40 -0400 Subject: [PATCH 01/13] Support dynamic query limit configuration Support the ability to dynamically update concurrent query limits while the webservers are running. Add the class `QueryLimitReloader`. A `QueryLimitReloader` will operate within the Zookeeper namespace `QueryLimitConfig` to listen for events that should trigger a reload. A reload will be triggered by the following: - The creation or modification of the node `/path` with non-empty data. - The creation, modification, or deletion of the node `/trigger`. Upon a triggering event, the reloader will attempt to load a valid `QueryLimitConfiguration` and supply it to all listeners, such as the one registered by `QueryLimiter`. The reloader will attempt to deserialize a `QueryLimiterConfiguration` from a file. It will look for the filepath in the node `/path`. The target file may be JSON, XML, or YAML, and may have the URI schemes `http:`, `https:`, `hdfs:`, `file:`, or none. After a reload attempt, the following nodes will be updated with the status of the attempt: - `/attempts//cause`: The triggering event for the reload. This may be `PATH_NODE_CREATED`, `PATH_NODE_MODIFIED`, `TRIGGER_NODE_CREATED`, `TRIGGER_NODE_MODIFIED`, or `TRIGGER_NODE_DELETED`. - `/attempts//status`: The final status of the reload attempt. It may be one of the following: - `SUCCESS`: A valid `QueryLimitConfiguration` was loaded and supplied to all listeners. - `LISTENER_ERROR`: A valid `QueryLimitConfiguration` was loaded, but an exception was thrown when supplying it to one or more listeners. - `RELOAD_ERROR`: A valid `QueryLimitConfiguration` could not be loaded. - `/attempts//time`: The time of the reload attempt in ISO-8601 format. - `/attempts//errors`: A node whose children will contain brief descriptions of the errors that occurred. Exists only if any errors occurred. The children's paths will have the format `error_X` where X is a value from `0...N` where N is one less than the total errors that were captured. The full stacktrace for the errors will be captured in the logs. When the `QueryLimiter` is supplied with a new configuration, it will update its internal configuration, and rebuild its internal limit providers. Closes #3511 --- pom.xml | 23 + .../datawave/query/QueryLimiterFactory.xml | 20 +- web-services/query/pom.xml | 17 + .../query/limit/ActiveQueryTracker.java | 275 ++---- .../query/limit/GroupLimitCache.java | 19 +- .../ImmutableQueryLimitConfiguration.java | 98 ++ ...ableQueryLogicGroupLimitConfiguration.java | 47 + .../ImmutableSystemLimitConfiguration.java | 59 ++ .../ImmutableUserLimitConfiguration.java | 51 + .../query/limit/LockedZkClientDispatcher.java | 239 +++++ .../query/limit/QueryLimitConfigReloader.java | 871 ++++++++++++++++++ .../query/limit/QueryLimitConfiguration.java | 37 +- .../QueryLimitConfigurationValidator.java | 230 +++++ .../webservice/query/limit/QueryLimiter.java | 274 ++++-- .../QueryLogicGroupLimitConfiguration.java | 34 +- .../limit/QueryLogicGroupLimitProvider.java | 66 +- .../datawave/webservice/query/limit/README.md | 33 +- .../query/limit/SystemLimitConfiguration.java | 35 +- .../query/limit/SystemLimitProvider.java | 107 +-- .../query/limit/UserLimitConfiguration.java | 32 +- .../query/limit/UserLimitProvider.java | 67 +- .../query/limit/ZookeeperUtils.java | 66 ++ .../query/limit/ActiveQueryTrackerTest.java | 2 +- .../ImmutableQueryLimitConfigurationTest.java | 84 ++ ...QueryLogicGroupLimitConfigurationTest.java | 46 + ...ImmutableSystemLimitConfigurationTest.java | 50 + .../ImmutableUserLimitConfigurationTest.java | 48 + .../limit/LockedZkClientDispatcherTest.java | 241 +++++ .../limit/QueryLimitConfigReloaderTest.java | 808 ++++++++++++++++ .../limit/QueryLimitConfigurationTest.java | 261 ++++++ .../QueryLimitConfigurationValidatorTest.java | 265 ++++++ .../limit/QueryLimiterConcurrencyTest.java | 5 +- .../query/limit/QueryLimiterSpringTest.java | 1 + .../query/limit/QueryLimiterTest.java | 118 ++- .../QueryLogicGroupLimitProviderTest.java | 56 -- .../query/limit/SystemLimitProviderTest.java | 69 -- .../query/limit/UserLimitProviderTest.java | 35 - .../query/limit/ZookeeperUtilsTest.java | 140 +++ .../query/src/test/resources/log4j.properties | 6 +- .../queryLimits/different_valid_config.json | 27 + .../resources/queryLimits/invalid_config.yaml | 5 + .../resources/queryLimits/non_config.yaml | 4 + .../queryLimits/unsupported_format.toml | 4 + .../resources/queryLimits/valid_config.json | 44 + .../resources/queryLimits/valid_config.xml | 55 ++ .../resources/queryLimits/valid_config.yaml | 35 + 46 files changed, 4467 insertions(+), 642 deletions(-) create mode 100644 web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableQueryLimitConfiguration.java create mode 100644 web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableQueryLogicGroupLimitConfiguration.java create mode 100644 web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableSystemLimitConfiguration.java create mode 100644 web-services/query/src/main/java/datawave/webservice/query/limit/ImmutableUserLimitConfiguration.java create mode 100644 web-services/query/src/main/java/datawave/webservice/query/limit/LockedZkClientDispatcher.java create mode 100644 web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java create mode 100644 web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigurationValidator.java create mode 100644 web-services/query/src/main/java/datawave/webservice/query/limit/ZookeeperUtils.java create mode 100644 web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableQueryLimitConfigurationTest.java create mode 100644 web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableQueryLogicGroupLimitConfigurationTest.java create mode 100644 web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableSystemLimitConfigurationTest.java create mode 100644 web-services/query/src/test/java/datawave/webservice/query/limit/ImmutableUserLimitConfigurationTest.java create mode 100644 web-services/query/src/test/java/datawave/webservice/query/limit/LockedZkClientDispatcherTest.java create mode 100644 web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigReloaderTest.java create mode 100644 web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationTest.java create mode 100644 web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationValidatorTest.java create mode 100644 web-services/query/src/test/java/datawave/webservice/query/limit/ZookeeperUtilsTest.java create mode 100644 web-services/query/src/test/resources/queryLimits/different_valid_config.json create mode 100644 web-services/query/src/test/resources/queryLimits/invalid_config.yaml create mode 100644 web-services/query/src/test/resources/queryLimits/non_config.yaml create mode 100644 web-services/query/src/test/resources/queryLimits/unsupported_format.toml create mode 100644 web-services/query/src/test/resources/queryLimits/valid_config.json create mode 100644 web-services/query/src/test/resources/queryLimits/valid_config.xml create mode 100644 web-services/query/src/test/resources/queryLimits/valid_config.yaml diff --git a/pom.xml b/pom.xml index 44d20522570..18bc372aa11 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 @@ -211,6 +213,16 @@ jackson-databind ${version.jackson} + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + ${version.jackson} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + ${version.jackson} + com.fasterxml.jackson.datatype jackson-datatype-guava @@ -1137,6 +1149,11 @@ + + org.awaitility + awaitility + ${version.awaitutility} + org.eclipse.emf org.eclipse.emf.common @@ -1469,6 +1486,12 @@ ${version.weld-test} test + + org.junit-pioneer + junit-pioneer + ${version.junit-pioneer} + test + org.junit.jupiter junit-jupiter 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..1e5e5ab78fc 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,28 @@ - + + + + + + + + diff --git a/web-services/query/pom.xml b/web-services/query/pom.xml index f9ca7002fb3..577a68471ef 100644 --- a/web-services/query/pom.xml +++ b/web-services/query/pom.xml @@ -10,6 +10,14 @@ ejb ${project.artifactId} + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + com.google.code.gson gson @@ -99,6 +107,10 @@ 3.0 jar + + org.awaitility + awaitility + org.easymock easymock @@ -249,6 +261,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/limit/ActiveQueryTracker.java b/web-services/query/src/main/java/datawave/webservice/query/limit/ActiveQueryTracker.java index 669b0daf260..56b88cfaf43 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,9 @@ package datawave.webservice.query.limit; -import java.io.File; -import java.net.URI; 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 +11,15 @@ 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; /** - * 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 { @@ -40,52 +33,30 @@ public class ActiveQueryTracker implements AutoCloseable { 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 = ZookeeperUtils.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 +106,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 +137,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 +159,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 +183,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 getTotalChildrenWithLock(client, getUserQueryLogicPath(userDn, queryLogic)); + } } /** @@ -238,30 +208,29 @@ public int getTotalSystemQueriesForQueryLogic(String system, String queryLogic) log.trace("Fetching total queries for system='" + system + "', queryLogic='" + queryLogic + "'"); } - return getTotalChildrenWithLock(getSystemQueryLogicPath(system, queryLogic)); + try (LockedZkClientDispatcher.LockedClient lockedClient = clientDispatcher.getLockedClient()) { + CuratorFramework client = lockedClient.getClient(); + return getTotalChildrenWithLock(client, 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 client + * the client * @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(); + private int getTotalChildrenWithLock(CuratorFramework client, String path) throws Exception { try { - // Initialize the client if needed. - initClient(); - - return getTotalChildren(path); + return getTotalChildren(client, path); } catch (Exception e) { log.error("Failed to get total children for path " + path, e); throw e; - } finally { - clientLock.unlock(); } } @@ -289,7 +258,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 +377,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 +391,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 +483,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/LockedZkClientDispatcher.java b/web-services/query/src/main/java/datawave/webservice/query/limit/LockedZkClientDispatcher.java new file mode 100644 index 00000000000..8775a9092f2 --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/LockedZkClientDispatcher.java @@ -0,0 +1,239 @@ +package datawave.webservice.query.limit; + +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) { + if (log.isWarnEnabled()) { + 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) { + if (log.isWarnEnabled()) { + 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/query/limit/QueryLimitConfigReloader.java b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java new file mode 100644 index 00000000000..196c981ce8a --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java @@ -0,0 +1,871 @@ +package datawave.webservice.query.limit; + +import static org.apache.commons.lang.StringUtils.split; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.URI; +import java.net.URL; +import java.net.UnknownHostException; +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.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.apache.zookeeper.server.quorum.QuorumPeerConfig; +import org.awaitility.Awaitility; +import org.awaitility.core.ConditionTimeoutException; + +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; + +/** + * This class provides functionality for leveraging Zookeeper to watch for changes that should trigger a reload of a {@link QueryLimitConfiguration} and provide + * it to listeners. It is expected that only a singleton {@link QueryLimitConfigReloader} will exist to be injected where needed. The Zookeeper logic here + * requires that only a singleton {@link QueryLimitConfigReloader} is used on each server. + */ +public class QueryLimitConfigReloader implements AutoCloseable { + + public static final String ZOOKEEPER_NAMESPACE = "QueryLimitConfig"; + + private static final Logger log = Logger.getLogger(QueryLimitConfigReloader.class); + + private static final String NODE_PATH = "/path"; + private static final String NODE_TRIGGER = "/trigger"; + private static final String NODE_ATTEMPTS = "/attempts"; + private static final String NODE_CAUSE = "/cause"; + private static final String NODE_STATUS = "/status"; + private static final String NODE_ERRORS = "/errors"; + private static final String NODE_ERROR_BASE = "/error_"; + private static final String NODE_TIME = "/time"; + + /** + * Mapper for JSON files. + */ + private static final JsonMapper jsonMapper = JsonMapper.builder().build(); + + /** + * Mapper for XML files. + */ + private static final XmlMapper xmlMapper = XmlMapper.builder().build(); + + /** + * Mapper for YAML files. + */ + private static final YAMLMapper yamlMapper = YAMLMapper.builder().build(); + + /** + * 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 list of listeners that should be supplied with new {@link QueryLimitConfiguration} after successful reloads. + */ + private List> listeners = 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(ReloadCause)} 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; + + /** + * The finalized path for the node {@code /attempts/}. + */ + private String baseAttemptNode; + + /** + * The finalized path for the node {@code /attempts//cause}. + */ + private String attemptCauseNode; + + /** + * The finalized path for the node {@code /attempts//status}. + */ + private String attemptStatusNode; + + /** + * The finalized path for the node {@code /attempts//errors}. + */ + private String attemptErrorsNode; + + /** + * The finalized path for the node {@code /attempts//time}. + */ + private String attemptTimeNode; + + /** + * The configuration to use when connecting to HDFS. + */ + private Configuration hadoopConfig; + + /** + * The zookeeper config. + */ + private String zookeeperConfig; + + /** + * The HDFS site config URLs. + */ + private String hdfsConfigUrls; + + /** + * Indicates whether a reload attempt succeeded for failed. + */ + public enum ReloadStatus { + /** + * Indicates a reload attempt was successful. + */ + SUCCESS, + /** + * Indicates a new {@link QueryLimitConfigReloader} could not be loaded from Zookeeper. + */ + RELOAD_ERROR, + /** + * Indicates a new {@link QueryLimitConfigReloader} was successfully loaded from Zookeeper, but an error occurred when supplying it to a listener. + */ + LISTENER_ERROR + } + + /** + * Indicates the triggering event that launched a new reload attempt. + */ + public enum ReloadCause { + /** + * Indicates the triggering event was the creation of the node {@value #NODE_PATH} with non-empty data. + */ + PATH_NODE_CREATED, + /** + * Indicates the triggering event was the modification of the node {@value #NODE_PATH} with non-empty data. + */ + PATH_NODE_MODIFIED, + /** + * Indicates the triggering event was the creation of the node {@value #NODE_TRIGGER}. + */ + TRIGGER_NODE_CREATED, + /** + * Indicates the triggering event was the modification of the node {@value #NODE_TRIGGER}. + */ + TRIGGER_NODE_MODIFIED, + /** + * Indicates the triggering event was the deletion of the node {@value #NODE_TRIGGER}. + */ + TRIGGER_NODE_DELETED + } + + /** + * Return the zookeeper configs. + * + * @return the zookeeper config + */ + public String getZookeeperConfig() { + return zookeeperConfig; + } + + /** + * Set the zookeeper configs. This can be a comma-delimited list of zookeeper hosts or a path to a local zookeeper config file. + */ + public void setZookeeperConfig(String zookeeperConfig) { + this.zookeeperConfig = zookeeperConfig; + } + + /** + * Return the HDFS site config URLs. + * + * @return the URLs + */ + public String getHdfsConfigUrls() { + return hdfsConfigUrls; + } + + /** + * Set a comma-delimited list of HDFS configuration files. + * + * @param hdfsConfigUrls + * the URLs + */ + public void setHdfsConfigUrls(String hdfsConfigUrls) { + this.hdfsConfigUrls = hdfsConfigUrls; + } + + /** + * Create the underlying {@link CuratorCache} caches that will watch for changes to the nodes {@code /path} and {@code /trigger}. These caches require some + * backend initialization before they can start listening for node changes. Use {@link QueryLimitConfigReloader#awaitCacheInitialization(long, TimeUnit)} to + * await the cache initialization. For testing purposes, this method should be called after setting the zookeeper configs, hdfs site config URLs, and client + * cleanup interval. + */ + public void setup() throws QuorumPeerConfig.ConfigException { + // If the zookeeper config points to a file, extract the hosts from it. + this.zookeeperConfig = ZookeeperUtils.getQuorumPeerConfig(this.zookeeperConfig); + + // @formatter:off + CuratorFrameworkFactory.Builder clientFactory = CuratorFrameworkFactory.builder() + .namespace(ZOOKEEPER_NAMESPACE) + .connectString(zookeeperConfig) + .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)); + try { + this.hadoopConfig = new Configuration(); + if (hdfsConfigUrls != null && !hdfsConfigUrls.isBlank()) { + for (String url : split(hdfsConfigUrls, ',')) { + hadoopConfig.addResource(new URL(url)); + } + } + } catch (Exception e) { + throw new RuntimeException("Failed to load hadoop configuration from URLs '" + hdfsConfigUrls + "'", e); + } + + // Construct the finalized attempt node paths to be relative to the server IP address. + try { + String serverIpAddress = InetAddress.getLocalHost().getHostAddress(); + baseAttemptNode = NODE_ATTEMPTS + "/" + serverIpAddress; + attemptCauseNode = baseAttemptNode + NODE_CAUSE; + attemptStatusNode = baseAttemptNode + NODE_STATUS; + attemptErrorsNode = baseAttemptNode + NODE_ERRORS; + attemptTimeNode = baseAttemptNode + NODE_TIME; + } catch (UnknownHostException e) { + throw new RuntimeException("Failed to get local host address", e); + } + } + + public void shutdown() { + close(); + } + + /** + * 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("Failed to create curator cache for path node " + node, e); + throw new RuntimeException("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 + */ + 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) { + if(log.isDebugEnabled()) { + log.debug("Triggering reload due to creation of node " + NODE_PATH + " with non-empty data at time " + System.currentTimeMillis()); + } + executor.submit(()-> triggerReload(ReloadCause.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) { + if(log.isDebugEnabled()){ + log.debug("Triggering reload due to modification of node " + NODE_PATH + " with non-empty data"); + } + executor.submit(()-> triggerReload(ReloadCause.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) -> { + if(log.isDebugEnabled()){ + log.debug("Trigger reload due to creation of node " + NODE_TRIGGER ); + } + executor.submit(()-> triggerReload(ReloadCause.TRIGGER_NODE_CREATED)); + }) + .forChanges((oldNode, newNode) -> { + if(log.isDebugEnabled()){ + log.debug("Triggering reload due to modification of node " + NODE_TRIGGER); + } + executor.submit(() -> triggerReload(ReloadCause.TRIGGER_NODE_MODIFIED)); + }) + .forDeletes((node) -> { + if(log.isDebugEnabled()){ + log.debug("Triggering reload due to deletion of node " + NODE_TRIGGER); + } + executor.submit(() -> triggerReload(ReloadCause.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 configuration reload. If a valid configuration is loaded + */ + private void triggerReload(ReloadCause cause) { + if (log.isDebugEnabled()) { + log.debug("Configuration reload triggered"); + } + + // Obtain the reload lock. + reloadLock.lock(); + try { + Instant attemptTime = Instant.now(); + // Attempt to load the configuration from the path node. + LoadResult result = loadConfiguration(); + + // If we successfully loaded a valid configuration, pass it to any listeners registered with this loader. + if (result.status == ReloadStatus.SUCCESS) { + if (!listeners.isEmpty()) { + for (Consumer listener : listeners) { + try { + listener.accept(result.config); + } catch (Exception e) { + // If an exception is thrown by a listener, log it and record it in the status. + log.warn("Exception thrown by listener " + listener, e); + result.setStatus(ReloadStatus.LISTENER_ERROR); + result.addErrorMessage("Exception thrown by listener: " + e.getMessage()); + } + } + if (log.isDebugEnabled()) { + log.debug("Supplied configuration update to all listeners"); + } + } else { + log.debug("No listeners registered to be supplied configuration updates"); + } + } + + // Update the attempt nodes for the latest attempt. + updateAttemptNodes(cause, result.getStatus(), result.getErrorMessages(), attemptTime); + } catch (Exception e) { + log.error("Failed to reload configuration", e); + throw new RuntimeException("Failed to reload configuration", e); + } finally { + reloadLock.unlock(); + } + + log.debug("Reload complete"); + } + + /** + * Make the following changes underneath the namespace {@value ZOOKEEPER_NAMESPACE}. 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 ReloadStatus}.
  • + *
  • Set the data for the node {@code /attempts//cause} to the bytes of the string form of the given {@link ReloadCause}.
  • + *
  • 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 errorMessages + * the error messages + * @param time + * the time of the attempt + * @throws Exception + * if an error occurs on Zookeeper + */ + private void updateAttemptNodes(ReloadCause cause, ReloadStatus status, List errorMessages, 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, errorMessages); + setData(client, attemptTimeNode, time.toString().getBytes()); + } + } + + /** + * Update the node {@code /attempts//errors} to reflect the contents of the given error message list. + * + * @param client + * the client + * @param errorMessages + * the error messages + * @throws Exception + * if an error occurs in Zookeeper + */ + private void updateErrorsNode(CuratorFramework client, List errorMessages) throws Exception { + Stat stat = client.checkExists().forPath(attemptErrorsNode); + if (stat != null) { + client.delete().deletingChildrenIfNeeded().forPath(attemptErrorsNode); + } + if (!errorMessages.isEmpty()) { + client.create().forPath(attemptErrorsNode); + for (int i = 0; i < errorMessages.size(); i++) { + String messageNode = attemptErrorsNode + NODE_ERROR_BASE + i; + setData(client, messageNode, errorMessages.get(i).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 {@link QueryLimitConfiguration} from the path specified in the data of the node {@value NODE_PATH} under the zookeeper namespace + * {@value ZOOKEEPER_NAMESPACE}. The path may point to an http, hdfs, or local file. Note that an invocation of this method will not result in the + * configuration being supplied to any listeners. + * + * @return the reload result + */ + public LoadResult loadConfiguration() { + if (log.isDebugEnabled()) { + log.debug("Attempting to load new query limit configuration"); + } + try (LockedZkClientDispatcher.LockedClient lockedClient = clientDispatcher.getLockedClient()) { + CuratorFramework client = lockedClient.getClient(); + + // Verify that the config URL node exists. + Stat stat = client.checkExists().forPath(NODE_PATH); + if (stat == null) { + if (log.isDebugEnabled()) { + log.debug("Node " + NODE_PATH + " does not exist, skipping reload"); + } + return LoadResult.reloadError("Node does not exist: " + 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 || pathBytes.length == 0) { + if (log.isDebugEnabled()) { + log.debug("Node " + NODE_PATH + " does not have a non-blank filepath, skipping reload"); + } + return LoadResult.reloadError("Config file path is not set in data for node " + NODE_PATH); + } + + String path = new String(pathBytes); + if (path.isBlank()) { + if (log.isDebugEnabled()) { + log.debug("Blank config filepath set in data for node " + NODE_PATH + ", skipping reload"); + } + return LoadResult.reloadError("Config file path is not set in data for node " + 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("Failed to read contents from file " + path, e); + return LoadResult.reloadError("File not found: " + path); + } catch (Exception e) { + log.error("Failed to read contents from file " + path, e); + return LoadResult.reloadError("Failed to read contents from file " + path + ": " + e.getMessage()); + } + + // Determine the format (XML, JSON, YAML) and use the corresponding mapper to deserialize the contents. + QueryLimitConfiguration config; + DataFormatMatcher format = formatDetector.findFormat(contents); + if (format.hasMatch()) { + JsonFactory factory = format.getMatch(); + if (log.isDebugEnabled()) { + log.debug("Deserializing config file using format " + factory.getFormatName()); + } + try { + // Deserialize the configuration using the associated mapper for the format. + config = formatToMapper.get(factory.getFormatName()).readValue(contents, QueryLimitConfiguration.class); + } catch (Exception e) { + log.error("Failed to deserialize file " + path + " to a " + QueryLimitConfiguration.class.getName(), e); + return LoadResult.reloadError("Failed to deserialize file to a " + QueryLimitConfiguration.class.getSimpleName()); + } + } else { + // If we do not have a match for a supported mapper, return an error. + if (log.isDebugEnabled()) { + log.debug("Query limit file " + path + " is not XML, JSON, or YAML, skipping reload"); + } + return LoadResult.reloadError("Config file must be XML, JSON, or YAML"); + } + + // If we successfully deserialize a QueryLimitConfiguration, validate it. + try { + QueryLimitConfigurationValidator.validate(config); + if (log.isDebugEnabled()) { + log.debug("Successfully loaded query limit configuration from file " + path + ": " + config); + } + return LoadResult.success(config); + } catch (Exception e) { + if (log.isDebugEnabled()) { + log.debug("Query limit configuration failed validation, skipping reload", e); + } + return LoadResult.reloadError("Configuration failed validation: " + e.getMessage()); + } + } catch (Exception e) { + log.error("Failed to load query limit configuration from Zookeeper nodes", e); + throw new RuntimeException("Failed to load query limit configuration from Zookeeper nodes", 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("Attempting to load query limit configuration 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("Attempting to load query limit configuration from HDFS file: " + 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("Attempting to load query limit configuration from local file: " + path); + } + try (InputStream is = Files.newInputStream(Path.of(path), StandardOpenOption.READ)) { + return IOUtils.toByteArray(is); + } + } + + /** + * Add a {@link Consumer} that, when a new {@link QueryLimitConfiguration} is loaded a path specified in Zookeeper, will be provided that configuration. + * + * @param listener + * the listener + */ + public void addListener(Consumer listener) { + this.listeners.add(listener); + } + + /** + * Perform 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 listener list.
      • + *
      • Close the locked client dispatcher.
      • + *
      + */ + public void cleanup() { + if (pathCache != null) { + try { + pathCache.close(); + } catch (Exception e) { + log.error("Failed to close path cache", e); + } + pathCache = null; + } + if (triggerCache != null) { + try { + triggerCache.close(); + } catch (Exception e) { + log.error("Failed to close trigger cache", e); + } + triggerCache = null; + } + if (executor != null) { + try { + executor.shutdown(); + } catch (Exception e) { + log.error("Failed to close executor", e); + } + executor = null; + } + + if (listeners != null) { + try { + listeners.clear(); + } catch (Exception e) { + log.error("Failed to clear listeners", e); + } finally { + listeners = null; + } + } + + if (clientDispatcher != null) { + try { + clientDispatcher.close(); + } catch (Exception e) { + log.error("Failed to close client dispatcher", e); + } + clientDispatcher = null; + } + } + + /** + * Clean up resources used by this {@link QueryLimitConfigReloader} via {@link #cleanup()}. + */ + @Override + public void close() { + cleanup(); + } + + public static class LoadResult { + private final QueryLimitConfiguration config; + private final List errorMessages = new ArrayList<>(); + private ReloadStatus status; + + public static LoadResult success(QueryLimitConfiguration config) { + return new LoadResult(config, ReloadStatus.SUCCESS); + } + + public static LoadResult reloadError(String message) { + LoadResult result = new LoadResult(null, ReloadStatus.RELOAD_ERROR); + result.addErrorMessage(message); + return result; + } + + private LoadResult(QueryLimitConfiguration config, ReloadStatus status) { + this.config = config; + this.status = status; + } + + public QueryLimitConfiguration getConfig() { + return config; + } + + public ReloadStatus getStatus() { + return status; + } + + public void setStatus(ReloadStatus status) { + this.status = status; + } + + public void addErrorMessage(String message) { + errorMessages.add(message); + } + + public List getErrorMessages() { + return errorMessages; + } + } +} 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/QueryLimitConfigurationValidator.java b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigurationValidator.java new file mode 100644 index 00000000000..1f31eacfc73 --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigurationValidator.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 QueryLimitConfigurationValidator { + + /** + * 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 QueryLimitConfigurationValidator() { + throw new UnsupportedOperationException(); + } +} 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..7386f41933b 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,33 @@ 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; + /** * 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 +48,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 config reloader responsible for notifying the query limiter when there are updates to the configuration. + private QueryLimitConfigReloader configReloader; + + // Whether the limiter is currently in a state where it can provide limits + private boolean canProvideLimits = false; /** * Return the zookeeper connection string. @@ -60,24 +74,141 @@ public void setZookeeperConfig(String zookeeperConfig) { } /** - * Set the configuration to use to set up this {@link QueryLimiter} + * Set the config reloader that will notify this {@link QueryLimiter} of configuration updates. + * + * @param configReloader + * the configuration reloader + */ + public void setConfigReloader(QueryLimitConfigReloader configReloader) { + this.configReloader = configReloader; + } + + /** + * 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) { + QueryLimitConfigurationValidator.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"); + } + // 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"); + } + // 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"); + } + // 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 +219,35 @@ 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 { + // If no configuration was supplied from a configured bean, attempt to load a configuration from Zookeeper. + if (this.configuration == null) { + if (this.configReloader != null) { + QueryLimitConfigReloader.LoadResult loadResult = configReloader.loadConfiguration(); + if (loadResult.getStatus() == QueryLimitConfigReloader.ReloadStatus.SUCCESS) { + // Update the configuration and create the providers. The configuration returned by the reloader will already be validated. + updateConfiguration(loadResult.getConfig(), false); + } + } + if (this.configReloader == null) { + throw new IllegalStateException("No configuration supplied for Query Limiter via injection or Zookeeper."); + } + } else { + // Update the configuration and create the providers. + updateConfiguration(this.configuration, true); } - if (this.configuration.getInternalCacheMaxSize() < 1) { - throw new IllegalArgumentException("Internal cache max size must be greater than 0"); + // 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 (configReloader != null) { + configReloader.addListener(((config) -> updateConfiguration(config, false))); } - - 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; + } finally { + configLock.unlock(); } } @@ -123,6 +261,7 @@ public void shutdown() { } catch (Exception e) { log.error("Error closing heartbeat cache", e); } + this.heartbeatCache = null; } if (this.activeQueryTracker != null) { try { @@ -130,6 +269,15 @@ public void shutdown() { } catch (Exception e) { log.error("Error closing active query tracker", e); } + this.activeQueryTracker = null; + } + if (this.configReloader != null) { + try { + this.configReloader.close(); + } catch (Exception e) { + log.error("Error closing config reloader", e); + } + this.configReloader = null; } } @@ -147,29 +295,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 +343,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..2602dc951fa 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,18 +3,13 @@ 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; - /** * This class is responsible for identifying and providing limits that should be enforced for query logic groups. */ @@ -29,7 +24,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 +31,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 +109,20 @@ public Map getGroupMatchers(Set groups) { return map; } + /** + * Clean up this {@link QueryLogicGroupLimitProvider} and release its underlying resources. + */ + public void cleanUp() { + if (groupsToLimits != null) { + groupsToLimits.clear(); + groupsToLimits = null; + } + if (groupLimitCache != null) { + groupLimitCache.cleanUp(); + 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..f43e48825d2 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,37 @@ 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 [QueryLimitConfigReloader](QueryLimitConfigReloader.java), it will register a listener with the reloader. When the reloader receives a triggering event, it will attempt to load a new `QueryLimitConfiguration` from a file who's filepath is specified in Zookeeper, and provide the configuration (if valid) to any listeners. + +The `QueryLimitConfigReloader` will operate on nodes in Zookeeper under the namespace `QueryLimitConfig`. When a reload is triggered, it will load a new `QueryLimitConfigration` from the file URL in the data of the node `/path`. A reload will be triggered if any of the following events happen: +- The node `/path` is created with non-empty data, or modified with new non-empty data. +- The node `/trigger` is created, modified, or deleted. + +The data of the node `/path` should be set to the file URL of a JSON, XML, or YAML file that can be deserialized to a `QueryLimitConfiguration`. The URL may have the URI schemes `http:`, `https:`,`hdfs:`, or `file:`. In the case of no scheme, the file will be loaded from the local filesystem. After a reload attempt, the following nodes will be created/updated: + +``` +/attempts//status # The status of the latest reload attempt. +/attempts//cause # The triggering cause of the latest reload attempt. +/attempts//time # The time of the latest reload attempt in ISO-8601 format. +/attempts//errors # A node containing children whose data contains brief descriptions about errors that occurred. Exists only when an error occurred. +``` + +The data of the node `/attempts//status` will be one of the following: +- `SUCCESS`: Indicates a valid `QueryLimitConfiguration` was loaded from the file and supplied to all configured listeners. +- `LISTENER_ERROR`: Indicates a valid `QueryLimitConfiguration` was loaded from the file, but one or more listeners threw an exception when provided the configuration. +- `RELOAD_ERROR`: Indicates a valid `QueryLimitConfiguration` could not be loaded. + +The data of the node `/attempts//cause` will be one of the following: +- `PATH_NODE_CREATED`: The reload was triggered by the creation of the node `/path` with non-empty data. +- `PATH_NODE_MODIFIED`: The reload was triggered by the modification of the node `/path` with non-empty data. +- `TRIGGER_NODE_CREATED`: The reload was triggered by the creation of the node `/trigger`. +- `TRIGGER_NODE_MODIFIED`: The reload was triggered by the modification of the node `/trigger`. +- `TRIGGER_NODE_DELETED`: The reload was triggered by the deletion of the node `/trigger`. + +The node `/attempts//errors` will contain children with names following the format `/error_` where X is a value from `0` to one less than the total errors that were recorded. The data of each error node will contain brief descriptions of the error that occurred. The full stack trace will be available in the logs. + ## 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 +101,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..16d9dcb5310 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,20 @@ 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) { + systemLimitCache.invalidateAll(); + systemLimitCache = null; + } + if (sortedSystemLimits != null) { + sortedSystemLimits.clear(); + 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..6ea94bafe06 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,14 @@ 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() { + if (customLimits != null) { + customLimits.clear(); + customLimits = null; + } + } } diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/ZookeeperUtils.java b/web-services/query/src/main/java/datawave/webservice/query/limit/ZookeeperUtils.java new file mode 100644 index 00000000000..33d643bd812 --- /dev/null +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/ZookeeperUtils.java @@ -0,0 +1,66 @@ +package datawave.webservice.query.limit; + +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 ZookeeperUtils { + + /** + * Return a + * + * @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 ZookeeperUtils() { + throw new UnsupportedOperationException(); + } +} 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/LockedZkClientDispatcherTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/LockedZkClientDispatcherTest.java new file mode 100644 index 00000000000..d644e487651 --- /dev/null +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/LockedZkClientDispatcherTest.java @@ -0,0 +1,241 @@ +package datawave.webservice.query.limit; + +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/query/limit/QueryLimitConfigReloaderTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigReloaderTest.java new file mode 100644 index 00000000000..c340a37fa27 --- /dev/null +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigReloaderTest.java @@ -0,0 +1,808 @@ +package datawave.webservice.query.limit; + +import static org.assertj.core.api.Assertions.assertThat; +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.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; + +class QueryLimitConfigReloaderTest { + + 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 QueryLimitConfigReloader reloader; + 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 = QueryLimitConfigReloaderTest.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(); + reloader = null; + server = new TestingServer(); + server.start(); + // @formatter:off + client = CuratorFrameworkFactory.builder().namespace("QueryLimitConfig") + .connectString(server.getConnectString()) + .sessionTimeoutMs(300000) + .connectionTimeoutMs(60000) + .retryPolicy(new RetryNTimes(10, 1000)) + .build(); + // @formatter:on + client.start(); + + } + + @AfterEach + void tearDown() throws IOException { + if (reloader != null) { + reloader.close(); + } + if (client != null) { + client.close(); + } + if (server != null) { + server.close(); + } + } + + /** + * Verify that the {@link QueryLimitConfigReloader} 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 reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.SUCCESS); + assertErrorsNodeDoesNotExist(); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that the {@link QueryLimitConfigReloader} 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 reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.SUCCESS); + assertErrorsNodeDoesNotExist(); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that the {@link QueryLimitConfigReloader} 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 reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.SUCCESS); + assertErrorsNodeDoesNotExist(); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that if the file path is prefixed with {@code file://}, {@link QueryLimitConfigReloader} 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 reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.SUCCESS); + assertErrorsNodeDoesNotExist(); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that if the node {@code /trigger} is created, {@link QueryLimitConfigReloader} will reload the configuration. + */ + @Test + void testReloadTriggeredByTriggerNodeCreation() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", validJsonFile); + + // Create the reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_CREATED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.SUCCESS); + assertErrorsNodeDoesNotExist(); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that if the node {@code /trigger} is deleted, {@link QueryLimitConfigReloader} will reload the configuration. + */ + @Test + void testReloadTriggeredByTriggerNodeDeleted() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", validJsonFile); + createOrUpdateNode("/trigger", "changeme"); + + // Create the reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_DELETED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.SUCCESS); + assertErrorsNodeDoesNotExist(); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that if the node {@code /path} is created with empty data, {@link QueryLimitConfigReloader} will not reload a configuration. + */ + @Test + void testReloadNotTriggeredByPathNodeCreationWithEmptyData() throws Exception { + // Create the reloader and start listening for trigger events. + createReloader(); + + // 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 QueryLimitConfigReloader} will not reload a configuration. + */ + @Test + void testReloadNotTriggeredByPathNodeModificationWithEmptyData() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", validJsonFile); + + // Create the reloader and start listening for trigger events. + createReloader(); + + // 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 QueryLimitConfigReloader} will reload a configuration. + */ + @Test + void testReloadTriggeredByPathNodeCreationWithNonEmptyData() throws Exception { + // Create the reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.PATH_NODE_CREATED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.SUCCESS); + assertErrorsNodeDoesNotExist(); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that if the node {@code /path} is modified with non-empty data, {@link QueryLimitConfigReloader} will reload a configuration. + */ + @Test + void testReloadTriggeredByPathModificationWithNonEmptyData() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", validJsonFile); + + // Create the reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.PATH_NODE_MODIFIED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.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 reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); + assertErrors("Node does not exist: /path"); + 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 reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); + assertErrors("Config file path is not set in data for node /path"); + 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 reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); + assertErrors("Config file path is not set in data for node /path"); + 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 reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); + assertErrors("Failed to read contents from file ftp://i/do/not/exist: 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 reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); + assertErrors("File not found: 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 reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); + assertErrors("Config file must be XML, JSON, or YAML"); + 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 reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); + assertErrors("Failed to deserialize file to a QueryLimitConfiguration"); + 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 reloader and start listening for trigger events. + createReloader(); + + // 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(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); + assertErrors("Configuration failed validation: Default user query limit must be greater than 0"); + assertTimeNodeHasRecentTime(); + } + + /** + * Verify that if exceptions are thrown by listeners after supplying them with a new configuration, the errors are captured and recorded. + */ + @Test + void testExceptionsThrownByListeners() throws Exception { + // Set up the /path node beforehand. + createOrUpdateNode("/path", validJsonFile); + createOrUpdateNode("/trigger", "changeme"); + + // Create the reloader and start listening for trigger events. + createReloader(); + + // Add listeners to the reloader that will throw a variety of exceptions. + reloader.addListener(configuration -> { + throw new NullPointerException("Something bad happened!"); + }); + reloader.addListener(configuration -> { + throw new IllegalArgumentException("I don't like this configuration."); + }); + reloader.addListener(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(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); + assertStatus(QueryLimitConfigReloader.ReloadStatus.LISTENER_ERROR); + assertErrors("Exception thrown by listener: Something bad happened!", "Exception thrown by listener: I don't like this configuration.", + "Exception thrown by listener: 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(QueryLimitConfigReloader.ReloadCause cause) throws Exception { + assertData(causeNode, cause.toString()); + } + + private void assertStatus(QueryLimitConfigReloader.ReloadStatus 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 assertErrors(String... errors) throws Exception { + Stat stat = client.checkExists().forPath(errorsNode); + assertNotNull(stat, "Expected node " + errorsNode + " to exist"); + List children = client.getChildren().forPath(errorsNode); + List actualErrors = new ArrayList<>(); + for (String child : children) { + String actualData = new String(client.getData().forPath(errorsNode + "/" + child)); + actualErrors.add(actualData); + } + assertThat(actualErrors).containsExactlyInAnyOrder(errors); + } + + 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 createReloader() throws QuorumPeerConfig.ConfigException { + reloader = new QueryLimitConfigReloader(); + reloader.setZookeeperConfig(server.getConnectString()); + reloader.setup(); + try { + reloader.awaitCacheInitialization(5, TimeUnit.SECONDS); + } catch (Exception e) { + throw new RuntimeException("Reloader caches failed to initialize before timeout", e); + } + reloader.addListener(configs::add); + } + + 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/query/limit/QueryLimitConfigurationTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationTest.java new file mode 100644 index 00000000000..efec38c30b5 --- /dev/null +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationTest.java @@ -0,0 +1,261 @@ +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 = YAMLMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + + @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/QueryLimitConfigurationValidatorTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationValidatorTest.java new file mode 100644 index 00000000000..00777746c13 --- /dev/null +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationValidatorTest.java @@ -0,0 +1,265 @@ +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 QueryLimitConfigurationValidator}. + */ +class QueryLimitConfigurationValidatorTest { + + /** + * Tests for {@link QueryLimitConfigurationValidator#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(() -> QueryLimitConfigurationValidator.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(() -> QueryLimitConfigurationValidator.validate(config)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Internal cache max size must be greater than 0"); + } + } + + /** + * Tests for {@link QueryLimitConfigurationValidator#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(() -> QueryLimitConfigurationValidator.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(() -> QueryLimitConfigurationValidator.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(() -> QueryLimitConfigurationValidator.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(() -> QueryLimitConfigurationValidator.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(() -> QueryLimitConfigurationValidator.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 QueryLimitConfigurationValidator#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(() -> QueryLimitConfigurationValidator.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(() -> QueryLimitConfigurationValidator.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(() -> QueryLimitConfigurationValidator.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 QueryLimitConfigurationValidator#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(() -> QueryLimitConfigurationValidator.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(() -> QueryLimitConfigurationValidator.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(() -> QueryLimitConfigurationValidator.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(() -> QueryLimitConfigurationValidator.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(() -> QueryLimitConfigurationValidator.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(() -> QueryLimitConfigurationValidator.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 6cfa34e1643..2bb786bfc40 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 = 100L; // 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..60befb9ef1f 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,16 +2,29 @@ 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; @@ -27,11 +40,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 = QueryLimitConfigReloaderTest.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,16 +84,14 @@ 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()); @@ -64,20 +103,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 { + QueryLimitConfigReloader reloader = new QueryLimitConfigReloader(); + reloader.setZookeeperConfig(server.getConnectString()); + reloader.setup(); + QueryLimiter limiter = new QueryLimiter(); limiter.setZookeeperConfig(server.getConnectString()); + limiter.setConfigReloader(reloader); - 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 +504,53 @@ 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 QueryLimitConfigReloader}, 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 { + QueryLimitConfigReloader reloader = new QueryLimitConfigReloader(); + reloader.setZookeeperConfig(server.getConnectString()); + reloader.setup(); + QueryLimiter limiter = new QueryLimiter(); limiter.setZookeeperConfig(server.getConnectString()); limiter.setConfiguration(config); limiter.setHeartbeatCache(heartbeatCache); + limiter.setConfigReloader(reloader); limiter.setup(); systemToLimiter.put(system, limiter); return limiter; @@ -503,4 +585,12 @@ private void assertLimitMet(String userDn, String system, String queryLogic, Str private void givenConfig(QueryLimitConfiguration config) { this.config = config; } + + private CuratorFramework createReloaderClient() { + CuratorFramework client = CuratorFrameworkFactory.builder().namespace(QueryLimitConfigReloader.ZOOKEEPER_NAMESPACE) + .connectString(server.getConnectString()).sessionTimeoutMs(60000).connectionTimeoutMs(60000).retryPolicy(new RetryNTimes(10, 1000)) + .build(); + 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/query/limit/ZookeeperUtilsTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/ZookeeperUtilsTest.java new file mode 100644 index 00000000000..c07647de280 --- /dev/null +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/ZookeeperUtilsTest.java @@ -0,0 +1,140 @@ +package datawave.webservice.query.limit; + +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 ZookeeperUtilsTest { + + @TempDir + File tempDir; + + /** + * Verify that when {@link ZookeeperUtils#getQuorumPeerConfig(String)} is given a non-filepath argument, the original argument is returned. + */ + @Test + void testGetQuorumPeerConfigGivenNonFilePath() throws QuorumPeerConfig.ConfigException { + assertEquals("localhost:2181", ZookeeperUtils.getQuorumPeerConfig("localhost:2181")); + } + + /** + * Verify that when {@link ZookeeperUtils#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", ZookeeperUtils.getQuorumPeerConfig("/i/do/not/exist/zookeeper.cfg")); + } + + /** + * Verify that when {@link ZookeeperUtils#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, () -> ZookeeperUtils.getQuorumPeerConfig(path)); + } + + /** + * Verify that when {@link ZookeeperUtils#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", ZookeeperUtils.getQuorumPeerConfig("file://" + path)); + } + + /** + * Verify that when {@link ZookeeperUtils#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", ZookeeperUtils.getQuorumPeerConfig(path)); + } + + /** + * Verify that when {@link ZookeeperUtils#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, ZookeeperUtils.getQuorumPeerConfig(path)); + } + + /** + * Verify that when {@link ZookeeperUtils#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", ZookeeperUtils.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/log4j.properties b/web-services/query/src/test/resources/log4j.properties index e60151be4e4..cc3e540f65d 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.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 + From 52b260385ee95f5bea54a4549d9a192fe7b77a38 Mon Sep 17 00:00:00 2001 From: Laura Schanno Date: Sat, 2 May 2026 05:39:36 -0400 Subject: [PATCH 02/13] Fix JavaDoc --- .../webservice/query/limit/QueryLimitConfigReloader.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java index 196c981ce8a..8d3c22ff259 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java @@ -497,7 +497,7 @@ private void triggerReload(ReloadCause cause) { *
    • 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.
    • - *
        + *
      * *
    * From 8b4e767951ba86f65c58ec8971385224063050fb Mon Sep 17 00:00:00 2001 From: Laura Schanno Date: Tue, 5 May 2026 16:37:54 -0400 Subject: [PATCH 03/13] Use constructors instead of builders for mappers --- .../webservice/query/limit/QueryLimitConfigReloader.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java index 8d3c22ff259..8ebd75d6121 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java @@ -71,17 +71,17 @@ public class QueryLimitConfigReloader implements AutoCloseable { /** * Mapper for JSON files. */ - private static final JsonMapper jsonMapper = JsonMapper.builder().build(); + private static final JsonMapper jsonMapper = new JsonMapper(); /** * Mapper for XML files. */ - private static final XmlMapper xmlMapper = XmlMapper.builder().build(); + private static final XmlMapper xmlMapper = new XmlMapper(); /** * Mapper for YAML files. */ - private static final YAMLMapper yamlMapper = YAMLMapper.builder().build(); + private static final YAMLMapper yamlMapper = new YAMLMapper(); /** * Map of format names to the mappers. From a119a5fc8845389cb6c5fc0f7b9d6b2f08288222 Mon Sep 17 00:00:00 2001 From: Laura Schanno Date: Wed, 6 May 2026 20:35:52 -0400 Subject: [PATCH 04/13] Rollback version of jackson-dataformat-xml --- pom.xml | 12 ++----- .../results/cached/CachedResultsBean.java | 1 + web-services/query/pom.xml | 4 +++ .../query/cache/QueryExpirationBean.java | 36 +++++-------------- .../query/limit/QueryLimitConfigReloader.java | 10 +++++- .../webservice/query/limit/QueryLimiter.java | 8 +++++ .../query/cache/QueryExpirationBeanTest.java | 14 ++++---- .../limit/QueryLimitConfigurationTest.java | 6 +++- .../query/limit/QueryLimiterTest.java | 2 ++ 9 files changed, 48 insertions(+), 45 deletions(-) diff --git a/pom.xml b/pom.xml index 18bc372aa11..0915e1179ba 100644 --- a/pom.xml +++ b/pom.xml @@ -151,6 +151,8 @@ 2.3.5.Final 17.0.1.Final + + 2.9.9 5.4.0 3.1.4 2.12.2 @@ -213,16 +215,6 @@ jackson-databind ${version.jackson} - - com.fasterxml.jackson.dataformat - jackson-dataformat-xml - ${version.jackson} - - - com.fasterxml.jackson.dataformat - jackson-dataformat-yaml - ${version.jackson} - com.fasterxml.jackson.datatype jackson-datatype-guava 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/query/pom.xml b/web-services/query/pom.xml index 577a68471ef..89d2e5fc3d9 100644 --- a/web-services/query/pom.xml +++ b/web-services/query/pom.xml @@ -9,14 +9,18 @@ datawave-ws-query ejb ${project.artifactId} + com.fasterxml.jackson.dataformat jackson-dataformat-xml + + ${version.wildfly.jackson} com.fasterxml.jackson.dataformat jackson-dataformat-yaml + ${version.jackson} com.google.code.gson 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/QueryLimitConfigReloader.java b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java index 8ebd75d6121..1f9bb243da3 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java @@ -40,6 +40,8 @@ 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; @@ -76,7 +78,8 @@ public class QueryLimitConfigReloader implements AutoCloseable { /** * Mapper for XML files. */ - private static final XmlMapper xmlMapper = new XmlMapper(); + // 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. @@ -273,6 +276,10 @@ public void setHdfsConfigUrls(String hdfsConfigUrls) { * cleanup interval. */ public void setup() throws QuorumPeerConfig.ConfigException { + if (log.isDebugEnabled()) { + log.debug("Initializing with zookeeperConfig: " + this.zookeeperConfig + " and hdfsConfigUrls: " + hdfsConfigUrls); + } + // If the zookeeper config points to a file, extract the hosts from it. this.zookeeperConfig = ZookeeperUtils.getQuorumPeerConfig(this.zookeeperConfig); @@ -313,6 +320,7 @@ public void setup() throws QuorumPeerConfig.ConfigException { } public void shutdown() { + log.debug("Shutting down"); close(); } 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 7386f41933b..22a1a3afc10 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 @@ -224,6 +224,11 @@ public void setup() { configLock.lock(); try { + // Require the heartbeat cache to be set. + if (heartbeatCache == null) { + throw new IllegalStateException("No heartbeat cache set"); + } + // If no configuration was supplied from a configured bean, attempt to load a configuration from Zookeeper. if (this.configuration == null) { if (this.configReloader != null) { @@ -255,6 +260,8 @@ 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(); @@ -375,6 +382,7 @@ public void countQueryTowardsLimits(String queryId, String userDn, String system * @return the set of IDs for active queries */ public Set getActiveQueries() { + log.debug("heartbeatCache == null ? " + (heartbeatCache == null)); return heartbeatCache.getQueryIds(); } 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/QueryLimitConfigurationTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationTest.java index efec38c30b5..589397fdce1 100644 --- 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 @@ -221,7 +221,11 @@ void setUp() { // 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 = YAMLMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + private static final YAMLMapper yamlMapper = new YAMLMapper(); + + static { + yamlMapper.enable(SerializationFeature.INDENT_OUTPUT); + } @BeforeAll static void beforeAll() { 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 60befb9ef1f..3fd105f9546 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 @@ -94,6 +94,7 @@ void tearDown() throws IOException { void testConfigurationFailsValidation() { QueryLimiter limiter = new QueryLimiter(); limiter.setZookeeperConfig(server.getConnectString()); + limiter.setHeartbeatCache(heartbeatCache); QueryLimitConfiguration config = new QueryLimitConfiguration(); config.setDefaultUserQueryLimit(0); @@ -114,6 +115,7 @@ void testInitialConfigurationSuppliedByZookeeper() throws Exception { QueryLimiter limiter = new QueryLimiter(); limiter.setZookeeperConfig(server.getConnectString()); limiter.setConfigReloader(reloader); + limiter.setHeartbeatCache(heartbeatCache); try (CuratorFramework client = createReloaderClient()) { client.create().forPath("/path", validJsonFile.getBytes()); From 78a2143f453d8023cf1bfedf4f556896fd190e4e Mon Sep 17 00:00:00 2001 From: Laura Schanno Date: Thu, 7 May 2026 20:24:44 -0400 Subject: [PATCH 05/13] Roll back version of jackson-dataformat-yaml --- web-services/deploy/application/pom.xml | 10 ++++++ web-services/query/pom.xml | 5 +-- .../query/limit/LockedZkClientDispatcher.java | 8 ++--- .../query/limit/QueryLimitConfigReloader.java | 33 +++++++++++++------ .../webservice/query/limit/QueryLimiter.java | 25 ++++++++------ .../limit/QueryLogicGroupLimitProvider.java | 18 ++++++---- .../query/limit/SystemLimitProvider.java | 14 ++++---- .../query/limit/UserLimitProvider.java | 5 +-- 8 files changed, 74 insertions(+), 44 deletions(-) diff --git a/web-services/deploy/application/pom.xml b/web-services/deploy/application/pom.xml index 96b637ba46e..d59ccfdbb3c 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/query/pom.xml b/web-services/query/pom.xml index 89d2e5fc3d9..e5b9bbb8145 100644 --- a/web-services/query/pom.xml +++ b/web-services/query/pom.xml @@ -14,13 +14,14 @@ com.fasterxml.jackson.dataformat jackson-dataformat-xml - + ${version.wildfly.jackson} com.fasterxml.jackson.dataformat jackson-dataformat-yaml - ${version.jackson} + + ${version.wildfly.jackson} com.google.code.gson diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/LockedZkClientDispatcher.java b/web-services/query/src/main/java/datawave/webservice/query/limit/LockedZkClientDispatcher.java index 8775a9092f2..23d3794e3f1 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/LockedZkClientDispatcher.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/LockedZkClientDispatcher.java @@ -155,9 +155,7 @@ private void cleanupClient() { try { client.close(); } catch (Exception e) { - if (log.isWarnEnabled()) { - log.warn("Failed to close client", e); - } + log.warn("Failed to close client", e); } finally { client = null; } @@ -178,9 +176,7 @@ private void cleanupClientAndExecutor() { try { executor.shutdown(); } catch (Exception e) { - if (log.isWarnEnabled()) { - log.warn("Failed to shutdown executor", e); - } + log.warn("Failed to shutdown executor", e); } executor = null; } diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java index 1f9bb243da3..99f3ace25b1 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java @@ -17,9 +17,11 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; @@ -450,7 +452,7 @@ public boolean areCachesInitialized() { */ private void triggerReload(ReloadCause cause) { if (log.isDebugEnabled()) { - log.debug("Configuration reload triggered"); + log.debug("Configuration reload triggered due to cause " + cause); } // Obtain the reload lock. @@ -460,6 +462,10 @@ private void triggerReload(ReloadCause cause) { // Attempt to load the configuration from the path node. LoadResult result = loadConfiguration(); + if (log.isDebugEnabled()) { + log.debug("Received reload result status=" + result.getStatus() + ", errors=" + result.getErrorMessages()); + } + // If we successfully loaded a valid configuration, pass it to any listeners registered with this loader. if (result.status == ReloadStatus.SUCCESS) { if (!listeners.isEmpty()) { @@ -644,6 +650,9 @@ public LoadResult loadConfiguration() { try { // Deserialize the configuration using the associated mapper for the format. config = formatToMapper.get(factory.getFormatName()).readValue(contents, QueryLimitConfiguration.class); + if (log.isDebugEnabled()) { + log.debug("Deserialized config: " + config); + } } catch (Exception e) { log.error("Failed to deserialize file " + path + " to a " + QueryLimitConfiguration.class.getName(), e); return LoadResult.reloadError("Failed to deserialize file to a " + QueryLimitConfiguration.class.getSimpleName()); @@ -787,32 +796,35 @@ public void cleanup() { try { pathCache.close(); } catch (Exception e) { - log.error("Failed to close path cache", e); + log.warn("Failed to close path cache", e); + } finally { + pathCache = null; } - pathCache = null; } if (triggerCache != null) { try { triggerCache.close(); } catch (Exception e) { - log.error("Failed to close trigger cache", e); + log.warn("Failed to close trigger cache", e); + } finally { + triggerCache = null; } - triggerCache = null; } if (executor != null) { try { executor.shutdown(); } catch (Exception e) { - log.error("Failed to close executor", e); + log.warn("Failed to close executor", e); + } finally { + executor = null; } - executor = null; } if (listeners != null) { try { listeners.clear(); } catch (Exception e) { - log.error("Failed to clear listeners", e); + log.warn("Failed to clear listeners", e); } finally { listeners = null; } @@ -822,9 +834,10 @@ public void cleanup() { try { clientDispatcher.close(); } catch (Exception e) { - log.error("Failed to close client dispatcher", e); + log.warn("Failed to close client dispatcher", e); + } finally { + clientDispatcher = null; } - clientDispatcher = null; } } 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 22a1a3afc10..59b1c1d55fd 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 @@ -115,7 +115,7 @@ private void updateConfiguration(QueryLimitConfiguration configuration, boolean try { this.queryLogicGroupLimitProvider.cleanUp(); } catch (Exception e) { - log.warn("Failed to clean up query logic group limit provider"); + 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; @@ -128,7 +128,7 @@ private void updateConfiguration(QueryLimitConfiguration configuration, boolean try { this.userLimitProvider.cleanUp(); } catch (Exception e) { - log.warn("Failed to clean up user limit provider"); + 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; @@ -141,7 +141,7 @@ private void updateConfiguration(QueryLimitConfiguration configuration, boolean try { this.systemLimitProvider.cleanUp(); } catch (Exception e) { - log.warn("Failed to clean up system limit provider"); + 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; @@ -250,6 +250,9 @@ public void setup() { // the reloader will already be validated. if (configReloader != null) { configReloader.addListener(((config) -> updateConfiguration(config, false))); + log.debug("QueryLimiter now listening for configuration updates"); + } else { + log.warn("No config reloader set for QueryLimiter, limiter will not be notified of configuration updates"); } } finally { configLock.unlock(); @@ -266,25 +269,28 @@ public void shutdown() { 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; } - 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; } - this.activeQueryTracker = null; } if (this.configReloader != null) { try { this.configReloader.close(); } catch (Exception e) { - log.error("Error closing config reloader", e); + log.warn("Error closing config reloader", e); + } finally { + this.configReloader = null; } - this.configReloader = null; } } @@ -382,7 +388,6 @@ public void countQueryTowardsLimits(String queryId, String userDn, String system * @return the set of IDs for active queries */ public Set getActiveQueries() { - log.debug("heartbeatCache == null ? " + (heartbeatCache == null)); return heartbeatCache.getQueryIds(); } 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 2602dc951fa..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 @@ -10,11 +10,15 @@ import java.util.TreeSet; import java.util.stream.Collectors; +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(); @@ -113,13 +117,15 @@ public Map getGroupMatchers(Set groups) { * Clean up this {@link QueryLogicGroupLimitProvider} and release its underlying resources. */ public void cleanUp() { - if (groupsToLimits != null) { - groupsToLimits.clear(); - groupsToLimits = null; - } + groupsToLimits = null; if (groupLimitCache != null) { - groupLimitCache.cleanUp(); - groupLimitCache = null; + try { + groupLimitCache.cleanUp(); + } catch (Exception e) { + log.warn("Failed to clear groupLimitCache", e); + } finally { + groupLimitCache = null; + } } } 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 16d9dcb5310..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 @@ -154,13 +154,15 @@ public boolean countsAgainstUserLimit(String system) { */ public void cleanUp() { if (systemLimitCache != null) { - systemLimitCache.invalidateAll(); - systemLimitCache = null; - } - if (sortedSystemLimits != null) { - sortedSystemLimits.clear(); - sortedSystemLimits = null; + try { + systemLimitCache.invalidateAll(); + } catch (Exception e) { + log.error("Failed to clear systemLimitCache", e); + } finally { + systemLimitCache = null; + } } + sortedSystemLimits = null; } /** 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 6ea94bafe06..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 @@ -87,9 +87,6 @@ public UserLimits getCustomLimits(String userDn) { * Clean up this {@link UserLimitProvider} and release its underlying resources. */ public void cleanUp() { - if (customLimits != null) { - customLimits.clear(); - customLimits = null; - } + customLimits = null; } } From 1611b453d1359b7f13f92217d0fc497f73e5b145 Mon Sep 17 00:00:00 2001 From: Laura Schanno Date: Thu, 7 May 2026 20:28:40 -0400 Subject: [PATCH 06/13] Remove unused imports --- .../webservice/query/limit/QueryLimitConfigReloader.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java index 99f3ace25b1..6f27761680b 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java +++ b/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java @@ -17,11 +17,9 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; From fd374dc47fa7ad9414bc41286e2df3ed8f47a9b6 Mon Sep 17 00:00:00 2001 From: Laura Schanno Date: Mon, 11 May 2026 21:14:12 -0400 Subject: [PATCH 07/13] Separate ZkObjectPublisher from query limit API --- .../datawave/query/QueryLimiterFactory.xml | 26 +- .../query/limit/ActiveQueryTracker.java | 9 +- ...ueryLimitConfigurationValidationUtils.java | 230 +++++++ .../QueryLimitConfigurationValidator.java | 230 +------ .../webservice/query/limit/QueryLimiter.java | 50 +- .../datawave/webservice/query/limit/README.md | 29 +- .../LockedZkClientDispatcher.java | 2 +- .../webservice/zookeeper/ObjectValidator.java | 16 + .../datawave/webservice/zookeeper/README.md | 36 + .../zookeeper/ZkObjectPublishCause.java | 24 + .../zookeeper/ZkObjectPublishError.java | 34 + .../zookeeper/ZkObjectPublishResult.java | 79 +++ .../zookeeper/ZkObjectPublishStatus.java | 18 + .../ZkObjectPublisher.java} | 631 ++++++++---------- .../ZkUtils.java} | 10 +- ...imitConfigurationValidationUtilsTest.java} | 62 +- .../query/limit/QueryLimiterTest.java | 34 +- .../LockedZkClientDispatcherTest.java | 2 +- .../ZkObjectPublisherSpringTest.java | 39 ++ .../ZkObjectPublisherTest.java} | 326 +++++---- .../ZkUtilsTest.java} | 38 +- .../TestZkObjectPublisherFactory.xml | 22 + .../query/src/test/resources/log4j.properties | 2 +- 23 files changed, 1098 insertions(+), 851 deletions(-) create mode 100644 web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigurationValidationUtils.java rename web-services/query/src/main/java/datawave/webservice/{query/limit => zookeeper}/LockedZkClientDispatcher.java (99%) create mode 100644 web-services/query/src/main/java/datawave/webservice/zookeeper/ObjectValidator.java create mode 100644 web-services/query/src/main/java/datawave/webservice/zookeeper/README.md create mode 100644 web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishCause.java create mode 100644 web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishError.java create mode 100644 web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishResult.java create mode 100644 web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublishStatus.java rename web-services/query/src/main/java/datawave/webservice/{query/limit/QueryLimitConfigReloader.java => zookeeper/ZkObjectPublisher.java} (51%) rename web-services/query/src/main/java/datawave/webservice/{query/limit/ZookeeperUtils.java => zookeeper/ZkUtils.java} (89%) rename web-services/query/src/test/java/datawave/webservice/query/limit/{QueryLimitConfigurationValidatorTest.java => QueryLimitConfigurationValidationUtilsTest.java} (75%) rename web-services/query/src/test/java/datawave/webservice/{query/limit => zookeeper}/LockedZkClientDispatcherTest.java (99%) create mode 100644 web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherSpringTest.java rename web-services/query/src/test/java/datawave/webservice/{query/limit/QueryLimitConfigReloaderTest.java => zookeeper/ZkObjectPublisherTest.java} (67%) rename web-services/query/src/test/java/datawave/webservice/{query/limit/ZookeeperUtilsTest.java => zookeeper/ZkUtilsTest.java} (69%) create mode 100644 web-services/query/src/test/resources/TestZkObjectPublisherFactory.xml 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 1e5e5ab78fc..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,15 +146,29 @@ - - - - + + + + + + + + + + + - + 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 56b88cfaf43..e8212f6f75a 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,5 +1,7 @@ package datawave.webservice.query.limit; +import static datawave.webservice.zookeeper.ZkUtils.EMPTY_DATA; + import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -17,6 +19,9 @@ import org.apache.zookeeper.data.Stat; 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. 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. @@ -27,8 +32,6 @@ 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"; @@ -47,7 +50,7 @@ public class ActiveQueryTracker implements AutoCloseable { * if an error occurs when verifying the zookeeper configuration */ public ActiveQueryTracker(String zookeeperConfig, long clientCleanupInterval) throws ConfigException { - zookeeperConfig = ZookeeperUtils.getQuorumPeerConfig(zookeeperConfig); + zookeeperConfig = ZkUtils.getQuorumPeerConfig(zookeeperConfig); // @formatter:off clientFactory = CuratorFrameworkFactory.builder() .namespace(ZOOKEEPER_NAMESPACE) 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 index 1f31eacfc73..f714103f56d 100644 --- 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 @@ -1,230 +1,14 @@ 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 com.google.common.base.Preconditions; -import org.apache.commons.lang3.StringUtils; +import datawave.webservice.zookeeper.ObjectValidator; -public final class QueryLimitConfigurationValidator { +public class QueryLimitConfigurationValidator implements ObjectValidator { - /** - * 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 QueryLimitConfigurationValidator() { - throw new UnsupportedOperationException(); + @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 59b1c1d55fd..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 @@ -13,6 +13,10 @@ 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. It is expected that only a singleton instance of {@link QueryLimiter} will be created via CDI. @@ -48,8 +52,8 @@ public class QueryLimiter { // The tracker responsible for interfacing with Zookeeper. private ActiveQueryTracker activeQueryTracker; - // The config reloader responsible for notifying the query limiter when there are updates to the configuration. - private QueryLimitConfigReloader configReloader; + // 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; @@ -74,13 +78,13 @@ public void setZookeeperConfig(String zookeeperConfig) { } /** - * Set the config reloader that will notify this {@link QueryLimiter} of configuration updates. + * Set the config publisher that will notify this {@link QueryLimiter} of configuration updates. * - * @param configReloader - * the configuration reloader + * @param configPublisher + * the configuration publisher */ - public void setConfigReloader(QueryLimitConfigReloader configReloader) { - this.configReloader = configReloader; + public void setConfigPublisher(ZkObjectPublisher configPublisher) { + this.configPublisher = configPublisher; } /** @@ -99,7 +103,7 @@ private void updateConfiguration(QueryLimitConfiguration configuration, boolean try { // If validation is required, do so. if (validationRequired) { - QueryLimitConfigurationValidator.validate(configuration); + QueryLimitConfigurationValidationUtils.validate(configuration); } if (log.isDebugEnabled()) { @@ -231,15 +235,17 @@ public void setup() { // If no configuration was supplied from a configured bean, attempt to load a configuration from Zookeeper. if (this.configuration == null) { - if (this.configReloader != null) { - QueryLimitConfigReloader.LoadResult loadResult = configReloader.loadConfiguration(); - if (loadResult.getStatus() == QueryLimitConfigReloader.ReloadStatus.SUCCESS) { + 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(loadResult.getConfig(), false); + updateConfiguration((QueryLimitConfiguration) result.getUpdatedObject(), false); + } else { + log.error("Failed to load configuration from zookeeper: " + result); } } - if (this.configReloader == null) { - throw new IllegalStateException("No configuration supplied for Query Limiter via injection or Zookeeper."); + 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. @@ -248,11 +254,11 @@ public void setup() { // 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 (configReloader != null) { - configReloader.addListener(((config) -> updateConfiguration(config, false))); - log.debug("QueryLimiter now listening for configuration updates"); + 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 reloader set for QueryLimiter, limiter will not be notified of configuration updates"); + log.warn("No config publisher set for QueryLimiter, limiter will not be notified of configuration updates"); } } finally { configLock.unlock(); @@ -283,13 +289,13 @@ public void shutdown() { this.activeQueryTracker = null; } } - if (this.configReloader != null) { + if (this.configPublisher != null) { try { - this.configReloader.close(); + this.configPublisher.close(); } catch (Exception e) { - log.warn("Error closing config reloader", e); + log.warn("Error closing config publisher", e); } finally { - this.configReloader = null; + this.configPublisher = null; } } } 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 f43e48825d2..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 @@ -49,34 +49,7 @@ When using regex patterns in the configurations above, there is the possibility ## Dynamic Configuration Updates -The configuration for the `QueryLimiter` may be updated dynamically through Zookeeper. When the `QueryLimiter` is configured with a [QueryLimitConfigReloader](QueryLimitConfigReloader.java), it will register a listener with the reloader. When the reloader receives a triggering event, it will attempt to load a new `QueryLimitConfiguration` from a file who's filepath is specified in Zookeeper, and provide the configuration (if valid) to any listeners. - -The `QueryLimitConfigReloader` will operate on nodes in Zookeeper under the namespace `QueryLimitConfig`. When a reload is triggered, it will load a new `QueryLimitConfigration` from the file URL in the data of the node `/path`. A reload will be triggered if any of the following events happen: -- The node `/path` is created with non-empty data, or modified with new non-empty data. -- The node `/trigger` is created, modified, or deleted. - -The data of the node `/path` should be set to the file URL of a JSON, XML, or YAML file that can be deserialized to a `QueryLimitConfiguration`. The URL may have the URI schemes `http:`, `https:`,`hdfs:`, or `file:`. In the case of no scheme, the file will be loaded from the local filesystem. After a reload attempt, the following nodes will be created/updated: - -``` -/attempts//status # The status of the latest reload attempt. -/attempts//cause # The triggering cause of the latest reload attempt. -/attempts//time # The time of the latest reload attempt in ISO-8601 format. -/attempts//errors # A node containing children whose data contains brief descriptions about errors that occurred. Exists only when an error occurred. -``` - -The data of the node `/attempts//status` will be one of the following: -- `SUCCESS`: Indicates a valid `QueryLimitConfiguration` was loaded from the file and supplied to all configured listeners. -- `LISTENER_ERROR`: Indicates a valid `QueryLimitConfiguration` was loaded from the file, but one or more listeners threw an exception when provided the configuration. -- `RELOAD_ERROR`: Indicates a valid `QueryLimitConfiguration` could not be loaded. - -The data of the node `/attempts//cause` will be one of the following: -- `PATH_NODE_CREATED`: The reload was triggered by the creation of the node `/path` with non-empty data. -- `PATH_NODE_MODIFIED`: The reload was triggered by the modification of the node `/path` with non-empty data. -- `TRIGGER_NODE_CREATED`: The reload was triggered by the creation of the node `/trigger`. -- `TRIGGER_NODE_MODIFIED`: The reload was triggered by the modification of the node `/trigger`. -- `TRIGGER_NODE_DELETED`: The reload was triggered by the deletion of the node `/trigger`. - -The node `/attempts//errors` will contain children with names following the format `/error_` where X is a value from `0` to one less than the total errors that were recorded. The data of each error node will contain brief descriptions of the error that occurred. The full stack trace will be available in the logs. +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 diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/LockedZkClientDispatcher.java b/web-services/query/src/main/java/datawave/webservice/zookeeper/LockedZkClientDispatcher.java similarity index 99% rename from web-services/query/src/main/java/datawave/webservice/query/limit/LockedZkClientDispatcher.java rename to web-services/query/src/main/java/datawave/webservice/zookeeper/LockedZkClientDispatcher.java index 23d3794e3f1..16686b5a0d4 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/LockedZkClientDispatcher.java +++ b/web-services/query/src/main/java/datawave/webservice/zookeeper/LockedZkClientDispatcher.java @@ -1,4 +1,4 @@ -package datawave.webservice.query.limit; +package datawave.webservice.zookeeper; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; 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..d5965ec11eb --- /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 be XML, JSON, or YAML, 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/query/limit/QueryLimitConfigReloader.java b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublisher.java similarity index 51% rename from web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java rename to web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublisher.java index 6f27761680b..ec75c79e85c 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/QueryLimitConfigReloader.java +++ b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublisher.java @@ -1,13 +1,10 @@ -package datawave.webservice.query.limit; - -import static org.apache.commons.lang.StringUtils.split; +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.net.UnknownHostException; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; @@ -27,6 +24,7 @@ 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; @@ -36,7 +34,6 @@ import org.apache.hadoop.fs.FileSystem; import org.apache.log4j.Logger; import org.apache.zookeeper.data.Stat; -import org.apache.zookeeper.server.quorum.QuorumPeerConfig; import org.awaitility.Awaitility; import org.awaitility.core.ConditionTimeoutException; @@ -49,26 +46,65 @@ 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; /** - * This class provides functionality for leveraging Zookeeper to watch for changes that should trigger a reload of a {@link QueryLimitConfiguration} and provide - * it to listeners. It is expected that only a singleton {@link QueryLimitConfigReloader} will exist to be injected where needed. The Zookeeper logic here - * requires that only a singleton {@link QueryLimitConfigReloader} is used on each server. + * 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 QueryLimitConfigReloader implements AutoCloseable { - - public static final String ZOOKEEPER_NAMESPACE = "QueryLimitConfig"; +public class ZkObjectPublisher { - private static final Logger log = Logger.getLogger(QueryLimitConfigReloader.class); + private static final Logger log = Logger.getLogger(ZkObjectPublisher.class); - private static final String NODE_PATH = "/path"; - private static final String NODE_TRIGGER = "/trigger"; - private static final String NODE_ATTEMPTS = "/attempts"; - private static final String NODE_CAUSE = "/cause"; - private static final String NODE_STATUS = "/status"; - private static final String NODE_ERRORS = "/errors"; - private static final String NODE_ERROR_BASE = "/error_"; - private static final String NODE_TIME = "/time"; + 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. @@ -91,9 +127,9 @@ public class QueryLimitConfigReloader implements AutoCloseable { */ // @formatter:off private static final Map formatToMapper = Map.of( - jsonMapper.getFactory().getFormatName(), jsonMapper, - xmlMapper.getFactory().getFormatName(), xmlMapper, - yamlMapper.getFactory().getFormatName(), yamlMapper + jsonMapper.getFactory().getFormatName(), jsonMapper, + xmlMapper.getFactory().getFormatName(), xmlMapper, + yamlMapper.getFactory().getFormatName(), yamlMapper ); // @formatter:on @@ -103,225 +139,142 @@ public class QueryLimitConfigReloader implements AutoCloseable { private static final DataFormatDetector formatDetector = new DataFormatDetector(jsonMapper.getFactory(), xmlMapper.getFactory(), yamlMapper.getFactory()); /** - * The list of listeners that should be supplied with new {@link QueryLimitConfiguration} after successful reloads. - */ - private List> listeners = 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(ReloadCause)} 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. + * The finalized path for the node {@code /attempts/}. */ - // @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 + private final String baseAttemptNode; /** - * The client dispatcher. + * The finalized path for the node {@code /attempts//cause}. */ - private LockedZkClientDispatcher clientDispatcher; + private final String attemptCauseNode; /** - * The finalized path for the node {@code /attempts/}. + * The finalized path for the node {@code /attempts//status}. */ - private String baseAttemptNode; + private final String attemptStatusNode; /** - * The finalized path for the node {@code /attempts//cause}. + * The finalized path for the node {@code /attempts//errors}. */ - private String attemptCauseNode; + private final String attemptErrorsNode; /** - * The finalized path for the node {@code /attempts//status}. + * The finalized path for the node {@code /attempts//time}. */ - private String attemptStatusNode; + private final String attemptTimeNode; - /** - * The finalized path for the node {@code /attempts//errors}. - */ - private String attemptErrorsNode; + private final String namespace; + private final Configuration hadoopConfig; + private final Class objectClass; + private final List objectValidators; /** - * The finalized path for the node {@code /attempts//time}. + * The list of subscribers that should be supplied with new objects after successful reloads. */ - private String attemptTimeNode; + private List> subscribers = new CopyOnWriteArrayList<>(); /** - * The configuration to use when connecting to HDFS. + * A {@link CuratorCache} that will listen for creates and modifications of the node {@code /path}. */ - private Configuration hadoopConfig; + private CuratorCache pathCache; /** - * The zookeeper config. + * A {@link CuratorCache} that will listen for creates, modifications, and deletions of the node {@code /trigger} */ - private String zookeeperConfig; + private CuratorCache triggerCache; /** - * The HDFS site config URLs. + * A boolean that will be set to true when {@link #pathCache} is initialized. */ - private String hdfsConfigUrls; + private final AtomicBoolean pathCacheInitialized = new AtomicBoolean(false); /** - * Indicates whether a reload attempt succeeded for failed. + * A boolean that will be set to true when {@link #triggerCache} is initialized. */ - public enum ReloadStatus { - /** - * Indicates a reload attempt was successful. - */ - SUCCESS, - /** - * Indicates a new {@link QueryLimitConfigReloader} could not be loaded from Zookeeper. - */ - RELOAD_ERROR, - /** - * Indicates a new {@link QueryLimitConfigReloader} was successfully loaded from Zookeeper, but an error occurred when supplying it to a listener. - */ - LISTENER_ERROR - } - - /** - * Indicates the triggering event that launched a new reload attempt. - */ - public enum ReloadCause { - /** - * Indicates the triggering event was the creation of the node {@value #NODE_PATH} with non-empty data. - */ - PATH_NODE_CREATED, - /** - * Indicates the triggering event was the modification of the node {@value #NODE_PATH} with non-empty data. - */ - PATH_NODE_MODIFIED, - /** - * Indicates the triggering event was the creation of the node {@value #NODE_TRIGGER}. - */ - TRIGGER_NODE_CREATED, - /** - * Indicates the triggering event was the modification of the node {@value #NODE_TRIGGER}. - */ - TRIGGER_NODE_MODIFIED, - /** - * Indicates the triggering event was the deletion of the node {@value #NODE_TRIGGER}. - */ - TRIGGER_NODE_DELETED - } + private final AtomicBoolean triggerCacheInitialized = new AtomicBoolean(false); /** - * Return the zookeeper configs. - * - * @return the zookeeper config + * The lock that must be obtained by any task calling {@link #triggerReload(ZkObjectPublishCause)} in order to perform a reload. */ - public String getZookeeperConfig() { - return zookeeperConfig; - } + private final Lock reloadLock = new ReentrantLock(); /** - * Set the zookeeper configs. This can be a comma-delimited list of zookeeper hosts or a path to a local zookeeper config file. + * 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. */ - public void setZookeeperConfig(String zookeeperConfig) { - this.zookeeperConfig = zookeeperConfig; - } + // @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 /** - * Return the HDFS site config URLs. - * - * @return the URLs + * The client dispatcher. */ - public String getHdfsConfigUrls() { - return hdfsConfigUrls; - } + private LockedZkClientDispatcher clientDispatcher; - /** - * Set a comma-delimited list of HDFS configuration files. - * - * @param hdfsConfigUrls - * the URLs - */ - public void setHdfsConfigUrls(String hdfsConfigUrls) { - this.hdfsConfigUrls = hdfsConfigUrls; - } + 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"); - /** - * Create the underlying {@link CuratorCache} caches that will watch for changes to the nodes {@code /path} and {@code /trigger}. These caches require some - * backend initialization before they can start listening for node changes. Use {@link QueryLimitConfigReloader#awaitCacheInitialization(long, TimeUnit)} to - * await the cache initialization. For testing purposes, this method should be called after setting the zookeeper configs, hdfs site config URLs, and client - * cleanup interval. - */ - public void setup() throws QuorumPeerConfig.ConfigException { if (log.isDebugEnabled()) { - log.debug("Initializing with zookeeperConfig: " + this.zookeeperConfig + " and hdfsConfigUrls: " + hdfsConfigUrls); + log.debug(addLogPrefix("Initializing with namespace=" + namespace + ", zookeeperConfig=" + zookeeperConfig + ", hdfsConfigUrls=" + hdfsConfigUrls + + ", " + "objectClass=" + objectClass.getName() + ", pojoValidators=" + objectValidators)); } - // If the zookeeper config points to a file, extract the hosts from it. - this.zookeeperConfig = ZookeeperUtils.getQuorumPeerConfig(this.zookeeperConfig); + this.namespace = namespace.trim(); + this.objectClass = objectClass; + this.objectValidators = objectValidators == null ? List.of() : List.copyOf(objectValidators); - // @formatter:off - CuratorFrameworkFactory.Builder clientFactory = CuratorFrameworkFactory.builder() - .namespace(ZOOKEEPER_NAMESPACE) - .connectString(zookeeperConfig) - .sessionTimeoutMs(60000) - .connectionTimeoutMs(60000) - .retryPolicy(new RetryNTimes(10, 1000)); - // @formatter:on + 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); + } - clientDispatcher = new LockedZkClientDispatcher(clientFactory, 120000, 120000, TimeUnit.MILLISECONDS); - this.pathCache = createCache(NODE_PATH, clientFactory, () -> createPathCacheListener(pathCacheInitialized)); - this.triggerCache = createCache(NODE_TRIGGER, clientFactory, () -> createTriggerCacheListener(triggerCacheInitialized)); try { + // Load any provided hadoop configurations. this.hadoopConfig = new Configuration(); if (hdfsConfigUrls != null && !hdfsConfigUrls.isBlank()) { - for (String url : split(hdfsConfigUrls, ',')) { + for (String url : StringUtils.split(hdfsConfigUrls, ",")) { hadoopConfig.addResource(new URL(url)); } } } catch (Exception e) { - throw new RuntimeException("Failed to load hadoop configuration from URLs '" + hdfsConfigUrls + "'", 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 { - String serverIpAddress = InetAddress.getLocalHost().getHostAddress(); - baseAttemptNode = NODE_ATTEMPTS + "/" + serverIpAddress; - attemptCauseNode = baseAttemptNode + NODE_CAUSE; - attemptStatusNode = baseAttemptNode + NODE_STATUS; - attemptErrorsNode = baseAttemptNode + NODE_ERRORS; - attemptTimeNode = baseAttemptNode + NODE_TIME; - } catch (UnknownHostException e) { - throw new RuntimeException("Failed to get local host address", e); + 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 - public void shutdown() { - log.debug("Shutting down"); - close(); + clientDispatcher = new LockedZkClientDispatcher(clientFactory, 120000, 120000, TimeUnit.MILLISECONDS); + this.pathCache = createCache(NODE_PATH, clientFactory, () -> createPathCacheListener(pathCacheInitialized)); + this.triggerCache = createCache(NODE_TRIGGER, clientFactory, () -> createTriggerCacheListener(triggerCacheInitialized)); } /** @@ -346,15 +299,15 @@ private CuratorCache createCache(String node, CuratorFrameworkFactory.Builder cl cache.start(); return cache; } catch (Exception e) { - log.error("Failed to create curator cache for path node " + node, e); - throw new RuntimeException("Failed to create curator cache for path " + node, 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. + * 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 @@ -368,20 +321,14 @@ private CuratorCacheListener createPathCacheListener(AtomicBoolean initFlag) { byte[] data = node.getData(); // Only trigger a reload attempt if the data is not empty. if (data != null && data.length > 0) { - if(log.isDebugEnabled()) { - log.debug("Triggering reload due to creation of node " + NODE_PATH + " with non-empty data at time " + System.currentTimeMillis()); - } - executor.submit(()-> triggerReload(ReloadCause.PATH_NODE_CREATED)); + 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) { - if(log.isDebugEnabled()){ - log.debug("Triggering reload due to modification of node " + NODE_PATH + " with non-empty data"); - } - executor.submit(()-> triggerReload(ReloadCause.PATH_NODE_MODIFIED)); + executor.submit(()-> triggerReload(ZkObjectPublishCause.PATH_NODE_MODIFIED)); } }).build(); @@ -400,24 +347,9 @@ private CuratorCacheListener createTriggerCacheListener(AtomicBoolean initFlag) 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) -> { - if(log.isDebugEnabled()){ - log.debug("Trigger reload due to creation of node " + NODE_TRIGGER ); - } - executor.submit(()-> triggerReload(ReloadCause.TRIGGER_NODE_CREATED)); - }) - .forChanges((oldNode, newNode) -> { - if(log.isDebugEnabled()){ - log.debug("Triggering reload due to modification of node " + NODE_TRIGGER); - } - executor.submit(() -> triggerReload(ReloadCause.TRIGGER_NODE_MODIFIED)); - }) - .forDeletes((node) -> { - if(log.isDebugEnabled()){ - log.debug("Triggering reload due to deletion of node " + NODE_TRIGGER); - } - executor.submit(() -> triggerReload(ReloadCause.TRIGGER_NODE_DELETED)); - }) + .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 } @@ -446,62 +378,65 @@ public boolean areCachesInitialized() { } /** - * Trigger a configuration reload. If a valid configuration is loaded + * Trigger a POJO reload. If a POJO is reloaded, it will be provided to any listeners configured for this {@link ZkObjectPublisher}. */ - private void triggerReload(ReloadCause cause) { + private void triggerReload(ZkObjectPublishCause trigger) { if (log.isDebugEnabled()) { - log.debug("Configuration reload triggered due to cause " + cause); + log.debug(addLogPrefix("Reload triggered by " + trigger)); } // Obtain the reload lock. reloadLock.lock(); try { Instant attemptTime = Instant.now(); - // Attempt to load the configuration from the path node. - LoadResult result = loadConfiguration(); - - if (log.isDebugEnabled()) { - log.debug("Received reload result status=" + result.getStatus() + ", errors=" + result.getErrorMessages()); - } - - // If we successfully loaded a valid configuration, pass it to any listeners registered with this loader. - if (result.status == ReloadStatus.SUCCESS) { - if (!listeners.isEmpty()) { - for (Consumer listener : listeners) { + // 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 { - listener.accept(result.config); + subscriber.accept(result.getUpdatedObject()); } catch (Exception e) { - // If an exception is thrown by a listener, log it and record it in the status. - log.warn("Exception thrown by listener " + listener, e); - result.setStatus(ReloadStatus.LISTENER_ERROR); - result.addErrorMessage("Exception thrown by listener: " + e.getMessage()); + // 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("Supplied configuration update to all listeners"); + log.debug(addLogPrefix("Supplied object update to all subscribers")); + } + if (!subscriberExceptions.isEmpty()) { + result = ZkObjectPublishResult.subscriberErrors(result.getTime(), result.getUpdatedObject(), subscriberExceptions); } } else { - log.debug("No listeners registered to be supplied configuration updates"); + 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=" + trigger + ", " + + "status=" + result.getStatus() + ", errors=" + result.getErrors())); + } + // Update the attempt nodes for the latest attempt. - updateAttemptNodes(cause, result.getStatus(), result.getErrorMessages(), attemptTime); + updateAttemptNodes(trigger, result.getStatus(), result.getErrors(), attemptTime); } catch (Exception e) { - log.error("Failed to reload configuration", e); - throw new RuntimeException("Failed to reload configuration", e); + log.error(addLogPrefix("Failed to reload object"), e); + throw new RuntimeException("Failed to reload object", e); } finally { reloadLock.unlock(); } - - log.debug("Reload complete"); } /** - * Make the following changes underneath the namespace {@value ZOOKEEPER_NAMESPACE}. All nodes listed here will be created if they do not exist: + * 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 ReloadStatus}.
    • - *
    • Set the data for the node {@code /attempts//cause} to the bytes of the string form of the given {@link ReloadCause}.
    • + *
    • 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}: *
        @@ -517,14 +452,15 @@ private void triggerReload(ReloadCause cause) { * the triggering event for the reload * @param status * the status - * @param errorMessages - * the error messages + * @param errors + * the errors * @param time * the time of the attempt * @throws Exception * if an error occurs on Zookeeper */ - private void updateAttemptNodes(ReloadCause cause, ReloadStatus status, List errorMessages, Instant time) throws Exception { + 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. @@ -532,31 +468,41 @@ private void updateAttemptNodes(ReloadCause cause, ReloadStatus status, List/errors} to reflect the contents of the given error message list. + * Update the node {@code /attempts//errors} to reflect the contents of the given error list. * * @param client * the client - * @param errorMessages - * the error messages + * @param errors + * the errors * @throws Exception * if an error occurs in Zookeeper */ - private void updateErrorsNode(CuratorFramework client, List errorMessages) throws Exception { + private void updateErrorsNode(CuratorFramework client, List errors) throws Exception { Stat stat = client.checkExists().forPath(attemptErrorsNode); if (stat != null) { client.delete().deletingChildrenIfNeeded().forPath(attemptErrorsNode); } - if (!errorMessages.isEmpty()) { + if (!errors.isEmpty()) { client.create().forPath(attemptErrorsNode); - for (int i = 0; i < errorMessages.size(); i++) { - String messageNode = attemptErrorsNode + NODE_ERROR_BASE + i; - setData(client, messageNode, errorMessages.get(i).getBytes()); + 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()); + } } } } @@ -581,45 +527,47 @@ private void setData(CuratorFramework client, String node, byte[] data) throws E } /** - * Attempt to load a {@link QueryLimitConfiguration} from the path specified in the data of the node {@value NODE_PATH} under the zookeeper namespace - * {@value ZOOKEEPER_NAMESPACE}. The path may point to an http, hdfs, or local file. Note that an invocation of this method will not result in the - * configuration being supplied to any listeners. + * 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 reload result + * @return the result */ - public LoadResult loadConfiguration() { + public ZkObjectPublishResult getObjectFromZk() { if (log.isDebugEnabled()) { - log.debug("Attempting to load new query limit configuration"); + 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 config URL node exists. + // Verify that the path node exists. Stat stat = client.checkExists().forPath(NODE_PATH); if (stat == null) { if (log.isDebugEnabled()) { - log.debug("Node " + NODE_PATH + " does not exist, skipping reload"); + log.debug(addLogPrefix("Node " + NODE_PATH + " does not exist, skipping reload")); } - return LoadResult.reloadError("Node does not exist: " + NODE_PATH); + 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 || pathBytes.length == 0) { + if (pathBytes == null) { if (log.isDebugEnabled()) { - log.debug("Node " + NODE_PATH + " does not have a non-blank filepath, skipping reload"); + log.debug(addLogPrefix("Node " + NODE_PATH + " does not have any data, skipping reload")); } - return LoadResult.reloadError("Config file path is not set in data for node " + NODE_PATH); + 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("Blank config filepath set in data for node " + NODE_PATH + ", skipping reload"); + log.debug(addLogPrefix("Node " + NODE_PATH + " does not have a non-blank filepath, skipping reload")); } - return LoadResult.reloadError("Config file path is not set in data for node " + NODE_PATH); + return ZkObjectPublishResult.error(attemptTime, "Blank filepath set in data for node " + namespace + NODE_PATH); } // Trim the path of any leading/trailing whitespace. @@ -630,55 +578,49 @@ public LoadResult loadConfiguration() { try { contents = getFileContents(path); } catch (NoSuchFileException e) { - log.error("Failed to read contents from file " + path, e); - return LoadResult.reloadError("File not found: " + path); + 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("Failed to read contents from file " + path, e); - return LoadResult.reloadError("Failed to read contents from file " + path + ": " + e.getMessage()); + 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. - QueryLimitConfiguration config; + Object pojo; DataFormatMatcher format = formatDetector.findFormat(contents); if (format.hasMatch()) { JsonFactory factory = format.getMatch(); - if (log.isDebugEnabled()) { - log.debug("Deserializing config file using format " + factory.getFormatName()); - } try { - // Deserialize the configuration using the associated mapper for the format. - config = formatToMapper.get(factory.getFormatName()).readValue(contents, QueryLimitConfiguration.class); - if (log.isDebugEnabled()) { - log.debug("Deserialized config: " + config); - } + // Deserialize the POJO using the associated mapper for the format. + pojo = formatToMapper.get(factory.getFormatName()).readValue(contents, objectClass); } catch (Exception e) { - log.error("Failed to deserialize file " + path + " to a " + QueryLimitConfiguration.class.getName(), e); - return LoadResult.reloadError("Failed to deserialize file to a " + QueryLimitConfiguration.class.getSimpleName()); + 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("Query limit file " + path + " is not XML, JSON, or YAML, skipping reload"); + log.debug(addLogPrefix("File " + path + " could not be detected as XML, JSON, or YAML, skipping reload")); } - return LoadResult.reloadError("Config file must be XML, JSON, or YAML"); + return ZkObjectPublishResult.error(attemptTime, "File " + path + " must be XML, JSON, or YAML"); } - // If we successfully deserialize a QueryLimitConfiguration, validate it. - try { - QueryLimitConfigurationValidator.validate(config); - if (log.isDebugEnabled()) { - log.debug("Successfully loaded query limit configuration from file " + path + ": " + config); - } - return LoadResult.success(config); - } catch (Exception e) { - if (log.isDebugEnabled()) { - log.debug("Query limit configuration failed validation, skipping reload", e); + // 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 LoadResult.reloadError("Configuration failed validation: " + e.getMessage()); } + + return ZkObjectPublishResult.success(attemptTime, pojo); } catch (Exception e) { - log.error("Failed to load query limit configuration from Zookeeper nodes", e); - throw new RuntimeException("Failed to load query limit configuration from Zookeeper nodes", 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); } } @@ -707,7 +649,7 @@ private byte[] getFileContents(String path) throws IOException { } else if (scheme.equalsIgnoreCase("file")) { return getContentFromLocalFs(uri.getPath()); } else { - throw new IOException("Unsupported URI scheme '" + scheme + "'"); + throw new IOException("Unsupported URI scheme: " + scheme); } } else { return getContentFromLocalFs(uri.getPath()); @@ -725,7 +667,7 @@ private byte[] getFileContents(String path) throws IOException { */ private byte[] getContentFromURL(String path) throws IOException { if (log.isDebugEnabled()) { - log.debug("Attempting to load query limit configuration from URL: " + path); + log.debug(addLogPrefix("Attempting to read file from URL: " + path)); } URL url = new URL(path); try (InputStream is = url.openStream()) { @@ -744,7 +686,7 @@ private byte[] getContentFromURL(String path) throws IOException { */ private byte[] getContentFromHdfs(String path) throws IOException { if (log.isDebugEnabled()) { - log.debug("Attempting to load query limit configuration from HDFS file: " + path); + 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))) { @@ -763,7 +705,7 @@ private byte[] getContentFromHdfs(String path) throws IOException { */ private byte[] getContentFromLocalFs(String path) throws IOException { if (log.isDebugEnabled()) { - log.debug("Attempting to load query limit configuration from local file: " + path); + 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); @@ -771,30 +713,30 @@ private byte[] getContentFromLocalFs(String path) throws IOException { } /** - * Add a {@link Consumer} that, when a new {@link QueryLimitConfiguration} is loaded a path specified in Zookeeper, will be provided that configuration. + * Add a {@link Consumer} that, when a new POJO is loaded a path specified in Zookeeper, will be provided that configuration. * - * @param listener - * the listener + * @param subscriber + * the subscriber */ - public void addListener(Consumer listener) { - this.listeners.add(listener); + public void subscribeToUpdates(Consumer subscriber) { + this.subscribers.add(subscriber); } /** - * Perform the following tasks: + * 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 listener list.
        • + *
        • Clear the subscribers list.
        • *
        • Close the locked client dispatcher.
        • *
        */ - public void cleanup() { + public void close() { if (pathCache != null) { try { pathCache.close(); } catch (Exception e) { - log.warn("Failed to close path cache", e); + log.warn(addLogPrefix("Failed to close path cache"), e); } finally { pathCache = null; } @@ -803,7 +745,7 @@ public void cleanup() { try { triggerCache.close(); } catch (Exception e) { - log.warn("Failed to close trigger cache", e); + log.warn(addLogPrefix("Failed to close trigger cache"), e); } finally { triggerCache = null; } @@ -812,19 +754,19 @@ public void cleanup() { try { executor.shutdown(); } catch (Exception e) { - log.warn("Failed to close executor", e); + log.warn(addLogPrefix("Failed to close executor"), e); } finally { executor = null; } } - if (listeners != null) { + if (subscribers != null) { try { - listeners.clear(); + subscribers.clear(); } catch (Exception e) { - log.warn("Failed to clear listeners", e); + log.warn(addLogPrefix("Failed to clear subscribers"), e); } finally { - listeners = null; + subscribers = null; } } @@ -832,59 +774,14 @@ public void cleanup() { try { clientDispatcher.close(); } catch (Exception e) { - log.warn("Failed to close client dispatcher", e); + log.warn(addLogPrefix("Failed to close client dispatcher"), e); } finally { clientDispatcher = null; } } } - /** - * Clean up resources used by this {@link QueryLimitConfigReloader} via {@link #cleanup()}. - */ - @Override - public void close() { - cleanup(); - } - - public static class LoadResult { - private final QueryLimitConfiguration config; - private final List errorMessages = new ArrayList<>(); - private ReloadStatus status; - - public static LoadResult success(QueryLimitConfiguration config) { - return new LoadResult(config, ReloadStatus.SUCCESS); - } - - public static LoadResult reloadError(String message) { - LoadResult result = new LoadResult(null, ReloadStatus.RELOAD_ERROR); - result.addErrorMessage(message); - return result; - } - - private LoadResult(QueryLimitConfiguration config, ReloadStatus status) { - this.config = config; - this.status = status; - } - - public QueryLimitConfiguration getConfig() { - return config; - } - - public ReloadStatus getStatus() { - return status; - } - - public void setStatus(ReloadStatus status) { - this.status = status; - } - - public void addErrorMessage(String message) { - errorMessages.add(message); - } - - public List getErrorMessages() { - return errorMessages; - } + private String addLogPrefix(String message) { + return namespace + ": " + message; } } diff --git a/web-services/query/src/main/java/datawave/webservice/query/limit/ZookeeperUtils.java b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkUtils.java similarity index 89% rename from web-services/query/src/main/java/datawave/webservice/query/limit/ZookeeperUtils.java rename to web-services/query/src/main/java/datawave/webservice/zookeeper/ZkUtils.java index 33d643bd812..ad56939c4d5 100644 --- a/web-services/query/src/main/java/datawave/webservice/query/limit/ZookeeperUtils.java +++ b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkUtils.java @@ -1,4 +1,4 @@ -package datawave.webservice.query.limit; +package datawave.webservice.zookeeper; import java.net.URI; import java.nio.file.Files; @@ -11,10 +11,12 @@ /** * Utility class for Zookeeper operations. */ -public final class ZookeeperUtils { +public final class ZkUtils { + + public static final byte[] EMPTY_DATA = new byte[0]; /** - * Return a + * Return a formatted Zookeeper connect string that can be used to connect to a running Zookeeper server. * * @param config * the configuration file/string @@ -60,7 +62,7 @@ public static String getQuorumPeerConfig(String config) throws QuorumPeerConfig. return sb.toString(); } - private ZookeeperUtils() { + private ZkUtils() { throw new UnsupportedOperationException(); } } diff --git a/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationValidatorTest.java b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationValidationUtilsTest.java similarity index 75% rename from web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationValidatorTest.java rename to web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationValidationUtilsTest.java index 00777746c13..b84687c29b2 100644 --- a/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationValidatorTest.java +++ b/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigurationValidationUtilsTest.java @@ -12,12 +12,12 @@ import org.junit.jupiter.api.Test; /** - * Tests for {@link QueryLimitConfigurationValidator}. + * Tests for {@link QueryLimitConfigurationValidationUtils}. */ -class QueryLimitConfigurationValidatorTest { +class QueryLimitConfigurationValidationUtilsTest { /** - * Tests for {@link QueryLimitConfigurationValidator#validate(QueryLimitConfiguration)}. + * Tests for {@link QueryLimitConfigurationValidationUtils#validate(QueryLimitConfiguration)}. */ @Nested class QueryLimitConfigurationValidationTests { @@ -30,7 +30,7 @@ void testDefaultUserQueryLimitLessThanOne() { QueryLimitConfiguration config = new QueryLimitConfiguration(); config.setDefaultUserQueryLimit(0); - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validate(config)).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validate(config)).isInstanceOf(IllegalArgumentException.class) .hasMessage("Default user query limit must be greater than 0"); } @@ -44,13 +44,13 @@ void testDefaultQueryLimitLessThanOne() { config.setDefaultSystemQueryLimit(5000); config.setInternalCacheMaxSize(0); - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validate(config)).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validate(config)).isInstanceOf(IllegalArgumentException.class) .hasMessage("Internal cache max size must be greater than 0"); } } /** - * Tests for {@link QueryLimitConfigurationValidator#validateQueryLogicGroupConfigs(Collection)}. + * Tests for {@link QueryLimitConfigurationValidationUtils#validateQueryLogicGroupConfigs(Collection)}. */ @Nested class QueryLogicGroupLimitConfigurationValidationTests { @@ -68,8 +68,8 @@ void setUp() { void testConfigWithBlankGroupName() { givenConfig(" ", "TLDQueryLogic", 50); - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validateQueryLogicGroupConfigs(configs)).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Query logic group limit configuration given with blank group name"); + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateQueryLogicGroupConfigs(configs)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Query logic group limit configuration given with blank group name"); } /** @@ -80,8 +80,8 @@ void testMultipleConfigsWithSameGroupName() { givenConfig("TLD", "TLDQueryLogic", 50); givenConfig("TLD", "TLD*", 25); - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validateQueryLogicGroupConfigs(configs)).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Multiple query logic group configurations given with group name 'TLD'"); + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateQueryLogicGroupConfigs(configs)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Multiple query logic group configurations given with group name 'TLD'"); } /** @@ -91,8 +91,8 @@ void testMultipleConfigsWithSameGroupName() { void testConfigWithNegativeLimit() { givenConfig("TLD", "TLDQueryLogic", -1); - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validateQueryLogicGroupConfigs(configs)).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Negative limit given for query logic group 'TLD'"); + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateQueryLogicGroupConfigs(configs)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Negative limit given for query logic group 'TLD'"); } /** @@ -102,8 +102,8 @@ void testConfigWithNegativeLimit() { void testConfigWithBlankQueryLogicPattern() { givenConfig("TLD", " ", 50); - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validateQueryLogicGroupConfigs(configs)).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Blank query logic pattern given for query logic group 'TLD'"); + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateQueryLogicGroupConfigs(configs)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Blank query logic pattern given for query logic group 'TLD'"); } /** @@ -113,8 +113,8 @@ void testConfigWithBlankQueryLogicPattern() { void testConfigWithUncompilableQueryLogicPattern() { givenConfig("TLD", "TLD[", 50); - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validateQueryLogicGroupConfigs(configs)).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Invalid regex in query logic pattern 'TLD[' for query logic group 'TLD'"); + 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) { @@ -124,7 +124,7 @@ private void givenConfig(String groupName, String queryLogicPattern, int queryLi } /** - * Tests for {@link QueryLimitConfigurationValidator#validateUserLimitConfigs(Collection)}. + * Tests for {@link QueryLimitConfigurationValidationUtils#validateUserLimitConfigs(Collection)}. */ @Nested class UserLimitConfigurationValidationTests { @@ -142,7 +142,7 @@ void setUp() { void testConfigWithBlankDn() { givenUserConfig(" ", null); - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validateUserLimitConfigs(configs)).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateUserLimitConfigs(configs)).isInstanceOf(IllegalArgumentException.class) .hasMessage("User query limit configuration given with blank user DN"); } @@ -154,7 +154,7 @@ void testMultipleConfigsWithSameUserDn() { givenUserConfig("cn=test user, c=us", 100); givenUserConfig("cn=test user, c=us", 200); - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validateUserLimitConfigs(configs)).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateUserLimitConfigs(configs)).isInstanceOf(IllegalArgumentException.class) .hasMessage("Multiple query limit configurations specified for user 'cn=test user, c=us'"); } @@ -165,7 +165,7 @@ void testMultipleConfigsWithSameUserDn() { void testConfigWithNegativeLimit() { givenUserConfig("cn=test user, c=us", -1); - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validateUserLimitConfigs(configs)).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateUserLimitConfigs(configs)).isInstanceOf(IllegalArgumentException.class) .hasMessage("Negative user query limit given for user 'cn=test user, c=us'"); } @@ -175,7 +175,7 @@ private void givenUserConfig(String userDn, Integer queryLimit) { } /** - * Tests for {@link QueryLimitConfigurationValidator#validateSystemLimitConfigs(Collection, long)}. + * Tests for {@link QueryLimitConfigurationValidationUtils#validateSystemLimitConfigs(Collection, long)}. */ @Nested class SystemLimitConfigurationValidationTests { @@ -194,8 +194,8 @@ void setUp() { void testConfigWithBlankSystemPattern() { givenSystemConfig(" ", 10, true, null); - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validateSystemLimitConfigs(configs, 200)).isInstanceOf(IllegalArgumentException.class) - .hasMessage("System query limit configuration specified with blank system pattern"); + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateSystemLimitConfigs(configs, 200)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("System query limit configuration specified with blank system pattern"); } /** @@ -205,8 +205,8 @@ void testConfigWithBlankSystemPattern() { void testConfigWithUncompilableSystemPattern() { givenSystemConfig("SYS[", 10, true, null); - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validateSystemLimitConfigs(configs, 200)).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Invalid regex in system pattern 'SYS['"); + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateSystemLimitConfigs(configs, 200)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Invalid regex in system pattern 'SYS['"); } /** @@ -217,7 +217,8 @@ void testMultipleConfigsWithSameSystemPattern() { givenSystemConfig("SYSTEM_01*", 10, true, null); givenSystemConfig("SYSTEM_01*", 10, true, null); - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validateSystemLimitConfigs(configs, 200)).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> QueryLimitConfigurationValidationUtils.validateSystemLimitConfigs(configs, 200)) + .isInstanceOf(IllegalArgumentException.class) .hasMessage("Multiple query limit configurations specified with system pattern 'SYSTEM_01*'"); } @@ -229,7 +230,8 @@ void testEquivalentExactMatchPatterns() { givenSystemConfig("SYSTEM_01", 10, true, null); // Literals only. givenSystemConfig("SYSTEM\\_01", 10, true, null); // Literals and escaped literals. - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validateSystemLimitConfigs(configs, 200)).isInstanceOf(IllegalArgumentException.class) + 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."); } @@ -241,7 +243,8 @@ void testEquivalentExactMatchPatterns() { void testImpliedWildcardSystemPatternThatDoesNotApplyToUserLimit() { givenSystemConfig("*", 10, false, null); - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validateSystemLimitConfigs(configs, 200)).isInstanceOf(IllegalArgumentException.class) + 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"); } @@ -252,7 +255,8 @@ void testImpliedWildcardSystemPatternThatDoesNotApplyToUserLimit() { void testExplicitWildcardSystemPatternThatDoesNotApplyToUserLimit() { givenSystemConfig(".*", 10, false, null); - assertThatThrownBy(() -> QueryLimitConfigurationValidator.validateSystemLimitConfigs(configs, 200)).isInstanceOf(IllegalArgumentException.class) + 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"); } 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 3fd105f9546..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 @@ -28,11 +28,15 @@ 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"; @@ -49,7 +53,7 @@ class QueryLimiterTest { @BeforeAll static void beforeAll() throws Exception { - ClassLoader classLoader = QueryLimitConfigReloaderTest.class.getClassLoader(); + ClassLoader classLoader = QueryLimiterTest.class.getClassLoader(); validJsonFile = getAbsolutePath(classLoader, "queryLimits/valid_config.json"); } @@ -108,13 +112,12 @@ void testConfigurationFailsValidation() { */ @Test void testInitialConfigurationSuppliedByZookeeper() throws Exception { - QueryLimitConfigReloader reloader = new QueryLimitConfigReloader(); - reloader.setZookeeperConfig(server.getConnectString()); - reloader.setup(); + ZkObjectPublisher publisher = new ZkObjectPublisher(PUBLISHER_NAMESPACE, server.getConnectString(), null, QueryLimitConfiguration.class, + List.of(new QueryLimitConfigurationValidator())); QueryLimiter limiter = new QueryLimiter(); limiter.setZookeeperConfig(server.getConnectString()); - limiter.setConfigReloader(reloader); + limiter.setConfigPublisher(publisher); limiter.setHeartbeatCache(heartbeatCache); try (CuratorFramework client = createReloaderClient()) { @@ -507,7 +510,7 @@ void testCreatingQueryAfterStoppingQueryThatMetLimit() throws Exception { } /** - * Verify that when a valid configuration is reloaded by the internal {@link QueryLimitConfigReloader}, the {@link QueryLimiter} is updated. + * Verify that when a valid configuration is reloaded by the internal {@link ZkObjectPublisher}, the {@link QueryLimiter} is updated. */ @Test void testConfigurationReload() throws Exception { @@ -544,15 +547,14 @@ private QueryLimiter getLimiter(String system) throws QuorumPeerConfig.ConfigExc if (systemToLimiter.containsKey(system)) { return systemToLimiter.get(system); } else { - QueryLimitConfigReloader reloader = new QueryLimitConfigReloader(); - reloader.setZookeeperConfig(server.getConnectString()); - reloader.setup(); + 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.setConfigReloader(reloader); + limiter.setConfigPublisher(publisher); limiter.setup(); systemToLimiter.put(system, limiter); return limiter; @@ -589,9 +591,15 @@ private void givenConfig(QueryLimitConfiguration config) { } private CuratorFramework createReloaderClient() { - CuratorFramework client = CuratorFrameworkFactory.builder().namespace(QueryLimitConfigReloader.ZOOKEEPER_NAMESPACE) - .connectString(server.getConnectString()).sessionTimeoutMs(60000).connectionTimeoutMs(60000).retryPolicy(new RetryNTimes(10, 1000)) - .build(); + // @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/LockedZkClientDispatcherTest.java b/web-services/query/src/test/java/datawave/webservice/zookeeper/LockedZkClientDispatcherTest.java similarity index 99% rename from web-services/query/src/test/java/datawave/webservice/query/limit/LockedZkClientDispatcherTest.java rename to web-services/query/src/test/java/datawave/webservice/zookeeper/LockedZkClientDispatcherTest.java index d644e487651..fb4e0d25153 100644 --- a/web-services/query/src/test/java/datawave/webservice/query/limit/LockedZkClientDispatcherTest.java +++ b/web-services/query/src/test/java/datawave/webservice/zookeeper/LockedZkClientDispatcherTest.java @@ -1,4 +1,4 @@ -package datawave.webservice.query.limit; +package datawave.webservice.zookeeper; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; 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/query/limit/QueryLimitConfigReloaderTest.java b/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherTest.java similarity index 67% rename from web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigReloaderTest.java rename to web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherTest.java index c340a37fa27..8bdd4e86099 100644 --- a/web-services/query/src/test/java/datawave/webservice/query/limit/QueryLimitConfigReloaderTest.java +++ b/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherTest.java @@ -1,6 +1,6 @@ -package datawave.webservice.query.limit; +package datawave.webservice.zookeeper; -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.assertNull; @@ -23,14 +23,21 @@ import org.apache.curator.retry.RetryNTimes; import org.apache.curator.test.TestingServer; import org.apache.zookeeper.data.Stat; -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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; -class QueryLimitConfigReloaderTest { +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; @@ -39,11 +46,13 @@ class QueryLimitConfigReloaderTest { private static String nonConfigFile; private static String unsupportedFormatFile; - private QueryLimitConfigReloader reloader; + private ZkObjectPublisher publisher; private final List configs = new ArrayList<>(); private TestingServer server; private CuratorFramework client; + private static String serverIpAddress; + private static String causeNode; private static String statusNode; private static String errorsNode; @@ -53,13 +62,13 @@ class QueryLimitConfigReloaderTest { @BeforeAll static void beforeAll() throws Exception { - String serverIpAddress = InetAddress.getLocalHost().getHostAddress(); + serverIpAddress = InetAddress.getLocalHost().getHostAddress(); causeNode = "/attempts/" + serverIpAddress + "/cause"; statusNode = "/attempts/" + serverIpAddress + "/status"; errorsNode = "/attempts/" + serverIpAddress + "/errors"; timeNode = "/attempts/" + serverIpAddress + "/time"; - ClassLoader classLoader = QueryLimitConfigReloaderTest.class.getClassLoader(); + 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"); @@ -92,25 +101,24 @@ private static String getAbsolutePath(ClassLoader classLoader, String relativePa void setUp() throws Exception { testStartTime = Instant.now(); configs.clear(); - reloader = null; + publisher = null; server = new TestingServer(); server.start(); // @formatter:off - client = CuratorFrameworkFactory.builder().namespace("QueryLimitConfig") - .connectString(server.getConnectString()) - .sessionTimeoutMs(300000) - .connectionTimeoutMs(60000) - .retryPolicy(new RetryNTimes(10, 1000)) - .build(); + 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 (reloader != null) { - reloader.close(); + if (publisher != null) { + publisher.close(); } if (client != null) { client.close(); @@ -121,7 +129,24 @@ void tearDown() throws IOException { } /** - * Verify that the {@link QueryLimitConfigReloader} can read a {@link QueryLimitConfiguration} from a JSON file on the local file system. + * 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, (Class) null, null)) + .isInstanceOf(NullPointerException.class).hasMessage("pojoClass 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 { @@ -129,8 +154,8 @@ void testReloadValidJsonFromLocalFilesystem() throws Exception { createOrUpdateNode("/path", validJsonFile); createOrUpdateNode("/trigger", "changeme"); - // Create the reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Trigger a reload. createOrUpdateNode("/trigger", ""); @@ -146,14 +171,14 @@ void testReloadValidJsonFromLocalFilesystem() throws Exception { waitForAttemptTimeNodeToBeCreated(); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.SUCCESS); + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.SUCCESS); assertErrorsNodeDoesNotExist(); assertTimeNodeHasRecentTime(); } /** - * Verify that the {@link QueryLimitConfigReloader} can read a {@link QueryLimitConfiguration} from an XML file on the local file system. + * Verify that the {@link ZkObjectPublisher} can read a {@link QueryLimitConfiguration} from an XML file on the local file system. */ @Test void testReloadValidXmlFromLocalFilesystem() throws Exception { @@ -161,8 +186,8 @@ void testReloadValidXmlFromLocalFilesystem() throws Exception { createOrUpdateNode("/path", validXmlFile); createOrUpdateNode("/trigger", "changeme"); - // Create the reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Trigger a reload. createOrUpdateNode("/trigger", ""); @@ -178,14 +203,14 @@ void testReloadValidXmlFromLocalFilesystem() throws Exception { waitForAttemptTimeNodeToBeCreated(); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.SUCCESS); + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.SUCCESS); assertErrorsNodeDoesNotExist(); assertTimeNodeHasRecentTime(); } /** - * Verify that the {@link QueryLimitConfigReloader} can read a {@link QueryLimitConfiguration} from a YAML file on the local file system. + * Verify that the {@link ZkObjectPublisher} can read a {@link QueryLimitConfiguration} from a YAML file on the local file system. */ @Test void testReloadValidYamlFromLocalFilesystem() throws Exception { @@ -193,8 +218,8 @@ void testReloadValidYamlFromLocalFilesystem() throws Exception { createOrUpdateNode("/path", validYamlFile); createOrUpdateNode("/trigger", "changeme"); - // Create the reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Trigger a reload. createOrUpdateNode("/trigger", ""); @@ -210,14 +235,14 @@ void testReloadValidYamlFromLocalFilesystem() throws Exception { waitForAttemptTimeNodeToBeCreated(); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.SUCCESS); + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.SUCCESS); assertErrorsNodeDoesNotExist(); assertTimeNodeHasRecentTime(); } /** - * Verify that if the file path is prefixed with {@code file://}, {@link QueryLimitConfigReloader} will attempt to read the file from the local file system. + * 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 { @@ -225,8 +250,8 @@ void testFilePrefixWillReloadFromLocalFilesystem() throws Exception { createOrUpdateNode("/path", "file://" + validJsonFile); createOrUpdateNode("/trigger", "changeme"); - // Create the reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Trigger a reload. createOrUpdateNode("/trigger", ""); @@ -242,22 +267,22 @@ void testFilePrefixWillReloadFromLocalFilesystem() throws Exception { waitForAttemptTimeNodeToBeCreated(); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.SUCCESS); + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.SUCCESS); assertErrorsNodeDoesNotExist(); assertTimeNodeHasRecentTime(); } /** - * Verify that if the node {@code /trigger} is created, {@link QueryLimitConfigReloader} will reload the configuration. + * 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 reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Trigger a reload. createOrUpdateNode("/trigger", ""); @@ -273,14 +298,14 @@ void testReloadTriggeredByTriggerNodeCreation() throws Exception { waitForAttemptTimeNodeToBeCreated(); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_CREATED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.SUCCESS); + assertCause(ZkObjectPublishCause.TRIGGER_NODE_CREATED); + assertStatus(ZkObjectPublishStatus.SUCCESS); assertErrorsNodeDoesNotExist(); assertTimeNodeHasRecentTime(); } /** - * Verify that if the node {@code /trigger} is deleted, {@link QueryLimitConfigReloader} will reload the configuration. + * Verify that if the node {@code /trigger} is deleted, {@link ZkObjectPublisher} will reload the configuration. */ @Test void testReloadTriggeredByTriggerNodeDeleted() throws Exception { @@ -288,8 +313,8 @@ void testReloadTriggeredByTriggerNodeDeleted() throws Exception { createOrUpdateNode("/path", validJsonFile); createOrUpdateNode("/trigger", "changeme"); - // Create the reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Trigger a reload. client.delete().forPath("/trigger"); @@ -305,19 +330,19 @@ void testReloadTriggeredByTriggerNodeDeleted() throws Exception { waitForAttemptTimeNodeToBeCreated(); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_DELETED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.SUCCESS); + assertCause(ZkObjectPublishCause.TRIGGER_NODE_DELETED); + assertStatus(ZkObjectPublishStatus.SUCCESS); assertErrorsNodeDoesNotExist(); assertTimeNodeHasRecentTime(); } /** - * Verify that if the node {@code /path} is created with empty data, {@link QueryLimitConfigReloader} will not reload a configuration. + * 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 reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Create the /path node. createOrUpdateNode("/path", ""); @@ -333,15 +358,15 @@ void testReloadNotTriggeredByPathNodeCreationWithEmptyData() throws Exception { } /** - * Verify that if the node {@code /path} is modified with empty data, {@link QueryLimitConfigReloader} will not reload a configuration. + * 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 reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Modify the /path node. createOrUpdateNode("/path", ""); @@ -357,12 +382,12 @@ void testReloadNotTriggeredByPathNodeModificationWithEmptyData() throws Exceptio } /** - * Verify that if the node {@code /path} is created with non-empty data, {@link QueryLimitConfigReloader} will reload a configuration. + * 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 reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Create the /path node. createOrUpdateNode("/path", validJsonFile); @@ -378,22 +403,22 @@ void testReloadTriggeredByPathNodeCreationWithNonEmptyData() throws Exception { waitForAttemptTimeNodeToBeCreated(); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.PATH_NODE_CREATED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.SUCCESS); + assertCause(ZkObjectPublishCause.PATH_NODE_CREATED); + assertStatus(ZkObjectPublishStatus.SUCCESS); assertErrorsNodeDoesNotExist(); assertTimeNodeHasRecentTime(); } /** - * Verify that if the node {@code /path} is modified with non-empty data, {@link QueryLimitConfigReloader} will reload a configuration. + * 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 reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Modify the /path node. createOrUpdateNode("/path", validXmlFile); @@ -409,8 +434,8 @@ void testReloadTriggeredByPathModificationWithNonEmptyData() throws Exception { waitForAttemptTimeNodeToBeCreated(); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.PATH_NODE_MODIFIED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.SUCCESS); + assertCause(ZkObjectPublishCause.PATH_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.SUCCESS); assertErrorsNodeDoesNotExist(); assertTimeNodeHasRecentTime(); } @@ -422,8 +447,8 @@ void testReloadTriggeredByPathModificationWithNonEmptyData() throws Exception { void testNonExistentPathNode() throws Exception { createOrUpdateNode("/trigger", "changeme"); - // Create the reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Trigger a reload. createOrUpdateNode("/trigger", ""); @@ -439,9 +464,11 @@ void testNonExistentPathNode() throws Exception { assertTrue(configs.isEmpty()); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); - assertErrors("Node does not exist: /path"); + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.RELOAD_ERROR); + assertTotalErrors(1); + assertErrorMessage(0, "Node does not exist: QueryLimitConfig/path"); + assertErrorDoesNotHaveStackTrace(0); assertTimeNodeHasRecentTime(); } @@ -454,8 +481,8 @@ void testPathNodeWithEmptyFilepath() throws Exception { createOrUpdateNode("/path", null); createOrUpdateNode("/trigger", "changeme"); - // Create the reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Trigger a reload. createOrUpdateNode("/trigger", ""); @@ -471,9 +498,11 @@ void testPathNodeWithEmptyFilepath() throws Exception { assertTrue(configs.isEmpty()); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); - assertErrors("Config file path is not set in data for node /path"); + 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(); } @@ -486,8 +515,8 @@ void testPathNodeWithBlankFilepath() throws Exception { createOrUpdateNode("/path", " "); createOrUpdateNode("/trigger", "changeme"); - // Create the reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Trigger a reload. createOrUpdateNode("/trigger", ""); @@ -503,9 +532,11 @@ void testPathNodeWithBlankFilepath() throws Exception { assertTrue(configs.isEmpty()); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); - assertErrors("Config file path is not set in data for node /path"); + 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(); } @@ -518,8 +549,8 @@ void testFileWithInvalidURIScheme() throws Exception { createOrUpdateNode("/path", "ftp://i/do/not/exist"); createOrUpdateNode("/trigger", "changeme"); - // Create the reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Trigger a reload. createOrUpdateNode("/trigger", ""); @@ -535,9 +566,11 @@ void testFileWithInvalidURIScheme() throws Exception { assertTrue(configs.isEmpty()); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); - assertErrors("Failed to read contents from file ftp://i/do/not/exist: Unsupported URI scheme 'ftp'"); + 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(); } @@ -550,8 +583,8 @@ void testNonExistentFile() throws Exception { createOrUpdateNode("/path", "i/do/not/exist"); createOrUpdateNode("/trigger", "changeme"); - // Create the reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Trigger a reload. createOrUpdateNode("/trigger", ""); @@ -567,9 +600,11 @@ void testNonExistentFile() throws Exception { assertTrue(configs.isEmpty()); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); - assertErrors("File not found: i/do/not/exist"); + 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(); } @@ -582,8 +617,8 @@ void testUnsupportedSyntax() throws Exception { createOrUpdateNode("/path", unsupportedFormatFile); createOrUpdateNode("/trigger", "changeme"); - // Create the reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Trigger a reload. createOrUpdateNode("/trigger", ""); @@ -599,9 +634,12 @@ void testUnsupportedSyntax() throws Exception { assertTrue(configs.isEmpty()); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); - assertErrors("Config file must be XML, JSON, or YAML"); + assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); + assertStatus(ZkObjectPublishStatus.RELOAD_ERROR); + assertTotalErrors(1); + assertErrorMessage(0, + "File /home/ubuntu/datawave-data/datawave/web-services/query/target/test-classes/queryLimits/unsupported_format.toml must be XML, JSON, or YAML"); + assertErrorDoesNotHaveStackTrace(0); assertTimeNodeHasRecentTime(); } @@ -614,8 +652,8 @@ void testNonQueryLimitConfigurationFile() throws Exception { createOrUpdateNode("/path", nonConfigFile); createOrUpdateNode("/trigger", "changeme"); - // Create the reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Trigger a reload. createOrUpdateNode("/trigger", ""); @@ -631,9 +669,11 @@ void testNonQueryLimitConfigurationFile() throws Exception { assertTrue(configs.isEmpty()); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); - assertErrors("Failed to deserialize file to a QueryLimitConfiguration"); + 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(); } @@ -647,8 +687,8 @@ void testInvalidQueryLimitConfigurationFile() throws Exception { createOrUpdateNode("/path", invalidConfigFile); createOrUpdateNode("/trigger", "changeme"); - // Create the reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); // Trigger a reload. createOrUpdateNode("/trigger", ""); @@ -664,32 +704,35 @@ void testInvalidQueryLimitConfigurationFile() throws Exception { assertTrue(configs.isEmpty()); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.RELOAD_ERROR); - assertErrors("Configuration failed validation: Default user query limit must be greater than 0"); + 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 listeners after supplying them with a new configuration, the errors are captured and recorded. + * Verify that if exceptions are thrown by subscribers after supplying them with a new configuration, the errors are captured and recorded. */ @Test - void testExceptionsThrownByListeners() throws Exception { + void testExceptionsThrownBySubscribers() throws Exception { // Set up the /path node beforehand. createOrUpdateNode("/path", validJsonFile); createOrUpdateNode("/trigger", "changeme"); - // Create the reloader and start listening for trigger events. - createReloader(); + // Create the publisher and start listening for trigger events. + createPublisher(); - // Add listeners to the reloader that will throw a variety of exceptions. - reloader.addListener(configuration -> { + // Add listeners to the publisher that will throw a variety of exceptions. + publisher.subscribeToUpdates(configuration -> { throw new NullPointerException("Something bad happened!"); }); - reloader.addListener(configuration -> { + publisher.subscribeToUpdates(configuration -> { throw new IllegalArgumentException("I don't like this configuration."); }); - reloader.addListener(configuration -> { + publisher.subscribeToUpdates(configuration -> { throw new UnsupportedOperationException("Why do I even exist?"); }); @@ -707,10 +750,15 @@ void testExceptionsThrownByListeners() throws Exception { waitForAttemptTimeNodeToBeCreated(); // Verify the attempt nodes were updated correctly. - assertCause(QueryLimitConfigReloader.ReloadCause.TRIGGER_NODE_MODIFIED); - assertStatus(QueryLimitConfigReloader.ReloadStatus.LISTENER_ERROR); - assertErrors("Exception thrown by listener: Something bad happened!", "Exception thrown by listener: I don't like this configuration.", - "Exception thrown by listener: Why do I even exist?"); + 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(); } @@ -744,11 +792,11 @@ private void waitForAttemptTimeNodeToBeCreated() { } } - private void assertCause(QueryLimitConfigReloader.ReloadCause cause) throws Exception { - assertData(causeNode, cause.toString()); + private void assertCause(ZkObjectPublishCause trigger) throws Exception { + assertData(causeNode, trigger.toString()); } - private void assertStatus(QueryLimitConfigReloader.ReloadStatus status) throws Exception { + private void assertStatus(ZkObjectPublishStatus status) throws Exception { assertData(statusNode, status.toString()); } @@ -757,16 +805,27 @@ private void assertErrorsNodeDoesNotExist() throws Exception { assertNull(stat, "Expected node " + errorsNode + " to not exist"); } - private void assertErrors(String... errors) throws Exception { + private void assertTotalErrors(int expected) throws Exception { Stat stat = client.checkExists().forPath(errorsNode); - assertNotNull(stat, "Expected node " + errorsNode + " to exist"); - List children = client.getChildren().forPath(errorsNode); - List actualErrors = new ArrayList<>(); - for (String child : children) { - String actualData = new String(client.getData().forPath(errorsNode + "/" + child)); - actualErrors.add(actualData); - } - assertThat(actualErrors).containsExactlyInAnyOrder(errors); + 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 { @@ -784,16 +843,15 @@ private void assertData(String path, String expectedData) throws Exception { assertEquals(expectedData, actualData, "Expected data for node " + path + " to be '" + expectedData + "'"); } - private void createReloader() throws QuorumPeerConfig.ConfigException { - reloader = new QueryLimitConfigReloader(); - reloader.setZookeeperConfig(server.getConnectString()); - reloader.setup(); + private void createPublisher() { + publisher = new ZkObjectPublisher(NAMESPACE, server.getConnectString(), null, QueryLimitConfiguration.class, + List.of(new QueryLimitConfigurationValidator())); try { - reloader.awaitCacheInitialization(5, TimeUnit.SECONDS); + publisher.awaitCacheInitialization(5, TimeUnit.SECONDS); } catch (Exception e) { - throw new RuntimeException("Reloader caches failed to initialize before timeout", e); + throw new RuntimeException("Publisher caches failed to initialize before timeout", e); } - reloader.addListener(configs::add); + publisher.subscribeToUpdates((pojo) -> configs.add((QueryLimitConfiguration) pojo)); } private void createOrUpdateNode(String node, String dataStr) throws Exception { diff --git a/web-services/query/src/test/java/datawave/webservice/query/limit/ZookeeperUtilsTest.java b/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkUtilsTest.java similarity index 69% rename from web-services/query/src/test/java/datawave/webservice/query/limit/ZookeeperUtilsTest.java rename to web-services/query/src/test/java/datawave/webservice/zookeeper/ZkUtilsTest.java index c07647de280..c17544dfb42 100644 --- a/web-services/query/src/test/java/datawave/webservice/query/limit/ZookeeperUtilsTest.java +++ b/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkUtilsTest.java @@ -1,4 +1,4 @@ -package datawave.webservice.query.limit; +package datawave.webservice.zookeeper; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -17,30 +17,30 @@ import org.junit.jupiter.api.io.TempDir; import org.junitpioneer.jupiter.SetSystemProperty; -class ZookeeperUtilsTest { +class ZkUtilsTest { @TempDir File tempDir; /** - * Verify that when {@link ZookeeperUtils#getQuorumPeerConfig(String)} is given a non-filepath argument, the original argument is returned. + * 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", ZookeeperUtils.getQuorumPeerConfig("localhost:2181")); + assertEquals("localhost:2181", ZkUtils.getQuorumPeerConfig("localhost:2181")); } /** - * Verify that when {@link ZookeeperUtils#getQuorumPeerConfig(String)} is given a filepath that does not point to an existing file, the original argument is + * 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", ZookeeperUtils.getQuorumPeerConfig("/i/do/not/exist/zookeeper.cfg")); + assertEquals("/i/do/not/exist/zookeeper.cfg", ZkUtils.getQuorumPeerConfig("/i/do/not/exist/zookeeper.cfg")); } /** - * Verify that when {@link ZookeeperUtils#getQuorumPeerConfig(String)} is given an invalid zookeeper config file, an exception is thrown. + * Verify that when {@link ZkUtils#getQuorumPeerConfig(String)} is given an invalid zookeeper config file, an exception is thrown. */ @Test void testGetQuorumPeerConfigGivenInvalidConfigFile() throws IOException { @@ -48,12 +48,12 @@ void testGetQuorumPeerConfigGivenInvalidConfigFile() throws IOException { properties.put("tickTime", "2000"); String path = createZookeeperCfgFile(properties); - assertThrows(QuorumPeerConfig.ConfigException.class, () -> ZookeeperUtils.getQuorumPeerConfig(path)); + assertThrows(QuorumPeerConfig.ConfigException.class, () -> ZkUtils.getQuorumPeerConfig(path)); } /** - * Verify that when {@link ZookeeperUtils#getQuorumPeerConfig(String)} is given a valid zookeeper config path with the URI scheme {@code file://}, it is - * able to load and read the file. + * 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 { @@ -65,12 +65,12 @@ void testGetQuorumPeerConfigGivenPathWithLocalFileScheme() throws QuorumPeerConf properties.put("clientPort", "2181"); String path = createZookeeperCfgFile(properties); - assertEquals("0.0.0.0:2181", ZookeeperUtils.getQuorumPeerConfig("file://" + path)); + assertEquals("0.0.0.0:2181", ZkUtils.getQuorumPeerConfig("file://" + path)); } /** - * Verify that when {@link ZookeeperUtils#getQuorumPeerConfig(String)} is given a valid zookeeper config a client port address, the default client port - * address {@code 0.0.0.0} is returned. + * 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 { @@ -82,11 +82,11 @@ void testGetQuorumPeerConfigGivenValidConfigFileWithoutClientPortAddress() throw properties.put("clientPort", "2181"); String path = createZookeeperCfgFile(properties); - assertEquals("0.0.0.0:2181", ZookeeperUtils.getQuorumPeerConfig(path)); + assertEquals("0.0.0.0:2181", ZkUtils.getQuorumPeerConfig(path)); } /** - * Verify that when {@link ZookeeperUtils#getQuorumPeerConfig(String)} is given a valid zookeeper config without servers, the default client port address is + * Verify that when {@link ZkUtils#getQuorumPeerConfig(String)} is given a valid zookeeper config without servers, the default client port address is * returned. */ @Test @@ -103,12 +103,12 @@ void testGetQuorumPeerConfigGivenValidConfigFileWithoutServers() throws QuorumPe InetSocketAddress clientSocketAddress = new InetSocketAddress(InetAddress.getByName("192.168.1.50"), 2181); String expected = clientSocketAddress.getHostName() + ":2181"; - assertEquals(expected, ZookeeperUtils.getQuorumPeerConfig(path)); + assertEquals(expected, ZkUtils.getQuorumPeerConfig(path)); } /** - * Verify that when {@link ZookeeperUtils#getQuorumPeerConfig(String)} is given a valid zookeeper config with servers, a comma-delimited list of the server - * client port addresses are returned. + * 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 @@ -129,7 +129,7 @@ void testGetQuorumPeerConfigGivenValidConfigFileWithServers() throws QuorumPeerC properties.setProperty("dataDir", tempDir.getAbsolutePath()); String path = createZookeeperCfgFile(properties); - assertEquals("client1:2181,client2:2181", ZookeeperUtils.getQuorumPeerConfig(path)); + assertEquals("client1:2181,client2:2181", ZkUtils.getQuorumPeerConfig(path)); } private String createZookeeperCfgFile(Properties properties) throws IOException { 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 cc3e540f65d..45f23ed228f 100644 --- a/web-services/query/src/test/resources/log4j.properties +++ b/web-services/query/src/test/resources/log4j.properties @@ -9,7 +9,7 @@ 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 - +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 From d13d5206eb8f3f7ae6b064ac70d7d64d2acddceb Mon Sep 17 00:00:00 2001 From: Laura Schanno Date: Mon, 11 May 2026 21:58:38 -0400 Subject: [PATCH 08/13] Fix failing unit test --- .../webservice/zookeeper/ZkObjectPublisherTest.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 index 8bdd4e86099..a95d72f9b8f 100644 --- a/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherTest.java +++ b/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherTest.java @@ -50,9 +50,7 @@ public class ZkObjectPublisherTest { private final List configs = new ArrayList<>(); private TestingServer server; private CuratorFramework client; - - private static String serverIpAddress; - + private static String causeNode; private static String statusNode; private static String errorsNode; @@ -62,7 +60,7 @@ public class ZkObjectPublisherTest { @BeforeAll static void beforeAll() throws Exception { - serverIpAddress = InetAddress.getLocalHost().getHostAddress(); + String serverIpAddress = InetAddress.getLocalHost().getHostAddress(); causeNode = "/attempts/" + serverIpAddress + "/cause"; statusNode = "/attempts/" + serverIpAddress + "/status"; errorsNode = "/attempts/" + serverIpAddress + "/errors"; @@ -638,7 +636,7 @@ void testUnsupportedSyntax() throws Exception { assertStatus(ZkObjectPublishStatus.RELOAD_ERROR); assertTotalErrors(1); assertErrorMessage(0, - "File /home/ubuntu/datawave-data/datawave/web-services/query/target/test-classes/queryLimits/unsupported_format.toml must be XML, JSON, or YAML"); + "File " + unsupportedFormatFile + " must be XML, JSON, or YAML"); assertErrorDoesNotHaveStackTrace(0); assertTimeNodeHasRecentTime(); } From b11a11150b1f0c1ec1a609fa353e204db5973821 Mon Sep 17 00:00:00 2001 From: Laura Schanno Date: Mon, 11 May 2026 22:10:38 -0400 Subject: [PATCH 09/13] Code formatting --- .../datawave/webservice/zookeeper/ZkObjectPublisherTest.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 index a95d72f9b8f..9b256767968 100644 --- a/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherTest.java +++ b/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherTest.java @@ -50,7 +50,7 @@ public class ZkObjectPublisherTest { private final List configs = new ArrayList<>(); private TestingServer server; private CuratorFramework client; - + private static String causeNode; private static String statusNode; private static String errorsNode; @@ -635,8 +635,7 @@ void testUnsupportedSyntax() throws Exception { assertCause(ZkObjectPublishCause.TRIGGER_NODE_MODIFIED); assertStatus(ZkObjectPublishStatus.RELOAD_ERROR); assertTotalErrors(1); - assertErrorMessage(0, - "File " + unsupportedFormatFile + " must be XML, JSON, or YAML"); + assertErrorMessage(0, "File " + unsupportedFormatFile + " must be XML, JSON, or YAML"); assertErrorDoesNotHaveStackTrace(0); assertTimeNodeHasRecentTime(); } From 91b928c3d8b13fdbef70f1afe2170a051c679535 Mon Sep 17 00:00:00 2001 From: Laura Schanno Date: Mon, 11 May 2026 22:35:24 -0400 Subject: [PATCH 10/13] Fix failing unit test --- .../datawave/webservice/zookeeper/ZkObjectPublisherTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 9b256767968..754b1121d48 100644 --- a/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherTest.java +++ b/web-services/query/src/test/java/datawave/webservice/zookeeper/ZkObjectPublisherTest.java @@ -139,8 +139,8 @@ void testInvalidConstructorArgs() { .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, (Class) null, null)) - .isInstanceOf(NullPointerException.class).hasMessage("pojoClass must not be null"); + assertThatThrownBy(() -> new ZkObjectPublisher("QueryLimitConfig", server.getConnectString(), null, null, null)) + .isInstanceOf(NullPointerException.class).hasMessage("objectClass must not be null"); } /** From f35b103b692c53149ee29e27f022b267d230e8aa Mon Sep 17 00:00:00 2001 From: Laura Schanno Date: Mon, 11 May 2026 23:01:49 -0400 Subject: [PATCH 11/13] Fix bad JavaDoc --- .../webservice/zookeeper/ZkObjectPublisher.java | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 index ec75c79e85c..65f65ebdee4 100644 --- a/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublisher.java +++ b/web-services/query/src/main/java/datawave/webservice/zookeeper/ZkObjectPublisher.java @@ -54,8 +54,8 @@ * 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. + *
        • 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: @@ -311,6 +311,7 @@ private CuratorCache createCache(String node, CuratorFrameworkFactory.Builder cl * * @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 @@ -379,10 +380,13 @@ public boolean areCachesInitialized() { /** * 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 trigger) { + private void triggerReload(ZkObjectPublishCause cause) { if (log.isDebugEnabled()) { - log.debug(addLogPrefix("Reload triggered by " + trigger)); + log.debug(addLogPrefix("Reload triggered by " + cause)); } // Obtain the reload lock. @@ -417,12 +421,12 @@ private void triggerReload(ZkObjectPublishCause trigger) { } if (log.isDebugEnabled()) { - log.debug(addLogPrefix("Update of " + objectClass.getName() + " completed with attemptTime:=" + attemptTime + ", trigger=" + trigger + ", " + 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(trigger, result.getStatus(), result.getErrors(), attemptTime); + 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); From ef07ab8812b16143ddb30633be6aec679c3d7716 Mon Sep 17 00:00:00 2001 From: Laura Schanno Date: Thu, 14 May 2026 15:22:41 -0400 Subject: [PATCH 12/13] Delete obsolete method --- .../query/limit/ActiveQueryTracker.java | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) 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 e8212f6f75a..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 @@ -188,7 +188,7 @@ public int getTotalUserQueriesForQueryLogic(String userDn, String queryLogic) th try (LockedZkClientDispatcher.LockedClient lockedClient = clientDispatcher.getLockedClient()) { CuratorFramework client = lockedClient.getClient(); - return getTotalChildrenWithLock(client, getUserQueryLogicPath(userDn, queryLogic)); + return getTotalChildren(client, getUserQueryLogicPath(userDn, queryLogic)); } } @@ -213,27 +213,7 @@ public int getTotalSystemQueriesForQueryLogic(String system, String queryLogic) try (LockedZkClientDispatcher.LockedClient lockedClient = clientDispatcher.getLockedClient()) { CuratorFramework client = lockedClient.getClient(); - return getTotalChildrenWithLock(client, 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 client - * the client - * @param path - * the node path - * @return the total children - * @throws Exception - * if an error occurs while scanning nodes - */ - private int getTotalChildrenWithLock(CuratorFramework client, String path) throws Exception { - try { - return getTotalChildren(client, path); - } catch (Exception e) { - log.error("Failed to get total children for path " + path, e); - throw e; + return getTotalChildren(client, getSystemQueryLogicPath(system, queryLogic)); } } From c4baf83c24bc95306fd81c041c0f750003d8c211 Mon Sep 17 00:00:00 2001 From: Laura Schanno Date: Tue, 19 May 2026 14:59:40 -0400 Subject: [PATCH 13/13] Improve clarity in README --- .../src/main/java/datawave/webservice/zookeeper/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index d5965ec11eb..129ced13078 100644 --- a/web-services/query/src/main/java/datawave/webservice/zookeeper/README.md +++ b/web-services/query/src/main/java/datawave/webservice/zookeeper/README.md @@ -1,7 +1,7 @@ # 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. +* `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. @@ -12,7 +12,7 @@ A ZkObjectPublisher will attempt to reload and publish a new instance of its con * 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 be XML, JSON, or YAML, and must conform to one of the following URI schemes: +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`