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 <ubuntu@ip-172-31-35-43.eu-west-2.compute.internal>
Co-authored-by: Ubuntu <ubuntu@ip-172-31-10-131.eu-west-2.compute.internal>
Co-authored-by: Jens Kristian Villadsen <jenskristianvilladsen@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Shamus Husheer
2025-10-09 08:39:01 -04:00
committed by GitHub
parent a55c8f20a9
commit cf003331e4
7 changed files with 516 additions and 10 deletions

View File

@@ -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. 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 ### 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.: You can customize HAPI by telling HAPI to look for the configuration file in a different location, e.g.:

View File

@@ -61,7 +61,15 @@ public class AppProperties {
private Boolean filter_search_enabled = true; private Boolean filter_search_enabled = true;
private Boolean graphql_enabled = false; private Boolean graphql_enabled = false;
private Boolean binary_storage_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_export_enabled = false;
private Boolean bulk_import_enabled = false; private Boolean bulk_import_enabled = false;
private Boolean default_pretty_print = true; private Boolean default_pretty_print = true;
@@ -485,6 +493,22 @@ public class AppProperties {
this.binary_storage_enabled = binary_storage_enabled; 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() { public Integer getInline_resource_storage_below_size() {
return inline_resource_storage_below_size; return inline_resource_storage_below_size;
} }

View File

@@ -1,8 +1,8 @@
package ca.uhn.fhir.jpa.starter.common; package ca.uhn.fhir.jpa.starter.common;
import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; 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.DatabaseBinaryContentStorageSvcImpl;
import ca.uhn.fhir.jpa.binstore.FilesystemBinaryStorageSvcImpl;
import ca.uhn.fhir.jpa.config.HibernatePropertiesProvider; import ca.uhn.fhir.jpa.config.HibernatePropertiesProvider;
import ca.uhn.fhir.jpa.model.config.PartitionSettings; import ca.uhn.fhir.jpa.model.config.PartitionSettings;
import ca.uhn.fhir.jpa.model.config.PartitionSettings.CrossPartitionReferenceMode; 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.hl7.fhir.r4.model.Bundle.BundleType;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.env.YamlPropertySourceLoader; import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.context.annotation.*; import org.springframework.context.annotation.*;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.annotation.EnableTransactionManagement; import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.util.Assert;
import java.util.HashSet; import java.util.HashSet;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -38,6 +40,7 @@ import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
public class FhirServerConfigCommon { public class FhirServerConfigCommon {
private static final Logger ourLog = LoggerFactory.getLogger(FhirServerConfigCommon.class); private static final Logger ourLog = LoggerFactory.getLogger(FhirServerConfigCommon.class);
private static final int DEFAULT_FILESYSTEM_INLINE_THRESHOLD = 102_400;
public FhirServerConfigCommon(AppProperties appProperties) { public FhirServerConfigCommon(AppProperties appProperties) {
ourLog.info( ourLog.info(
@@ -222,8 +225,9 @@ public class FhirServerConfigCommon {
jpaStorageSettings.setLastNEnabled(true); jpaStorageSettings.setLastNEnabled(true);
} }
if (appProperties.getInline_resource_storage_below_size() != 0) { Integer inlineResourceThreshold = resolveInlineResourceThreshold(appProperties);
jpaStorageSettings.setInlineResourceTextBelowSize(appProperties.getInline_resource_storage_below_size()); if (inlineResourceThreshold != null && inlineResourceThreshold != 0) {
jpaStorageSettings.setInlineResourceTextBelowSize(inlineResourceThreshold);
} }
jpaStorageSettings.setStoreResourceInHSearchIndex(appProperties.getStore_resource_in_lucene_index_enabled()); jpaStorageSettings.setStoreResourceInHSearchIndex(appProperties.getStore_resource_in_lucene_index_enabled());
@@ -354,16 +358,50 @@ public class FhirServerConfigCommon {
return new JpaHibernatePropertiesProvider(myEntityManagerFactory); return new JpaHibernatePropertiesProvider(myEntityManagerFactory);
} }
@Lazy
@Bean @Bean
public IBinaryStorageSvc binaryStorageSvc(AppProperties appProperties) { @ConditionalOnProperty(prefix = "hapi.fhir", name = "binary_storage_mode", havingValue = "FILESYSTEM")
DatabaseBinaryContentStorageSvcImpl binaryStorageSvc = new DatabaseBinaryContentStorageSvcImpl(); 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) { FilesystemBinaryStorageSvcImpl filesystemSvc = new FilesystemBinaryStorageSvcImpl(baseDirectory);
binaryStorageSvc.setMaximumBinarySize(appProperties.getMax_binary_size()); 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 @Bean

View File

@@ -386,6 +386,14 @@ hapi:
# max_page_size: 200 # max_page_size: 200
# retain_cached_searches_mins: 60 # retain_cached_searches_mins: 60
# reuse_cached_search_results_millis: 60000 # 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 inline_resource_storage_below_size: 4000
# ------------------------------------------------------------------------------- # -------------------------------------------------------------------------------

View File

@@ -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<IIdType> 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<Path> 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<Path> 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<String> beforeIds = captureContentIds();
createPatientWithPhoto(uniqueLabel("database"), randomBytes(150_000));
Set<String> 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<String> 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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1 @@
# Empty config to bypass default application.yaml during BinaryStorageIntegrationTest