All Downloads are FREE. Search and download functionalities are using the official Maven repository.

au.csiro.pathling.terminology.caching.CachingTerminologyService Maven / Gradle / Ivy

There is a newer version: 7.0.1
Show newest version
/*
 * Copyright 2023 Commonwealth Scientific and Industrial Research
 * Organisation (CSIRO) ABN 41 687 119 230.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package au.csiro.pathling.terminology.caching;

import static au.csiro.pathling.utilities.Preconditions.checkPresent;
import static java.util.Objects.requireNonNull;

import au.csiro.pathling.config.HttpClientCachingConfiguration;
import au.csiro.pathling.fhir.TerminologyClient;
import au.csiro.pathling.fhirpath.encoding.ImmutableCoding;
import au.csiro.pathling.terminology.BaseTerminologyService;
import au.csiro.pathling.terminology.TerminologyOperation;
import au.csiro.pathling.terminology.TerminologyParameters;
import au.csiro.pathling.terminology.TerminologyResult;
import au.csiro.pathling.terminology.lookup.LookupExecutor;
import au.csiro.pathling.terminology.lookup.LookupParameters;
import au.csiro.pathling.terminology.subsumes.SubsumesExecutor;
import au.csiro.pathling.terminology.subsumes.SubsumesParameters;
import au.csiro.pathling.terminology.translate.TranslateExecutor;
import au.csiro.pathling.terminology.translate.TranslateParameters;
import au.csiro.pathling.terminology.validatecode.ValidateCodeExecutor;
import au.csiro.pathling.terminology.validatecode.ValidateCodeParameters;
import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.gclient.IOperationUntypedWithInput;
import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
import ca.uhn.fhir.rest.server.exceptions.NotModifiedException;
import java.io.Closeable;
import java.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.ws.rs.core.CacheControl;
import org.hl7.fhir.r4.model.Coding;
import org.hl7.fhir.r4.model.codesystems.ConceptSubsumptionOutcome;
import org.infinispan.Cache;
import org.infinispan.manager.EmbeddedCacheManager;

/**
 * A terminology service that uses embedded Infinispan to cache the results of the underlying
 * terminology service operations.
 *
 * @author John Grimes
 */
public abstract class CachingTerminologyService extends BaseTerminologyService {

  public static final String VALIDATE_CODE_CACHE_NAME = "validate-code";
  public static final String SUBSUMES_CACHE_NAME = "subsumes";
  public static final String TRANSLATE_CACHE_NAME = "translate";
  public static final String LOOKUP_CACHE_NAME = "lookup";
  public static final String ETAG_HEADER_NAME = "etag";
  public static final String IF_NONE_MATCH_HEADER_NAME = "if-none-match";
  public static final String CACHE_CONTROL_HEADER_NAME = "cache-control";

  @Nonnull
  protected final HttpClientCachingConfiguration configuration;

  @Nonnull
  protected final EmbeddedCacheManager cacheManager;

  @Nonnull
  protected final Cache> validateCodeCache;

  @Nonnull
  protected final Cache> subsumesCache;

  @Nonnull
  protected final Cache>> translateCache;

  @Nonnull
  protected final Cache>> lookupCache;

  @SuppressWarnings("unchecked")
  public CachingTerminologyService(@Nonnull final TerminologyClient terminologyClient,
      @Nonnull final HttpClientCachingConfiguration configuration,
      @Nonnull final Closeable... resourcesToClose) {
    super(terminologyClient, resourcesToClose);
    this.configuration = configuration;
    // register manager as a closeable resource
    cacheManager = registerResource(buildCacheManager());
    validateCodeCache = (Cache>) buildCache(cacheManager,
        VALIDATE_CODE_CACHE_NAME);
    subsumesCache = (Cache>) buildCache(
        cacheManager, SUBSUMES_CACHE_NAME);
    translateCache = (Cache>>) buildCache(
        cacheManager, TRANSLATE_CACHE_NAME);
    lookupCache = (Cache>>) buildCache(
        cacheManager, LOOKUP_CACHE_NAME);
  }

  @Override
  public boolean validateCode(@Nonnull final String valueSetUrl, @Nonnull final Coding coding) {
    final ValidateCodeParameters parameters = new ValidateCodeParameters(valueSetUrl,
        ImmutableCoding.of(coding));
    final ValidateCodeExecutor executor = new ValidateCodeExecutor(terminologyClient, parameters);
    return getFromCache(validateCodeCache, parameters, executor);
  }

  @Nonnull
  @Override
  public List translate(@Nonnull final Coding coding,
      @Nonnull final String conceptMapUrl,
      final boolean reverse, @Nullable final String target) {
    final TranslateParameters parameters = new TranslateParameters(ImmutableCoding.of(coding),
        conceptMapUrl, reverse, target);
    final TranslateExecutor executor = new TranslateExecutor(terminologyClient, parameters);
    return getFromCache(translateCache, parameters, executor);
  }

  @Nonnull
  @Override
  public ConceptSubsumptionOutcome subsumes(@Nonnull final Coding codingA,
      @Nonnull final Coding codingB) {
    final SubsumesParameters parameters = new SubsumesParameters(
        ImmutableCoding.of(codingA), ImmutableCoding.of(codingB));
    final SubsumesExecutor executor = new SubsumesExecutor(terminologyClient, parameters);
    return getFromCache(subsumesCache, parameters, executor);
  }

  @Nonnull
  @Override
  public List lookup(@Nonnull final Coding coding,
      @Nullable final String property, @Nullable final String acceptLanguage) {
    final LookupParameters parameters = new LookupParameters(ImmutableCoding.of(coding), property,
            acceptLanguage);
    final LookupExecutor executor = new LookupExecutor(terminologyClient, parameters);
    return getFromCache(lookupCache, parameters, executor);
  }

  /**
   * Gets the result of an operation from the cache, or fetches a new result if the cache is empty
   * or expired.
   *
   * @param cache The cache being used for this operation
   * @param parameters The parameters for the operation
   * @param operation A {@link TerminologyOperation} that provides the behavior specific to the type
   * of operation
   * @param  The type of the parameters that are input to the operation
   * @param  The type of the response returned by the terminology client
   * @param  The type of the final result that is extracted from the response
   * @return The operation result
   */
  private  ResultType getFromCache(
      @Nonnull final Cache> cache,
      @Nonnull final ParametersType parameters,
      @Nonnull final TerminologyOperation operation
  ) {
    final int key = parameters.hashCode();
    final TerminologyResult cached = cache.get(key);

    if (cached == null) {
      // Cache miss.
      final TerminologyResult result = fetch(operation, Optional.empty());
      cache.put(key, result);
      return result.getData();
    } else {
      final boolean expired =
          cached.getExpires() != null && System.currentTimeMillis() > cached.getExpires();
      return requireNonNull(cache.compute(key,
          (k, v) -> expired
                    // Cache hit, but the entry is expired and needs to be revalidated.
                    ? fetch(operation, Optional.ofNullable(v))
                    // Cache hit, and the entry is still valid.
                    : v)).getData();
    }
  }

  /**
   * Fetches an operation result, or revalidates it if the cached result is still valid.
   *
   * @param operation A {@link TerminologyOperation} that provides the behavior specific to the type
   * of operation
   * @param cached A previously cached value
   * @param  The type of the response returned by the terminology client
   * @param  The type of the final result that is extracted from the response
   * @return The operation result
   */
  private  TerminologyResult fetch(
      @Nonnull final TerminologyOperation operation,
      @Nonnull final Optional> cached) {
    final Optional invalidResult = operation.validate();
    if (invalidResult.isPresent()) {
      // If the parameters fail validation, cache the invalid result forever.
      return new TerminologyResult<>(invalidResult.get(), null, null, false);
    }

    final IOperationUntypedWithInput request = operation.buildRequest();

    // Add an If-None-Match header if the cached result is accompanied by an ETag.
    cached.flatMap(c -> Optional.ofNullable(c.getETag()))
        .ifPresent(eTag -> request.withAdditionalHeader(IF_NONE_MATCH_HEADER_NAME, eTag));

    // We use a default expiry if the server does not provide one.
    final Optional defaultExpires = Optional.of(
        secondsFromNow(configuration.getDefaultExpiry()));
    // There may be a configured override expiry.
    final Optional overrideExpires = Optional.ofNullable(
        configuration.getOverrideExpiry()).map(CachingTerminologyService::secondsFromNow);

    try {
      final MethodOutcome outcome = request.returnMethodOutcome().execute();

      // If the response was 200 OK, use the data from the fresh response.
      @SuppressWarnings("unchecked") final ResultType result = operation.extractResult(
          (ResponseType) outcome.getResource());
      final Optional newETag = getSingularHeader(outcome, ETAG_HEADER_NAME);
      final Optional serverExpires = getExpires(outcome);
      return new TerminologyResult<>(
          result,
          newETag.orElse(null),
          // Expiry values are used in this order:
          // 1. The override expiry, if present;
          // 2. The expiry provided by the server, if present, then;
          // 3. The default expiry.
          resolveExpires(overrideExpires, serverExpires, defaultExpires),
          false);

    } catch (final NotModifiedException e) {
      // If the response was 304 Not Modified, use the data from the cached response and update 
      // the ETag and expiry.
      final Optional newETag = getSingularHeader(e.getResponseHeaders(), ETAG_HEADER_NAME);
      final TerminologyResult previous = checkPresent(cached);
      final Optional serverExpires = getExpires(e.getResponseHeaders());
      final Optional previousExpiry = Optional.ofNullable(previous.getExpires());
      return new TerminologyResult<>(previous.getData(),
          newETag.orElse(previous.getETag()),
          // Expiry values are used in this order:
          // 1. The override expiry, if present;
          // 2. The expiry from the 304 response, if present;
          // 3. The expiry from the cached response, if present, then;
          // 4. The default expiry.
          resolveExpires(overrideExpires, serverExpires, previousExpiry, defaultExpires),
          false);

    } catch (final BaseServerResponseException e) {
      // If the terminology server rejects the request as invalid, cache the invalid result for the 
      // amount of time instructed by the server. If there is no such instruction, cache it for the 
      // configured default expiry.
      final Optional serverExpires = getExpires(e.getResponseHeaders());
      final long expires = resolveExpires(serverExpires, defaultExpires);
      final TerminologyResult fallback = new TerminologyResult<>(
          operation.invalidRequestFallback(), null, expires, false);
      return handleError(e, fallback);
    }
  }

  /**
   * @return a new {@link EmbeddedCacheManager} instance appropriate for the specific implementation
   */
  protected abstract EmbeddedCacheManager buildCacheManager();

  /**
   * @param cacheManager the {@link EmbeddedCacheManager} to use to construct the cache
   * @param cacheName a name for the cache
   * @return a new {@link Cache} instance appropriate for the specific implementation
   */
  protected abstract Cache buildCache(@Nonnull final EmbeddedCacheManager cacheManager,
      @Nonnull final String cacheName);

  @SuppressWarnings("SameParameterValue")
  @Nonnull
  private static Optional getSingularHeader(@Nonnull final MethodOutcome outcome,
      @Nonnull final String headerName) {
    return getSingularHeader(outcome.getResponseHeaders(), headerName);
  }

  @Nonnull
  private static Optional getSingularHeader(
      @Nullable final Map> headers,
      @Nonnull final String headerName) {
    return Optional.ofNullable(headers)
        .map(rh -> rh.get(headerName))
        .flatMap(h -> h.stream().findFirst());
  }

  @Nonnull
  private static Optional getExpires(@Nonnull final MethodOutcome outcome) {
    return getExpires(outcome.getResponseHeaders());
  }

  @Nonnull
  private static Optional getExpires(@Nullable final Map> headers) {
    final Optional maxAge = getSingularHeader(headers, CACHE_CONTROL_HEADER_NAME)
        .map(CacheControl::valueOf)
        .map(CacheControl::getMaxAge);
    return maxAge.map(CachingTerminologyService::secondsFromNow);
  }

  private static long secondsFromNow(final int seconds) {
    return Instant.now().plusSeconds(seconds).toEpochMilli();
  }

  @SafeVarargs
  private static Long resolveExpires(final Optional... orderedExpiryValues) {
    // Go through each of the expiry values and combine them using OR logic. If the final value is 
    // not present, throw an error.
    return Arrays.stream(orderedExpiryValues)
        .reduce((acc, v) -> acc.or(() -> v))
        .orElseThrow()
        .orElseThrow();
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy