Repository: aehrc/pathling (language: python)

Issue:
Repeated sessions using the same disk cache location can cause corruption
This can cause errors such as:

```
ERROR Compactor: There was a problem moving indexes for compactor with file 202
java.lang.IllegalStateException: Too many records for this key (short overflow)
	at org.infinispan.persistence.sifs.IndexNode.copyWith(IndexNode.java:654)
	at org.infinispan.persistence.sifs.IndexNode.setPosition(IndexNode.java:381)
	at org.infinispan.persistence.sifs.Index$Segment.accept(Index.java:372)
	at org.infinispan.persistence.sifs.Index$Segment.accept(Index.java:242)
	at io.reactivex.rxjava3.internal.subscribers.LambdaSubscriber.onNext(LambdaSubscriber.java:65)
	at io.reactivex.rxjava3.internal.operators.flowable.FlowableObserveOn$ObserveOnSubscriber.runAsync(FlowableObserveOn.java:402)
	at io.reactivex.rxjava3.internal.operators.flowable.FlowableObserveOn$BaseObserveOnSubscriber.run(FlowableObserveOn.java:176)
	at io.reactivex.rxjava3.internal.schedulers.ExecutorScheduler$ExecutorWorker$BooleanRunnable.run(ExecutorScheduler.java:322)
	at io.reactivex.rxjava3.internal.schedulers.ExecutorScheduler$ExecutorWorker.runEager(ExecutorScheduler.java:287)
	at io.reactivex.rxjava3.internal.schedulers.ExecutorScheduler$ExecutorWorker.run(ExecutorScheduler.java:248)
	at org.jboss.threads.ContextClassLoaderSavingRunnable.run(ContextClassLoaderSavingRunnable.java:35)
	at org.jboss.threads.EnhancedQueueExecutor.safeRun(EnhancedQueueExecutor.java:1982)
	at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.doRunTask(EnhancedQueueExecutor.java:1486)
	at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1377)
	at java.base/java.lang.Thread.run(Thread.java:829)
23/02/15 16:55:19 ERROR Compactor: There was a problem moving indexes for compactor with file 202
java.lang.IllegalStateException: Too many records for this key (short overflow)
	at org.infinispan.persistence.sifs.IndexNode.copyWith(IndexNode.java:654)
	at org.infinispan.persistence.sifs.IndexNode.setPosition(IndexNode.java:381)
	at org.infinispan.persistence.sifs.Index$Segment.accept(Index.java:372)
	at org.infinispan.persistence.sifs.Index$Segment.accept(Index.java:242)
	at io.reactivex.rxjava3.internal.subscribers.LambdaSubscriber.onNext(LambdaSubscriber.java:65)
	at io.reactivex.rxjava3.internal.operators.flowable.FlowableObserveOn$ObserveOnSubscriber.runAsync(FlowableObserveOn.java:402)
	at io.reactivex.rxjava3.internal.operators.flowable.FlowableObserveOn$BaseObserveOnSubscriber.run(FlowableObserveOn.java:176)
	at io.reactivex.rxjava3.internal.schedulers.ExecutorScheduler$ExecutorWorker$BooleanRunnable.run(ExecutorScheduler.java:322)
	at io.reactivex.rxjava3.internal.schedulers.ExecutorScheduler$ExecutorWorker.runEager(ExecutorScheduler.java:287)
	at io.reactivex.rxjava3.internal.schedulers.ExecutorScheduler$ExecutorWorker.run(ExecutorScheduler.java:248)
```

I suspect that this might be because there are closeable dependencies of the context that are not being properly closed upon shutdown of the JVM. One possible solution to this might be to register JVM shutdown hooks that close these resources.


Discussion / hints:

Fix patch:
diff --git a/terminology/src/main/java/au/csiro/pathling/fhir/DefaultTerminologyClient.java b/terminology/src/main/java/au/csiro/pathling/fhir/DefaultTerminologyClient.java
index 68aaedb9de..0dcda333fc 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhir/DefaultTerminologyClient.java
+++ b/terminology/src/main/java/au/csiro/pathling/fhir/DefaultTerminologyClient.java
@@ -17,6 +17,7 @@
 
 package au.csiro.pathling.fhir;
 
+import au.csiro.pathling.utilities.ResourceCloser;
 import ca.uhn.fhir.rest.client.api.IGenericClient;
 import ca.uhn.fhir.rest.gclient.IOperationUntypedWithInput;
 import javax.annotation.Nonnull;
@@ -29,16 +30,19 @@
 import org.hl7.fhir.r4.model.StringType;
 import org.hl7.fhir.r4.model.UriType;
 import org.hl7.fhir.r4.model.ValueSet;
+import java.io.Closeable;
 
 /**
  * An implementation of {@link TerminologyClient} that uses cacheable GET requests.
  */
-class DefaultTerminologyClient implements TerminologyClient {
+class DefaultTerminologyClient extends ResourceCloser implements TerminologyClient {
 
   @Nonnull
   final IGenericClient fhirClient;
 
-  DefaultTerminologyClient(@Nonnull final IGenericClient fhirClient) {
+  DefaultTerminologyClient(@Nonnull final IGenericClient fhirClient, @Nonnull final Closeable...
+      resourcesToAdopt) {
+    super(resourcesToAdopt);
     this.fhirClient = fhirClient;
   }
 
diff --git a/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient.java b/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient.java
index 1777884487..970853d378 100644
--- a/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient.java
+++ b/terminology/src/main/java/au/csiro/pathling/fhir/TerminologyClient.java
@@ -39,6 +39,7 @@
 import org.hl7.fhir.r4.model.ValueSet;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import java.io.Closeable;
 
 /**
  * The client interface to FHIR terminology operations.
@@ -46,7 +47,7 @@
  * @author John Grimes
  * @author Piotr Szul
  */
-public interface TerminologyClient {
+public interface TerminologyClient extends Closeable {
 
   Logger log = LoggerFactory.getLogger(TerminologyClient.class);
 
@@ -56,8 +57,7 @@ public interface TerminologyClient {
    * @param version the version of the code system to validate against
    * @param code the code to validate
    * @return a {@link Parameters} resource
-   * @see <a
-   * href="https://www.hl7.org/fhir/R4/valueset-operation-validate-code.html">ValueSet/$validate-code</a>
+   * @see <a href="https://www.hl7.org/fhir/R4/valueset-operation-validate-code.html">ValueSet/$validate-code</a>
    */
   @Operation(name = "$validate-code", type = ValueSet.class, idempotent = true)
   @Nonnull
@@ -76,8 +76,7 @@ Parameters validateCode(
    * @param version the version of the code system to validate against
    * @param code the code to validate
    * @return an {@link IOperationUntypedWithInput} that can be customized and executed later
-   * @see <a
-   * href="https://www.hl7.org/fhir/R4/valueset-operation-validate-code.html">ValueSet/$validate-code</a>
+   * @see <a href="https://www.hl7.org/fhir/R4/valueset-operation-validate-code.html">ValueSet/$validate-code</a>
    */
   IOperationUntypedWithInput<Parameters> buildValidateCode(@Nonnull UriType url,
       @Nonnull UriType system,
@@ -91,8 +90,7 @@ IOperationUntypedWithInput<Parameters> buildValidateCode(@Nonnull UriType url,
    * @param reverse if true, the translation will be reversed
    * @param target the URL of the value set within which the translation is sought
    * @return a {@link Parameters} resource
-   * @see <a
-   * href="https://www.hl7.org/fhir/R4/operation-conceptmap-translate.html">ConceptMap/$translate</a>
+   * @see <a href="https://www.hl7.org/fhir/R4/operation-conceptmap-translate.html">ConceptMap/$translate</a>
    */
   @Operation(name = "$translate", type = CodeSystem.class, idempotent = true)
   @Nonnull
@@ -115,8 +113,7 @@ Parameters translate(
    * @param reverse if true, the translation will be reversed
    * @param target the URL of the value set within which the translation is sought
    * @return an {@link IOperationUntypedWithInput} that can be customized and executed later
-   * @see <a
-   * href="https://www.hl7.org/fhir/R4/operation-conceptmap-translate.html">ConceptMap/$translate</a>
+   * @see <a href="https://www.hl7.org/fhir/R4/operation-conceptmap-translate.html">ConceptMap/$translate</a>
    */
   @Nonnull
   IOperationUntypedWithInput<Parameters> buildTranslate(@Nonnull UriType url,
@@ -130,8 +127,7 @@ IOperationUntypedWithInput<Parameters> buildTranslate(@Nonnull UriType url,
    * @param system the system of the codes being tested
    * @param version the version of the code system that the codes are from
    * @return a {@link Parameters} resource
-   * @see <a
-   * href="https://www.hl7.org/fhir/R4/codesystem-operation-subsumes.html">CodeSystem/$subsumes</a>
+   * @see <a href="https://www.hl7.org/fhir/R4/codesystem-operation-subsumes.html">CodeSystem/$subsumes</a>
    */
   @Operation(name = "$subsumes", type = CodeSystem.class, idempotent = true)
   @Nonnull
@@ -150,8 +146,7 @@ Parameters subsumes(
    * @param system the system of the codes being tested
    * @param version the version of the code system that the codes are from
    * @return an {@link IOperationUntypedWithInput} that can be customized and executed later
-   * @see <a
-   * href="https://www.hl7.org/fhir/R4/codesystem-operation-subsumes.html">CodeSystem/$subsumes</a>
+   * @see <a href="https://www.hl7.org/fhir/R4/codesystem-operation-subsumes.html">CodeSystem/$subsumes</a>
    */
   @Nonnull
   IOperationUntypedWithInput<Parameters> buildSubsumes(@Nonnull CodeType codeA,
@@ -163,8 +158,7 @@ IOperationUntypedWithInput<Parameters> buildSubsumes(@Nonnull CodeType codeA,
    * @param code the code to lookup
    * @param property the property or properties to be returned in the response
    * @return a {@link Parameters} resource
-   * @see <a
-   * href="https://www.hl7.org/fhir/R4/codesystem-operation-lookup.html">CodeSystem/$lookup</a>
+   * @see <a href="https://www.hl7.org/fhir/R4/codesystem-operation-lookup.html">CodeSystem/$lookup</a>
    */
   @Operation(name = "$lookup", type = CodeSystem.class, idempotent = true)
   @Nonnull
@@ -183,8 +177,7 @@ Parameters lookup(
    * @param code the code to lookup
    * @param property the property or properties to be returned in the response
    * @return an {@link IOperationUntypedWithInput} that can be customized and executed later
-   * @see <a
-   * href="https://www.hl7.org/fhir/R4/codesystem-operation-lookup.html">CodeSystem/$lookup</a>
+   * @see <a href="https://www.hl7.org/fhir/R4/codesystem-operation-lookup.html">CodeSystem/$lookup</a>
    */
   @Nonnull
   IOperationUntypedWithInput<Parameters> buildLookup(@Nonnull UriType system,
@@ -223,9 +216,13 @@ static TerminologyClient build(@Nonnull final FhirContext fhirContext,
     // sending them.
     final TerminologyAuthConfiguration authConfig = terminologyConfiguration.getAuthentication();
     if (authConfig.isEnabled()) {
-      genericClient.registerInterceptor(new ClientAuthInterceptor(authConfig));
+      final ClientAuthInterceptor clientAuthInterceptor = new ClientAuthInterceptor(authConfig);
+      genericClient.registerInterceptor(clientAuthInterceptor);
+      // pass the client auth interceptor as a resource to close when the client is closed
+      return new DefaultTerminologyClient(genericClient, clientAuthInterceptor);
+    } else {
+      return new DefaultTerminologyClient(genericClient);
     }
-    return new DefaultTerminologyClient(genericClient);
   }
 
   @Nonnull
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/BaseTerminologyService.java b/terminology/src/main/java/au/csiro/pathling/terminology/BaseTerminologyService.java
index d57ab08ae5..4ae8d17773 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/BaseTerminologyService.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/BaseTerminologyService.java
@@ -17,12 +17,10 @@
 
 package au.csiro.pathling.terminology;
 
-import static java.util.Objects.nonNull;
-
 import au.csiro.pathling.fhir.TerminologyClient;
+import au.csiro.pathling.utilities.ResourceCloser;
 import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
 import java.io.Closeable;
-import java.io.IOException;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
@@ -31,18 +29,15 @@
  *
  * @author John Grimes
  */
-public abstract class BaseTerminologyService implements TerminologyService, Closeable {
+public abstract class BaseTerminologyService extends ResourceCloser implements TerminologyService {
 
   @Nonnull
   protected final TerminologyClient terminologyClient;
 
-  @Nullable
-  protected final Closeable toClose;
-
   public BaseTerminologyService(@Nonnull final TerminologyClient terminologyClient,
-      @Nullable final Closeable toClose) {
+      @Nonnull final Closeable... resourcesToClose) {
+    super(resourcesToClose);
     this.terminologyClient = terminologyClient;
-    this.toClose = toClose;
   }
 
   /**
@@ -64,12 +59,4 @@ public static <ResultType> ResultType handleError(@Nonnull final BaseServerRespo
       throw e;
     }
   }
-
-  @Override
-  public void close() throws IOException {
-    if (nonNull(toClose)) {
-      toClose.close();
-    }
-  }
-
 }
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService.java b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService.java
index 2dce65d955..0c2f6448ec 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyService.java
@@ -49,10 +49,10 @@
 public class DefaultTerminologyService extends BaseTerminologyService {
 
   public DefaultTerminologyService(@Nonnull final TerminologyClient terminologyClient,
-      @Nullable final Closeable toClose) {
-    super(terminologyClient, toClose);
+      @Nonnull final Closeable... resourcesToClose) {
+    super(terminologyClient, resourcesToClose);
   }
-
+  
   @Override
   public boolean validateCode(@Nonnull final String valueSetUrl, @Nonnull final Coding coding) {
     final ValidateCodeParameters parameters = new ValidateCodeParameters(valueSetUrl,
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyServiceFactory.java b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyServiceFactory.java
index 6c9889568b..869fae213f 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyServiceFactory.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/DefaultTerminologyServiceFactory.java
@@ -97,19 +97,21 @@ private TerminologyService createService() {
       // If caching is enabled and storage type is disk, use a persistent caching terminology 
       // service implementation.
       log.debug("Creating PersistentCachingTerminologyService with cache config: {}", cacheConfig);
-      return new PersistentCachingTerminologyService(terminologyClient, httpClient, cacheConfig);
+      return new PersistentCachingTerminologyService(terminologyClient, cacheConfig, httpClient,
+          terminologyClient);
 
     } else if (cacheConfig.isEnabled() && cacheConfig.getStorageType().equals(
         HttpClientCachingStorageType.MEMORY)) {
       // If caching is enabled and storage type is memory, use an in-memory caching terminology
       // service implementation.
       log.debug("Creating InMemoryCachingTerminologyService with cache config: {}", cacheConfig);
-      return new InMemoryCachingTerminologyService(terminologyClient, httpClient, cacheConfig);
+      return new InMemoryCachingTerminologyService(terminologyClient, cacheConfig, httpClient,
+          terminologyClient);
 
     } else {
       // If caching is disabled, use a terminology service implementation that does not cache.
       log.debug("Creating DefaultTerminologyService with no caching");
-      return new DefaultTerminologyService(terminologyClient, httpClient);
+      return new DefaultTerminologyService(terminologyClient, httpClient, terminologyClient);
     }
   }
 
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/caching/CachingTerminologyService.java b/terminology/src/main/java/au/csiro/pathling/terminology/caching/CachingTerminologyService.java
index 74223d52eb..0ac628e3ee 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/caching/CachingTerminologyService.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/caching/CachingTerminologyService.java
@@ -91,11 +91,12 @@ public abstract class CachingTerminologyService extends BaseTerminologyService {
 
   @SuppressWarnings("unchecked")
   public CachingTerminologyService(@Nonnull final TerminologyClient terminologyClient,
-      @Nullable final Closeable toClose,
-      @Nonnull final HttpClientCachingConfiguration configuration) {
-    super(terminologyClient, toClose);
+      @Nonnull final HttpClientCachingConfiguration configuration,
+      @Nonnull final Closeable... resourcesToClose) {
+    super(terminologyClient, resourcesToClose);
     this.configuration = configuration;
-    cacheManager = buildCacheManager();
+    // register manager as a closeable resource
+    cacheManager = registerResource(buildCacheManager());
     validateCodeCache = (Cache<Integer, TerminologyResult<Boolean>>) buildCache(cacheManager,
         VALIDATE_CODE_CACHE_NAME);
     subsumesCache = (Cache<Integer, TerminologyResult<ConceptSubsumptionOutcome>>) buildCache(
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/caching/InMemoryCachingTerminologyService.java b/terminology/src/main/java/au/csiro/pathling/terminology/caching/InMemoryCachingTerminologyService.java
index 6890e92029..80abf4de30 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/caching/InMemoryCachingTerminologyService.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/caching/InMemoryCachingTerminologyService.java
@@ -21,7 +21,6 @@
 import au.csiro.pathling.fhir.TerminologyClient;
 import java.io.Closeable;
 import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
 import org.infinispan.Cache;
 import org.infinispan.configuration.cache.Configuration;
 import org.infinispan.configuration.cache.ConfigurationBuilder;
@@ -37,9 +36,9 @@
 public class InMemoryCachingTerminologyService extends CachingTerminologyService {
 
   public InMemoryCachingTerminologyService(@Nonnull final TerminologyClient terminologyClient,
-      @Nullable final Closeable toClose,
-      @Nonnull final HttpClientCachingConfiguration configuration) {
-    super(terminologyClient, toClose, configuration);
+      @Nonnull final HttpClientCachingConfiguration configuration,
+      @Nonnull final Closeable... resourcesToClose) {
+    super(terminologyClient, configuration, resourcesToClose);
   }
 
   @Override
diff --git a/terminology/src/main/java/au/csiro/pathling/terminology/caching/PersistentCachingTerminologyService.java b/terminology/src/main/java/au/csiro/pathling/terminology/caching/PersistentCachingTerminologyService.java
index e758425da3..6958dd872b 100644
--- a/terminology/src/main/java/au/csiro/pathling/terminology/caching/PersistentCachingTerminologyService.java
+++ b/terminology/src/main/java/au/csiro/pathling/terminology/caching/PersistentCachingTerminologyService.java
@@ -24,7 +24,7 @@
 import java.io.Closeable;
 import java.nio.file.Path;
 import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
+import lombok.extern.slf4j.Slf4j;
 import org.infinispan.Cache;
 import org.infinispan.commons.marshall.JavaSerializationMarshaller;
 import org.infinispan.configuration.cache.Configuration;
@@ -42,15 +42,16 @@
  *
  * @author John Grimes
  */
+@Slf4j
 public class PersistentCachingTerminologyService extends CachingTerminologyService {
 
   private static final String DATA_DIRECTORY = "data";
   private static final String INDEX_DIRECTORY = "index";
 
   public PersistentCachingTerminologyService(@Nonnull final TerminologyClient terminologyClient,
-      @Nullable final Closeable toClose,
-      @Nonnull final HttpClientCachingConfiguration configuration) {
-    super(terminologyClient, toClose, configuration);
+      @Nonnull final HttpClientCachingConfiguration configuration,
+      @Nonnull final Closeable... resourcesToClose) {
+    super(terminologyClient, configuration, resourcesToClose);
   }
 
   @Override
diff --git a/utilities/pom.xml b/utilities/pom.xml
index 1fe1b3b9f5..83185d64c4 100644
--- a/utilities/pom.xml
+++ b/utilities/pom.xml
@@ -67,6 +67,10 @@
       <artifactId>scala-library</artifactId>
       <scope>provided</scope>
     </dependency>
+    <dependency>
+      <groupId>commons-io</groupId>
+      <artifactId>commons-io</artifactId>
+    </dependency>
 
     <dependency>
       <groupId>org.junit.jupiter</groupId>
@@ -84,13 +88,13 @@
       <scope>test</scope>
     </dependency>
     <dependency>
-      <groupId>commons-io</groupId>
-      <artifactId>commons-io</artifactId>
+      <groupId>com.google.code.gson</groupId>
+      <artifactId>gson</artifactId>
       <scope>test</scope>
     </dependency>
     <dependency>
-      <groupId>com.google.code.gson</groupId>
-      <artifactId>gson</artifactId>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-core</artifactId>
       <scope>test</scope>
     </dependency>
   </dependencies>
diff --git a/utilities/src/main/java/au/csiro/pathling/utilities/ObjectHolder.java b/utilities/src/main/java/au/csiro/pathling/utilities/ObjectHolder.java
index da34256bf9..2d9a419695 100644
--- a/utilities/src/main/java/au/csiro/pathling/utilities/ObjectHolder.java
+++ b/utilities/src/main/java/au/csiro/pathling/utilities/ObjectHolder.java
@@ -19,6 +19,7 @@
 
 import static au.csiro.pathling.utilities.Preconditions.check;
 import static java.util.Objects.requireNonNull;
+import static org.apache.commons.io.IOUtils.closeQuietly;
 
 import java.io.Closeable;
 import java.io.Serializable;
@@ -26,6 +27,7 @@
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 import org.apache.curator.utils.CloseableUtils;
+import org.apache.hadoop.util.ShutdownHookManager;
 
 /**
  * Represents a scoped storage for instances of type V that can be created from configuration of
@@ -51,16 +53,24 @@ public interface ObjectHolder<C extends Serializable, V> {
    */
   void reset();
 
-  class SingletonHolder<C extends Serializable, V> implements ObjectHolder<C, V> {
+  class SingletonHolder<C extends Serializable, V> implements ObjectHolder<C, V>, Closeable {
 
     @Nonnull
-    final Function<C, V> constructor;
+    private final Function<C, V> constructor;
 
     @Nullable
     private C configuration;
     @Nullable
     private V instance;
 
+    /**
+     * The default priority of the shutdown hook. This is set to be executed after the SparkContext
+     * shutdown hook (which has priority 50) and before temporary dirs are cleaned up (priority 25).
+     * See {@link org.apache.spark.util.ShutdownHookManager} for more info on Spark related shutdown
+     * hooks.
+     */
+    public static int DEFAULT_SHUTDOWN_HOOK_PRIORITY = 30;
+
     private SingletonHolder(@Nonnull final Function<C, V> constructor) {
       this.constructor = constructor;
     }
@@ -86,6 +96,15 @@ public synchronized void reset() {
         configuration = null;
       }
     }
+
+    /**
+     * Closes this singleton and releases any system resources associated with it. If the singleton
+     * is already closed then invoking this method has no effect.
+     */
+    @Override
+    public void close() {
+      reset();
+    }
   }
 
   /**
@@ -93,12 +112,35 @@ public synchronized void reset() {
    * another instance with different configuration will fail.
    *
    * @param constructor the function that creates new instance from the configuration.
+   * @param closeOnShutdown whether to close the instance on JVM shutdown.
+   * @param shutdownHookPriority the priority of the shutdown hook.
+   * @param <C> the type of the configuration.
+   * @param <V> the type of the instance.
+   * @return the singleton holder.
+   */
+  static <C extends Serializable, V> ObjectHolder<C, V> singleton(
+      @Nonnull final Function<C, V> constructor, final boolean closeOnShutdown,
+      final int shutdownHookPriority) {
+    final SingletonHolder<C, V> singletonHolder = new SingletonHolder<>(constructor);
+    if (closeOnShutdown) {
+      ShutdownHookManager.get().addShutdownHook(() -> closeQuietly(singletonHolder),
+          shutdownHookPriority);
+    }
+    return singletonHolder;
+  }
+
+  /**
+   * Creates the holder allowing a single instance to be created per process. Attempts to create
+   * another instance with different configuration will fail. The instance will be closed on JVM
+   * shutdown.
+   *
+   * @param constructor the function that creates new instance from the configuration.
    * @param <C> the type of the configuration.
    * @param <V> the type of the instance.
    * @return the singleton holder.
    */
   static <C extends Serializable, V> ObjectHolder<C, V> singleton(
       @Nonnull final Function<C, V> constructor) {
-    return new SingletonHolder<>(constructor);
+    return singleton(constructor, true, SingletonHolder.DEFAULT_SHUTDOWN_HOOK_PRIORITY);
   }
 }
diff --git a/utilities/src/main/java/au/csiro/pathling/utilities/ResourceCloser.java b/utilities/src/main/java/au/csiro/pathling/utilities/ResourceCloser.java
new file mode 100644
index 0000000000..c9e47cc4d2
--- /dev/null
+++ b/utilities/src/main/java/au/csiro/pathling/utilities/ResourceCloser.java
@@ -0,0 +1,76 @@
+/*
+ * 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.utilities;
+
+
+import static org.apache.hadoop.shaded.org.apache.commons.io.IOUtils.closeQuietly;
+
+import java.io.Closeable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import javax.annotation.Nonnull;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * A {@link Closeable} that closes a collection of underlying resources.
+ *
+ * @author Piotr Szul
+ */
+@Slf4j
+public class ResourceCloser implements Closeable {
+
+  @Nonnull
+  private final List<Closeable> resourcesToClose;
+
+
+  /**
+   * Constructs a new {@link ResourceCloser} with provided resources to close.
+   *
+   * @param resourcesToClose the resources to close
+   */
+  protected ResourceCloser(@Nonnull final Closeable... resourcesToClose) {
+    this.resourcesToClose = new ArrayList<>(Arrays.asList(resourcesToClose));
+  }
+
+  /**
+   * Adds a resource to the list of resources to close.
+   *
+   * @param resource the resource to close
+   * @param <T> the type of the resource
+   * @return the resource
+   */
+  protected <T extends Closeable> T registerResource(@Nonnull final T resource) {
+    synchronized (resourcesToClose) {
+      resourcesToClose.add(resource);
+      return resource;
+    }
+  }
+
+  @Override
+  public void close() {
+    log.debug("Closing {} resources for: {}", resourcesToClose.size(), this);
+    synchronized (resourcesToClose) {
+      for (final Closeable closeable : resourcesToClose) {
+        log.debug("Closing resource: {} in: {}", closeable, this);
+        closeQuietly(closeable,
+            ex -> log.warn("Ignoring an error while closing resource: " + closeable, ex));
+      }
+    }
+  }
+}


Test patch:
diff --git a/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyServiceTest.java b/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyServiceTest.java
index ced1108709..57f2d6f150 100644
--- a/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyServiceTest.java
+++ b/terminology/src/test/java/au/csiro/pathling/terminology/DefaultTerminologyServiceTest.java
@@ -99,7 +99,7 @@ private static Parameters translation(@Nonnull final Translation... entries) {
   void setUp() {
     terminologyClient = mock(TerminologyClient.class);
     terminologyService = new DefaultTerminologyService(
-        terminologyClient, null);
+        terminologyClient);
   }
 
   @Test
diff --git a/utilities/src/test/java/au/csiro/pathling/utilities/ResourceCloserTest.java b/utilities/src/test/java/au/csiro/pathling/utilities/ResourceCloserTest.java
new file mode 100644
index 0000000000..ea6fddddc7
--- /dev/null
+++ b/utilities/src/test/java/au/csiro/pathling/utilities/ResourceCloserTest.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright © 2018-2022, Commonwealth Scientific and Industrial Research
+ * Organisation (CSIRO) ABN 41 687 119 230. Licensed under the CSIRO Open Source
+ * Software Licence Agreement.
+ */
+
+package au.csiro.pathling.utilities;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import java.io.Closeable;
+import java.io.IOException;
+import org.junit.jupiter.api.Test;
+
+class ResourceCloserTest {
+
+  @Test
+  void testEmptyListOfResourceIsNoop() {
+    new ResourceCloser().close();
+  }
+
+  @Test
+  void testClosesAllRegisteredResources() throws IOException {
+    final Closeable resource1 = mock(Closeable.class);
+    final Closeable resource2 = mock(Closeable.class);
+    final Closeable resource3 = mock(Closeable.class);
+
+    doThrow(new IOException("Test exception")).when(resource2).close();
+    
+    final ResourceCloser resourceCloser = new ResourceCloser(resource1, resource2);
+    assertEquals(resource3, resourceCloser.registerResource(resource3));
+    resourceCloser.close();
+    verify(resource1, times(1)).close();
+    verify(resource2, times(1)).close();
+    verify(resource3, times(1)).close();
+    verifyNoMoreInteractions(resource1, resource2, resource3);
+  }
+}