From a55c8f20a9cda619a9e407dc7ad0000ca42ef4d7 Mon Sep 17 00:00:00 2001 From: Patrick Werner Date: Thu, 9 Oct 2025 14:15:17 +0200 Subject: [PATCH 1/2] make thread count configurable (#868) * feat: add configuration for reindex and expunge thread counts * fix formatting --- .../ca/uhn/fhir/jpa/starter/AppProperties.java | 18 ++++++++++++++++++ .../starter/common/FhirServerConfigCommon.java | 15 +++++++++++++++ src/main/resources/application.yaml | 5 +++++ 3 files changed, 38 insertions(+) diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java index f10f693..ed32f51 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java @@ -118,6 +118,8 @@ public class AppProperties { private Map remote_terminology_service = null; private Boolean match_url_cache_enabled = false; private Boolean index_storage_optimized = false; + private Integer reindex_thread_count = null; + private Integer expunge_thread_count = null; public List getCustomInterceptorClasses() { return custom_interceptor_classes; @@ -785,6 +787,22 @@ public class AppProperties { index_storage_optimized = theIndex_storage_optimized; } + public Integer getReindex_thread_count() { + return reindex_thread_count; + } + + public void setReindex_thread_count(Integer reindex_thread_count) { + this.reindex_thread_count = reindex_thread_count; + } + + public Integer getExpunge_thread_count() { + return expunge_thread_count; + } + + public void setExpunge_thread_count(Integer expunge_thread_count) { + this.expunge_thread_count = expunge_thread_count; + } + public JpaStorageSettings.StoreMetaSourceInformationEnum getStore_meta_source_information() { return store_meta_source_information; } diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java index f28a2c6..6ca4834 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java @@ -282,6 +282,21 @@ public class FhirServerConfigCommon { jpaStorageSettings.setIndexOnContainedResources(appProperties.getEnable_index_contained_resource()); jpaStorageSettings.setIndexIdentifierOfType(appProperties.getEnable_index_of_type()); + + // Configure thread counts for reindex and expunge operations + if (appProperties.getReindex_thread_count() != null) { + jpaStorageSettings.setReindexThreadCount(appProperties.getReindex_thread_count()); + ourLog.info( + "Server configured to use {} threads for reindex operations", + appProperties.getReindex_thread_count()); + } + if (appProperties.getExpunge_thread_count() != null) { + jpaStorageSettings.setExpungeThreadCount(appProperties.getExpunge_thread_count()); + ourLog.info( + "Server configured to use {} threads for expunge operations", + appProperties.getExpunge_thread_count()); + } + return jpaStorageSettings; } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 967ba37..af9fdcb 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -279,6 +279,11 @@ hapi: # filter_search_enabled: true # graphql_enabled: true + # Thread pool configuration for maintenance operations + # Defaults to Runtime.getRuntime().availableProcessors() if not specified + # reindex_thread_count: 4 # Number of threads to use for reindex operations + # expunge_thread_count: 4 # Number of threads to use for expunge operations + # ------------------------------------------------------------------------------- # G. Narrative & Validation # ------------------------------------------------------------------------------- From cf003331e4d30e23065022fa771fad828e5d958b Mon Sep 17 00:00:00 2001 From: Shamus Husheer Date: Thu, 9 Oct 2025 08:39:01 -0400 Subject: [PATCH 2/2] Add configurable filesystem binary storage (fix #860) (#864) * Add configurable filesystem binary storage (fix #860) * Refine binary storage configuration handling * Refine binary storage bean wiring * Refine binary storage conditional beans * Add integration coverage for binary storage configs * Exercise binary storage via REST integration tests * Update src/main/resources/application.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Ubuntu Co-authored-by: Ubuntu Co-authored-by: Jens Kristian Villadsen Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 15 + .../uhn/fhir/jpa/starter/AppProperties.java | 26 +- .../common/FhirServerConfigCommon.java | 56 ++- src/main/resources/application.yaml | 8 + .../starter/BinaryStorageIntegrationTest.java | 324 ++++++++++++++++++ ...irServerConfigCommonBinaryStorageTest.java | 96 ++++++ .../resources/binary-storage-test-empty.yaml | 1 + 7 files changed, 516 insertions(+), 10 deletions(-) create mode 100644 src/test/java/ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java create mode 100644 src/test/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommonBinaryStorageTest.java create mode 100644 src/test/resources/binary-storage-test-empty.yaml diff --git a/README.md b/README.md index 5c2e220..96b11b8 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,21 @@ docker run -p 8080:8080 -e hapi.fhir.default_encoding=xml hapiproject/hapi:lates HAPI looks in the environment variables for properties in the [application.yaml](https://github.com/hapifhir/hapi-fhir-jpaserver-starter/blob/master/src/main/resources/application.yaml) file for defaults. +### Binary storage configuration + +To stream large `Binary` payloads to disk instead of the database, configure the starter with filesystem storage properties: + +``` +hapi: + fhir: + binary_storage_enabled: true + binary_storage_mode: FILESYSTEM + binary_storage_filesystem_base_directory: /binstore + # inline_resource_storage_below_size: 131072 # optional override +``` + +When `binary_storage_mode` is set to `FILESYSTEM` and `inline_resource_storage_below_size` is omitted, the starter automatically applies a 102400 byte (100 KB) inline threshold so smaller payloads remain in the database. Ensure the directory you point to is writable by the process (for Docker builds, mount it into the container with appropriate permissions). + ### Configuration via overridden application.yaml file and using Docker You can customize HAPI by telling HAPI to look for the configuration file in a different location, e.g.: diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java index ed32f51..94f12dd 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java @@ -61,7 +61,15 @@ public class AppProperties { private Boolean filter_search_enabled = true; private Boolean graphql_enabled = false; private Boolean binary_storage_enabled = false; - private Integer inline_resource_storage_below_size = 0; + + public enum BinaryStorageMode { + DATABASE, + FILESYSTEM + } + + private BinaryStorageMode binary_storage_mode = BinaryStorageMode.DATABASE; + private String binary_storage_filesystem_base_directory; + private Integer inline_resource_storage_below_size; private Boolean bulk_export_enabled = false; private Boolean bulk_import_enabled = false; private Boolean default_pretty_print = true; @@ -485,6 +493,22 @@ public class AppProperties { this.binary_storage_enabled = binary_storage_enabled; } + public BinaryStorageMode getBinary_storage_mode() { + return binary_storage_mode; + } + + public void setBinary_storage_mode(BinaryStorageMode binary_storage_mode) { + this.binary_storage_mode = binary_storage_mode; + } + + public String getBinary_storage_filesystem_base_directory() { + return binary_storage_filesystem_base_directory; + } + + public void setBinary_storage_filesystem_base_directory(String binary_storage_filesystem_base_directory) { + this.binary_storage_filesystem_base_directory = binary_storage_filesystem_base_directory; + } + public Integer getInline_resource_storage_below_size() { return inline_resource_storage_below_size; } diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java index 6ca4834..da3ce06 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java @@ -1,8 +1,8 @@ package ca.uhn.fhir.jpa.starter.common; import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; -import ca.uhn.fhir.jpa.binary.api.IBinaryStorageSvc; import ca.uhn.fhir.jpa.binstore.DatabaseBinaryContentStorageSvcImpl; +import ca.uhn.fhir.jpa.binstore.FilesystemBinaryStorageSvcImpl; import ca.uhn.fhir.jpa.config.HibernatePropertiesProvider; import ca.uhn.fhir.jpa.model.config.PartitionSettings; import ca.uhn.fhir.jpa.model.config.PartitionSettings.CrossPartitionReferenceMode; @@ -19,10 +19,12 @@ import com.google.common.base.Strings; import org.hl7.fhir.r4.model.Bundle.BundleType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.env.YamlPropertySourceLoader; import org.springframework.context.annotation.*; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.util.Assert; import java.util.HashSet; import java.util.stream.Collectors; @@ -38,6 +40,7 @@ import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; public class FhirServerConfigCommon { private static final Logger ourLog = LoggerFactory.getLogger(FhirServerConfigCommon.class); + private static final int DEFAULT_FILESYSTEM_INLINE_THRESHOLD = 102_400; public FhirServerConfigCommon(AppProperties appProperties) { ourLog.info( @@ -222,8 +225,9 @@ public class FhirServerConfigCommon { jpaStorageSettings.setLastNEnabled(true); } - if (appProperties.getInline_resource_storage_below_size() != 0) { - jpaStorageSettings.setInlineResourceTextBelowSize(appProperties.getInline_resource_storage_below_size()); + Integer inlineResourceThreshold = resolveInlineResourceThreshold(appProperties); + if (inlineResourceThreshold != null && inlineResourceThreshold != 0) { + jpaStorageSettings.setInlineResourceTextBelowSize(inlineResourceThreshold); } jpaStorageSettings.setStoreResourceInHSearchIndex(appProperties.getStore_resource_in_lucene_index_enabled()); @@ -354,16 +358,50 @@ public class FhirServerConfigCommon { return new JpaHibernatePropertiesProvider(myEntityManagerFactory); } - @Lazy @Bean - public IBinaryStorageSvc binaryStorageSvc(AppProperties appProperties) { - DatabaseBinaryContentStorageSvcImpl binaryStorageSvc = new DatabaseBinaryContentStorageSvcImpl(); + @ConditionalOnProperty(prefix = "hapi.fhir", name = "binary_storage_mode", havingValue = "FILESYSTEM") + public FilesystemBinaryStorageSvcImpl filesystemBinaryStorageSvc(AppProperties appProperties) { + String baseDirectory = appProperties.getBinary_storage_filesystem_base_directory(); + Assert.hasText( + baseDirectory, + "binary_storage_filesystem_base_directory must be provided when binary_storage_mode=FILESYSTEM"); - if (appProperties.getMax_binary_size() != null) { - binaryStorageSvc.setMaximumBinarySize(appProperties.getMax_binary_size()); + FilesystemBinaryStorageSvcImpl filesystemSvc = new FilesystemBinaryStorageSvcImpl(baseDirectory); + Integer inlineResourceThreshold = resolveInlineResourceThreshold(appProperties); + int minimumBinarySize = + inlineResourceThreshold == null ? DEFAULT_FILESYSTEM_INLINE_THRESHOLD : inlineResourceThreshold; + filesystemSvc.setMinimumBinarySize(minimumBinarySize); + + Integer maxBinarySize = appProperties.getMax_binary_size(); + if (maxBinarySize != null) { + filesystemSvc.setMaximumBinarySize(maxBinarySize.longValue()); } - return binaryStorageSvc; + return filesystemSvc; + } + + @Bean + @ConditionalOnProperty( + prefix = "hapi.fhir", + name = "binary_storage_mode", + havingValue = "DATABASE", + matchIfMissing = true) + public DatabaseBinaryContentStorageSvcImpl databaseBinaryStorageSvc(AppProperties appProperties) { + DatabaseBinaryContentStorageSvcImpl databaseSvc = new DatabaseBinaryContentStorageSvcImpl(); + Integer maxBinarySize = appProperties.getMax_binary_size(); + if (maxBinarySize != null) { + databaseSvc.setMaximumBinarySize(maxBinarySize.longValue()); + } + return databaseSvc; + } + + private Integer resolveInlineResourceThreshold(AppProperties appProperties) { + Integer inlineResourceThreshold = appProperties.getInline_resource_storage_below_size(); + if (inlineResourceThreshold == null + && appProperties.getBinary_storage_mode() == AppProperties.BinaryStorageMode.FILESYSTEM) { + return DEFAULT_FILESYSTEM_INLINE_THRESHOLD; + } + return inlineResourceThreshold; } @Bean diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index af9fdcb..5dff60e 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -386,6 +386,14 @@ hapi: # max_page_size: 200 # retain_cached_searches_mins: 60 # reuse_cached_search_results_millis: 60000 + # validation: + # requests_enabled: true + # responses_enabled: true + # binary_storage_enabled: true + # binary_storage_mode: FILESYSTEM + # binary_storage_filesystem_base_directory: /binstore + # When binary_storage_mode is FILESYSTEM and this value is not set, + # the starter defaults to 102400 bytes so smaller binaries stay inline. inline_resource_storage_below_size: 4000 # ------------------------------------------------------------------------------- diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java b/src/test/java/ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java new file mode 100644 index 0000000..f40368e --- /dev/null +++ b/src/test/java/ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java @@ -0,0 +1,324 @@ +package ca.uhn.fhir.jpa.starter; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.binstore.FilesystemBinaryStorageSvcImpl; +import ca.uhn.fhir.jpa.dao.data.IBinaryStorageEntityDao; +import ca.uhn.fhir.jpa.model.entity.BinaryStorageEntity; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum; +import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +abstract class BaseBinaryStorageIntegrationTest { + protected static final String COMMON_CONFIG_LOCATION = "spring.config.location=classpath:/binary-storage-test-empty.yaml"; + protected static final String COMMON_H2_USERNAME = "spring.datasource.username=sa"; + protected static final String COMMON_H2_PASSWORD = "spring.datasource.password="; + protected static final String COMMON_JPA_DDL = "spring.jpa.hibernate.ddl-auto=create-drop"; + protected static final String COMMON_HIBERNATE_DIALECT = + "spring.jpa.properties.hibernate.dialect=ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect"; + protected static final String COMMON_HIBERNATE_SEARCH_DISABLED = "spring.jpa.properties.hibernate.search.enabled=false"; + protected static final String COMMON_FLYWAY_DISABLED = "spring.flyway.enabled=false"; + protected static final String COMMON_FHIR_VERSION = "hapi.fhir.fhir_version=r4"; + protected static final String COMMON_REPO_VALIDATION_DISABLED = + "hapi.fhir.enable_repository_validating_interceptor=false"; + protected static final String COMMON_MDM_DISABLED = "hapi.fhir.mdm_enabled=false"; + protected static final String COMMON_CR_DISABLED = "hapi.fhir.cr_enabled=false"; + protected static final String COMMON_SUBSCRIPTION_WS_DISABLED = "hapi.fhir.subscription.websocket_enabled=false"; + protected static final String COMMON_BEAN_OVERRIDE_ALLOWED = "spring.main.allow-bean-definition-overriding=true"; + protected static final String COMMON_CIRCULAR_REFERENCES = "spring.main.allow-circular-references=true"; + protected static final String COMMON_MCP_DISABLED = "spring.ai.mcp.server.enabled=false"; + protected static final String CONTENT_TYPE = "application/octet-stream"; + + @LocalServerPort + protected int port; + + protected FhirContext fhirContext; + protected IGenericClient client; + private final List resourcesToDelete = new ArrayList<>(); + + @BeforeEach + void setUpClient() { + fhirContext = FhirContext.forR4(); + fhirContext.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER); + fhirContext.getRestfulClientFactory().setSocketTimeout(1200 * 1000); + String serverBase = "http://localhost:" + port + "/fhir/"; + client = fhirContext.newRestfulGenericClient(serverBase); + resourcesToDelete.clear(); + } + + @AfterEach + void deleteCreatedResources() { + for (IIdType id : resourcesToDelete) { + try { + client.delete().resourceById(id).execute(); + } catch (Exception ignored) { + // Ignore cleanup failures to keep tests resilient + } + } + } + + protected IIdType createPatientWithPhoto(String label, byte[] payload) { + Patient patient = new Patient(); + patient.addIdentifier().setSystem("urn:binary-storage-test").setValue(label); + patient.addName().setFamily(label); + patient.addPhoto().setContentType(CONTENT_TYPE).setData(payload); + IIdType id = client.create().resource(patient).execute().getId().toUnqualifiedVersionless(); + resourcesToDelete.add(id); + return id; + } + + protected String uniqueLabel(String prefix) { + return prefix + "-" + UUID.randomUUID(); + } + + protected byte[] randomBytes(int size) { + byte[] payload = new byte[size]; + ThreadLocalRandom.current().nextBytes(payload); + return payload; + } + + protected void assertRegularFileCount(Path baseDir, long expectedFileCount) throws IOException { + assertThat(regularFileCount(baseDir)).isEqualTo(expectedFileCount); + } + + protected void assertRegularFileCountGreaterThan(Path baseDir, long minimumFileCount) throws IOException { + assertThat(regularFileCount(baseDir)).isGreaterThan(minimumFileCount); + } + + protected Path ensureDirectory(Path directory) throws IOException { + Files.createDirectories(directory); + return directory; + } + + protected void deleteDirectoryContents(Path baseDir) throws IOException { + if (Files.notExists(baseDir)) { + return; + } + try (Stream files = Files.walk(baseDir)) { + files.sorted(Comparator.reverseOrder()) + .filter(path -> !path.equals(baseDir)) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + } + + private long regularFileCount(Path baseDir) throws IOException { + if (Files.notExists(baseDir)) { + return 0; + } + try (Stream files = Files.walk(baseDir)) { + return files.filter(Files::isRegularFile).count(); + } + } +} + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = Application.class, + properties = { + BaseBinaryStorageIntegrationTest.COMMON_CONFIG_LOCATION, + "spring.datasource.url=jdbc:h2:mem:binary-storage-db;DB_CLOSE_DELAY=-1", + BaseBinaryStorageIntegrationTest.COMMON_H2_USERNAME, + BaseBinaryStorageIntegrationTest.COMMON_H2_PASSWORD, + BaseBinaryStorageIntegrationTest.COMMON_JPA_DDL, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_DIALECT, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_SEARCH_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FLYWAY_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FHIR_VERSION, + BaseBinaryStorageIntegrationTest.COMMON_REPO_VALIDATION_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_MDM_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_CR_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_SUBSCRIPTION_WS_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_BEAN_OVERRIDE_ALLOWED, + BaseBinaryStorageIntegrationTest.COMMON_CIRCULAR_REFERENCES, + BaseBinaryStorageIntegrationTest.COMMON_MCP_DISABLED, + "hapi.fhir.binary_storage_enabled=true", + "hapi.fhir.binary_storage_mode=DATABASE" + } +) +class BinaryStorageDatabaseModeIT extends BaseBinaryStorageIntegrationTest { + + @Autowired + private IBinaryStorageEntityDao binaryStorageEntityDao; + + @Autowired + private PlatformTransactionManager transactionManager; + + private TransactionTemplate transactionTemplate; + + @BeforeEach + void initTemplate() { + transactionTemplate = new TransactionTemplate(transactionManager); + } + + @Test + void largeAttachmentStoredInDatabase() { + Set beforeIds = captureContentIds(); + + createPatientWithPhoto(uniqueLabel("database"), randomBytes(150_000)); + + Set afterIds = captureContentIds(); + afterIds.removeAll(beforeIds); + assertThat(afterIds).hasSize(1); + + String binaryId = afterIds.iterator().next(); + BinaryStorageEntity entity = transactionTemplate.execute(status -> + binaryStorageEntityDao.findById(binaryId).orElseThrow()); + + assertThat(entity.hasStorageContent()).isTrue(); + assertThat(entity.getStorageContentBin()).hasSize(150_000); + + transactionTemplate.execute(status -> { + binaryStorageEntityDao.deleteById(binaryId); + return null; + }); + } + + private Set captureContentIds() { + return transactionTemplate.execute(status -> + binaryStorageEntityDao.findAll().stream() + .map(BinaryStorageEntity::getContentId) + .collect(Collectors.toCollection(LinkedHashSet::new))); + } +} + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = Application.class, + properties = { + BaseBinaryStorageIntegrationTest.COMMON_CONFIG_LOCATION, + "spring.datasource.url=jdbc:h2:mem:binary-storage-fs-default;DB_CLOSE_DELAY=-1", + BaseBinaryStorageIntegrationTest.COMMON_H2_USERNAME, + BaseBinaryStorageIntegrationTest.COMMON_H2_PASSWORD, + BaseBinaryStorageIntegrationTest.COMMON_JPA_DDL, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_DIALECT, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_SEARCH_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FLYWAY_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FHIR_VERSION, + BaseBinaryStorageIntegrationTest.COMMON_REPO_VALIDATION_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_MDM_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_CR_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_SUBSCRIPTION_WS_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_BEAN_OVERRIDE_ALLOWED, + BaseBinaryStorageIntegrationTest.COMMON_CIRCULAR_REFERENCES, + BaseBinaryStorageIntegrationTest.COMMON_MCP_DISABLED, + "hapi.fhir.binary_storage_enabled=true", + "hapi.fhir.binary_storage_mode=FILESYSTEM", + "hapi.fhir.binary_storage_filesystem_base_directory=target/test-binary-storage/filesystem-default" + } +) +class BinaryStorageFilesystemDefaultIT extends BaseBinaryStorageIntegrationTest { + static final Path BASE_DIRECTORY = Paths.get("target/test-binary-storage/filesystem-default").toAbsolutePath(); + + @Autowired + private FilesystemBinaryStorageSvcImpl filesystemBinaryStorageSvc; + + @BeforeEach + void prepareDirectory() throws IOException { + ensureDirectory(BASE_DIRECTORY); + deleteDirectoryContents(BASE_DIRECTORY); + } + + @Test + void filesystemModeUsesDefaultThreshold() throws IOException { + assertThat(filesystemBinaryStorageSvc.getMinimumBinarySize()).isEqualTo(102_400); + assertRegularFileCount(BASE_DIRECTORY, 0); + + createPatientWithPhoto(uniqueLabel("fs-default-inline"), randomBytes(50_000)); + assertRegularFileCount(BASE_DIRECTORY, 0); + + createPatientWithPhoto(uniqueLabel("fs-default-offload"), randomBytes(150_000)); + assertRegularFileCountGreaterThan(BASE_DIRECTORY, 0); + } + + @AfterEach + void cleanUpDirectory() throws IOException { + deleteDirectoryContents(BASE_DIRECTORY); + } +} + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = Application.class, + properties = { + BaseBinaryStorageIntegrationTest.COMMON_CONFIG_LOCATION, + "spring.datasource.url=jdbc:h2:mem:binary-storage-fs-custom;DB_CLOSE_DELAY=-1", + BaseBinaryStorageIntegrationTest.COMMON_H2_USERNAME, + BaseBinaryStorageIntegrationTest.COMMON_H2_PASSWORD, + BaseBinaryStorageIntegrationTest.COMMON_JPA_DDL, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_DIALECT, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_SEARCH_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FLYWAY_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FHIR_VERSION, + BaseBinaryStorageIntegrationTest.COMMON_REPO_VALIDATION_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_MDM_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_CR_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_SUBSCRIPTION_WS_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_BEAN_OVERRIDE_ALLOWED, + BaseBinaryStorageIntegrationTest.COMMON_CIRCULAR_REFERENCES, + BaseBinaryStorageIntegrationTest.COMMON_MCP_DISABLED, + "hapi.fhir.binary_storage_enabled=true", + "hapi.fhir.binary_storage_mode=FILESYSTEM", + "hapi.fhir.binary_storage_filesystem_base_directory=target/test-binary-storage/filesystem-custom", + "hapi.fhir.inline_resource_storage_below_size=32768" + } +) +class BinaryStorageFilesystemCustomThresholdIT extends BaseBinaryStorageIntegrationTest { + static final Path BASE_DIRECTORY = Paths.get("target/test-binary-storage/filesystem-custom").toAbsolutePath(); + + @Autowired + private FilesystemBinaryStorageSvcImpl filesystemBinaryStorageSvc; + + @BeforeEach + void prepareDirectory() throws IOException { + ensureDirectory(BASE_DIRECTORY); + deleteDirectoryContents(BASE_DIRECTORY); + } + + @Test + void filesystemModeHonoursCustomThreshold() throws IOException { + assertThat(filesystemBinaryStorageSvc.getMinimumBinarySize()).isEqualTo(32_768); + assertRegularFileCount(BASE_DIRECTORY, 0); + + createPatientWithPhoto(uniqueLabel("fs-custom-inline"), randomBytes(30_000)); + assertRegularFileCount(BASE_DIRECTORY, 0); + + createPatientWithPhoto(uniqueLabel("fs-custom-offload"), randomBytes(40_000)); + assertRegularFileCountGreaterThan(BASE_DIRECTORY, 0); + } + + @AfterEach + void cleanUpDirectory() throws IOException { + deleteDirectoryContents(BASE_DIRECTORY); + } +} diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommonBinaryStorageTest.java b/src/test/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommonBinaryStorageTest.java new file mode 100644 index 0000000..bad6417 --- /dev/null +++ b/src/test/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommonBinaryStorageTest.java @@ -0,0 +1,96 @@ +package ca.uhn.fhir.jpa.starter.common; + +import ca.uhn.fhir.jpa.binstore.DatabaseBinaryContentStorageSvcImpl; +import ca.uhn.fhir.jpa.binstore.FilesystemBinaryStorageSvcImpl; +import ca.uhn.fhir.jpa.binary.api.IBinaryStorageSvc; +import ca.uhn.fhir.jpa.starter.AppProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FhirServerConfigCommonBinaryStorageTest { + + @TempDir + Path tempDir; + + private FhirServerConfigCommon newConfig() { + return new FhirServerConfigCommon(new AppProperties()); + } + + @Test + void defaultsToDatabaseImplementation() { + AppProperties props = new AppProperties(); + + IBinaryStorageSvc svc = binaryStorageSvc(props); + + assertThat(svc).isInstanceOf(DatabaseBinaryContentStorageSvcImpl.class); + } + + @Test + void filesystemModeUsesDefaultMinimumWhenUnspecified() throws Exception { + AppProperties props = new AppProperties(); + props.setBinary_storage_mode(AppProperties.BinaryStorageMode.FILESYSTEM); + Path baseDir = tempDir.resolve("fs-default"); + Files.createDirectories(baseDir); + props.setBinary_storage_filesystem_base_directory(baseDir.toString()); + + FilesystemBinaryStorageSvcImpl svc = filesystemBinaryStorageSvc(props); + + assertThat(svc.getMinimumBinarySize()).isEqualTo(102_400); + } + + @Test + void filesystemModeHonoursExplicitMinimum() throws Exception { + AppProperties props = new AppProperties(); + props.setBinary_storage_mode(AppProperties.BinaryStorageMode.FILESYSTEM); + props.setInline_resource_storage_below_size(4096); + Path baseDir = tempDir.resolve("fs-min-explicit"); + Files.createDirectories(baseDir); + props.setBinary_storage_filesystem_base_directory(baseDir.toString()); + + FilesystemBinaryStorageSvcImpl svc = filesystemBinaryStorageSvc(props); + + assertThat(svc.getMinimumBinarySize()).isEqualTo(4096); + } + + @Test + void filesystemModeSupportsZeroMinimumWhenExplicit() throws Exception { + AppProperties props = new AppProperties(); + props.setBinary_storage_mode(AppProperties.BinaryStorageMode.FILESYSTEM); + props.setInline_resource_storage_below_size(0); + Path baseDir = tempDir.resolve("fs-zero"); + Files.createDirectories(baseDir); + props.setBinary_storage_filesystem_base_directory(baseDir.toString()); + + FilesystemBinaryStorageSvcImpl svc = filesystemBinaryStorageSvc(props); + + assertThat(svc.getMinimumBinarySize()).isZero(); + } + + @Test + void filesystemModeRequiresBaseDirectory() { + AppProperties props = new AppProperties(); + props.setBinary_storage_mode(AppProperties.BinaryStorageMode.FILESYSTEM); + + assertThatThrownBy(() -> filesystemBinaryStorageSvc(props)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("binary_storage_filesystem_base_directory"); + } + + private IBinaryStorageSvc binaryStorageSvc(AppProperties props) { + FhirServerConfigCommon config = newConfig(); + if (props.getBinary_storage_mode() == AppProperties.BinaryStorageMode.FILESYSTEM) { + return config.filesystemBinaryStorageSvc(props); + } + return config.databaseBinaryStorageSvc(props); + } + + private FilesystemBinaryStorageSvcImpl filesystemBinaryStorageSvc(AppProperties props) { + return (FilesystemBinaryStorageSvcImpl) binaryStorageSvc(props); + } +} diff --git a/src/test/resources/binary-storage-test-empty.yaml b/src/test/resources/binary-storage-test-empty.yaml new file mode 100644 index 0000000..d889d02 --- /dev/null +++ b/src/test/resources/binary-storage-test-empty.yaml @@ -0,0 +1 @@ +# Empty config to bypass default application.yaml during BinaryStorageIntegrationTest