Enhancing RESTHeart GraphQL API Performance

Andrea Di Cesare
SoftInstigate Team
Published in
6 min readAug 27, 2024

I recently worked on performance improvements for the RESTHeart GraphQL API, available from version 8.0.10. These enhancements focused on optimizing GQL app definition caching and improving the handling of complex queries involving multiple type resolvers.

Curious about the RESTHeart GraphQL API? Take a look at my previous post, Easily Create a Star Wars GraphQL API with RESTHeart, for a step-by-step guide.

The recent changes addressed several bottlenecks identified during an in-depth performance evaluation, which revealed response time spikes and overhead in queries utilizing typeResolvers.

Below, we explore the key code changes and their impact on the API’s performance.

But first, the results: throughput improved by 22%, and the response time spikes caused by app definition cache evictions were eliminated.

The following chart presents the performance test results for version 8.0.9, showcasing the absence of spikes due to app reloading and demonstrating enhanced throughput with reduced latency.

The following chart presents the performance test results for version 8.0.7. Performance spikes are observed whenever the GQL app definition cache entry is evicted, triggering app reloading.

The following chart presents the performance test results for version 8.0.7, where the GQL app cache TTL was configured with an extremely high value to prevent eviction, thereby eliminating app reloading.

GQL App Definition Caching Optimization

Removal of TTL for Cached App Definitions:

  • Before: The Time-to-Live (TTL) for cached GQL app definitions was explicitly set, causing periodic cache evictions and leading to potential performance spikes as definitions had to be reloaded.
  • After: The TTL configuration was removed entirely, and the caching mechanism now relies on automatic, asynchronous cache updates. This change ensures that frequently used app definitions remain in the cache indefinitely, eliminating unnecessary reloads and maintaining consistent performance.
# Previous configuration with TTL graphql:   
app-def-cache-ttl: 10_000 # in milliseconds
# Updated configuration without TTL graphql:
app-cache-ttr: 60_000 # Time-to-Revalidate (TTR) in milliseconds

Introduction of App Definition Revalidation and Cache Invalidation:

  • Cache Invalidation: The GraphAppDefinitionCacheInvalidator was introduced to handle scenarios where app definitions need to be explicitly invalidated, such as when an app definition is deleted. This ensures that the cache remains consistent with the current state of the GQL app definitions in the database.
  • Revalidation Mechanism: The GraphAppsUpdater class introduces a new revalidation mechanism for cached app definitions. This class periodically revalidates these definitions based on the Time-to-Revalidate (TTR) interval. If an app definition is found to be invalid or outdated, it is automatically reloaded and updated in the cache. This approach ensures that cached entries remain current without requiring unnecessary evictions, ultimately guaranteeing that all nodes in a multi-node deployment eventually have the most up-to-date definitions in their caches.
Executors.newSingleThreadScheduledExecutor()     
.scheduleAtFixedRate(() -> revalidateCacheEntries(), TTR, TTR, TimeUnit.MILLISECONDS);

private void revalidateCacheEntries() {
gqlAppDefCache.asMap().entrySet()
.forEach(entry -> {
try {
var appDef = AppDefinitionLoader.loadAppDefinition(entry.getKey());
gqlAppDefCache.put(entry.getKey(), appDef);
LOGGER.debug("GQL cache entry {} updated", entry.getKey());
} catch (GraphQLAppDefNotFoundException e) {
gqlAppDefCache.invalidate(entry.getKey());
LOGGER.debug("GQL cache entry {} removed", entry.getKey());
}
});
}

Streamlined Cache Access and Error Handling:

  • Before: Cache retrieval operations did not differentiate between a missing app definition and an invalid one, leading to potential issues where invalid app definitions could persist in the cache.
  • After: The AppDefinitionLoader was modified to throw specific exceptions (GraphQLAppDefNotFoundException and GraphQLIllegalAppDefinitionException) when an app definition is not found or is invalid. This ensures that only valid definitions are cached and used, improving the robustness of the cache.
static GraphQLApp loadAppDefinition(String appURI) throws GraphQLIllegalAppDefinitionException, GraphQLAppDefNotFoundException {     
LOGGER.trace("Loading GQL App Definition {} from db", appURI);
var appDefinition = // Logic to load app definition
if (appDefinition != null) {
return AppBuilder.build(appDefinition);
} else {
throw new GraphQLAppDefNotFoundException("GQL App Definition for uri " + appURI + " not found.");
}
}

Improved Logging and Traceability:

  • Before: Logging around cache operations was minimal, making it difficult to trace issues related to app definition loading and caching.
  • After: Logging was enhanced to provide detailed information about cache operations, including when entries are loaded, updated, or removed. This increased transparency helps in debugging and monitoring the health of the caching system.
LOGGER.trace("Loading GQL App Definition {} from db", appURI); 
LOGGER.debug("GQL cache entry {} updated", appUri);
LOGGER.warn("GQL cache entry {} removed due to illegal definition", appUri, e);

Impact of the GQL App Definition Caching Optimizations

These caching optimizations lead to several significant benefits:

  • Elimination of Cache Eviction Spikes: The latest version no longer experienced periodic spikes in response time due to cache eviction, which was a prominent issue in earlier versions.
  • Consistent Performance: By removing the TTL and relying on revalidation, the cache maintains consistent performance.
  • Enhanced Reliability: The new error handling and revalidation mechanisms ensure that only valid app definitions are cached, reducing the risk of serving outdated or incorrect data.
  • Improved Transparency: Enhanced logging provides better insights into the caching process, making it easier to diagnose issues and monitor cache health.

JXPathContext Caching in typeResolver predicates evaluation

  • Before: Each time a GraphQL query involving typeResolvers required evaluation, a new JXPathContext was created for accessing and resolving data paths within a BSON document. This repeated instantiation was particularly costly when processing large documents with complex type resolution logic.
  • After: A caching mechanism was introduced for JXPathContext objects within the ExchangeWithBsonValue class. This change ensures that the context is only created once per document and reused across multiple resolver checks, significantly reducing overhead. The new method jxPathCtx(HttpServerExchange exchange) in ExchangeWithBsonValue manages this caching process, improving the performance of XPath evaluations.
public static JXPathContext jxPathCtx(HttpServerExchange exchange) {     
var ctx = exchange.getAttachment(JX_PATH_CTX_KEY);
if (ctx == null) {
ctx = JXPathContext.newContext(value(exchange));
exchange.putAttachment(JX_PATH_CTX_KEY, ctx);
}

return ctx;
}

Refactoring of Predicate Implementations:

  • Before: Predicates such as FieldEqPredicate and FieldExists operated directly on BsonValue objects. This approach required the frequent conversion of field paths to XPath expressions and the creation of contexts for each field check, leading to significant performance degradation, especially when multiple fields were involved.
  • After: The predicates were refactored to utilize the cached JXPathContext instead of operating directly on BsonValue objects. The BsonUtils.get method was updated to take a JXPathContext as an argument, streamlining the process of evaluating field existence and equality checks within documents.
public boolean resolve(JXPathContext ctx) {     
var _v = BsonUtils.get(ctx, key);
if (_v.isPresent()) {
return this.value.equals(_v.get());
} else {
return false;
}
}

Improvement in Union and Interface Mapping:

  • Before: In GraphQLApp, the logic for resolving unions and interfaces repeatedly created exchange objects for each evaluation. This not only increased overhead but also resulted in redundant operations during complex type resolution.
  • After: The creation of the exchange object was optimized by storing it in a local variable (ex) before performing any resolution checks. This simple change eliminated unnecessary recomputation, further contributing to performance improvements.
final var ex = ExchangeWithBsonValue.exchange(value); 
match = unionMapping.entrySet().stream()
.filter(p -> p.getValue().resolve(ex))
.findFirst();

Impact of the JXPathContext Caching

The combined effect of these changes was a substantial improvement in the performance of the RESTHeart GraphQL API. The introduction of JXPathContext caching and the refactoring of predicate evaluations reduced the overhead associated with complex queries. Tests comparing the latest snapshot version with the previous stable release showed:

  • Reduction in Average Response Time: The average response time decreased from 118ms to 99ms.
  • Improved Throughput: The number of requests processed per minute increased, reflecting a more efficient handling of GraphQL queries.

--

--