From db4121e06396854c1daff999c5f4372c7d5c572d Mon Sep 17 00:00:00 2001
From: Scott Wicken <1562170+swicken@users.noreply.github.com>
Date: Fri, 25 Sep 2026 14:05:27 -0400
Subject: [PATCH 1/7] feat(storage): add feature-gated S3 storage layer for
shared asset storage
First slice of the S3 asset storage work. Adds the FEATURE_FLAG_S3_ASSET_STORAGE
startup flag (read once per process), chain-compatible S3, filesystem and database
persistence operations, content-addressed S3 blob storage, shared extracted
metadata, STS credential support, and SigV4/region/pagination handling for static
push publishing. Everything is inert while the flag is off, which is the default.
---
docs/testing/BINARY_S3_STORAGE.md | 97 ++++
dotCMS/pom.xml | 4 +
.../publishing/storage/AWSS3Storage.java | 106 ++++-
.../publishing/storage/Storage.java | 20 +
.../AmazonS3StoragePersistenceAPIImpl.java | 413 +++++++++++++++++-
.../dotcms/storage/AssetStorageFeature.java | 48 ++
.../ChainableStoragePersistenceAPI.java | 185 +++++++-
.../DataBaseStoragePersistenceAPIImpl.java | 19 +-
.../com/dotcms/storage/FileStorageAPI.java | 24 +
.../dotcms/storage/FileStorageAPIImpl.java | 40 +-
.../FileSystemStoragePersistenceAPIImpl.java | 170 ++++++-
.../storage/S3ContentAddressedStorage.java | 194 ++++++++
.../storage/SharedExtractedMetadata.java | 51 +++
.../dotcms/storage/StoragePersistenceAPI.java | 40 ++
.../main/java/com/dotcms/tika/TikaUtils.java | 7 +
.../java/com/dotmarketing/util/Config.java | 5 +
.../resources/dotmarketing-config.properties | 10 +
.../storage/AssetStorageFeatureLatchTest.java | 46 ++
.../storage/AssetStorageFeatureTest.java | 242 ++++++++++
.../dotcms/storage/BinaryS3StorageTest.java | 161 +++++++
.../storage/S3StorageConfigurationTest.java | 128 ++++++
21 files changed, 1974 insertions(+), 36 deletions(-)
create mode 100644 docs/testing/BINARY_S3_STORAGE.md
create mode 100644 dotCMS/src/main/java/com/dotcms/storage/AssetStorageFeature.java
create mode 100644 dotCMS/src/main/java/com/dotcms/storage/S3ContentAddressedStorage.java
create mode 100644 dotCMS/src/main/java/com/dotcms/storage/SharedExtractedMetadata.java
create mode 100644 dotCMS/src/test/java/com/dotcms/storage/AssetStorageFeatureLatchTest.java
create mode 100644 dotCMS/src/test/java/com/dotcms/storage/AssetStorageFeatureTest.java
create mode 100644 dotCMS/src/test/java/com/dotcms/storage/BinaryS3StorageTest.java
create mode 100644 dotCMS/src/test/java/com/dotcms/storage/S3StorageConfigurationTest.java
diff --git a/docs/testing/BINARY_S3_STORAGE.md b/docs/testing/BINARY_S3_STORAGE.md
new file mode 100644
index 000000000000..f46533c3fad8
--- /dev/null
+++ b/docs/testing/BINARY_S3_STORAGE.md
@@ -0,0 +1,97 @@
+# S3 asset storage
+
+S3 asset storage lets dotCMS keep binary assets and their metadata durably in S3, with the
+local asset directory acting as a cache. It is opt-in and off by default. With the flag off,
+the filesystem/NFS behavior is unchanged.
+
+This page describes what is in the code today. It grows as the remaining parts land.
+
+## Feature flag
+
+Set `FEATURE_FLAG_S3_ASSET_STORAGE=true` (Docker: `DOT_FEATURE_FLAG_S3_ASSET_STORAGE=true`)
+in the environment or `dotmarketing-config.properties`, then restart every node. The default
+is `false`.
+
+The flag is read once per process (`AssetStorageFeature.isEnabled()`) and then kept, because
+several storage objects capture the mode when they are built. A value set only in the system
+table may not be seen at first read, and a runtime change is ignored until restart. In-memory
+overrides through `Config.setProperty` do re-read it, which is how tests switch modes. Tests
+that mock `Config` statically must call `AssetStorageFeature.reset()` themselves.
+
+## Storage layer behavior with the flag on
+
+The storage chain (`ChainableStoragePersistenceAPI`) and its providers change as follows:
+
+- Writes publish to every durable provider before the new local copy becomes visible, and a
+ failed remote write keeps the previous local contents.
+- Reads restore a missing local copy from S3. A miss is not cached as a 404, because another
+ node can upload the same key at any time.
+- Deletes keep the local copy until all durable providers accept the deletion, so an
+ in-flight restore on the same chain cannot bring the object back.
+- Storage and database query failures propagate instead of being reported as a missing
+ object.
+- Providers can list objects under a prefix, verify that a durable copy has the same
+ contents (`hasDurableCopy`), and copy an existing file or object to durable storage without
+ overwriting a conflicting one (`backfillFile`, `backfillObject`).
+
+Two building blocks are included for the slices that follow. `S3ContentAddressedStorage`
+stores one immutable copy of each set of bytes at
+`asset-blobs/sha256/////`.
+`SharedExtractedMetadata` caches byte-derived Tika extraction at
+`extracted-metadata//.json`. Nothing calls either one yet.
+
+## Credentials and region
+
+With the flag on, leave both `storage.file-metadata.s3.access-key` and
+`storage.file-metadata.s3.secret-access-key` unset to use the AWS SDK default credential
+provider chain. The configured bucket region and endpoint are kept in that mode. A partial
+key pair fails initialization. The AWS STS module is packaged so role-based providers,
+including web identity, work.
+
+Without a custom endpoint, a configured region selects the regional AWS endpoint, and an
+absent region falls back to the SDK region provider chain. A custom endpoint
+(`storage.file-metadata.s3.endpoint`) must be an absolute HTTP(S) URL and requires
+`storage.file-metadata.s3.bucket-region` for request signing. Invalid endpoint configuration
+fails instead of silently selecting AWS. With the flag off, configuration behaves as before.
+
+## Sharing one bucket across installations
+
+With the flag on, `storage.file-metadata.s3.namespace` (Docker:
+`DOT_STORAGE_FILE_METADATA_S3_NAMESPACE`) separates the keys of installations that share a
+bucket. Owned keys become `asset-namespaces///`; shared
+extracted metadata stays at the bucket root so identical bytes are extracted once. Use the same
+value for all nodes in one cluster and distinct values for independent installations. Values
+are 1 to 64 letters, digits, underscores or hyphens, starting with a letter or digit. Empty is
+the default and keeps the existing layout.
+
+Set the namespace before the installation first stores assets. Changing it later does not
+migrate existing records. It separates application keys only; it does not restrict what the
+bucket credentials can access.
+
+## Static push publishing
+
+Enabling the flag also changes static push publishing to S3 (`AWSS3Storage`). Clients sign
+with SigV4, resolve the endpoint from the region when no endpoint is configured, and list
+every page of objects instead of only the first 1,000.
+
+## Running the checks
+
+The S3 checks use the real filesystem provider, chain and AWS adapter against a disposable
+MinIO bucket. `BinaryS3StorageTest` is skipped unless `s3.test.endpoint` is set; the other
+tests always run. The credentials below are disposable local test values.
+
+```sh
+docker run -d --rm --name binary-s3-test \
+ -p 127.0.0.1:19002:9000 \
+ -e MINIO_ROOT_USER=binary-storage-test \
+ -e MINIO_ROOT_PASSWORD=binary-storage-test \
+ minio/minio:latest server /data
+
+./mvnw test -pl :dotcms-core -Dmaven.build.cache.enabled=false \
+ -Dtest=AssetStorageFeatureTest,AssetStorageFeatureLatchTest,S3StorageConfigurationTest,BinaryS3StorageTest \
+ -Ds3.test.endpoint=http://127.0.0.1:19002
+
+docker stop binary-s3-test
+```
+
+CI does not yet provide the MinIO service, so `BinaryS3StorageTest` does not run there.
diff --git a/dotCMS/pom.xml b/dotCMS/pom.xml
index 47ef2d90e573..1798a2727f91 100644
--- a/dotCMS/pom.xml
+++ b/dotCMS/pom.xml
@@ -763,6 +763,10 @@
com.amazonawsaws-java-sdk-s3
+
+ com.amazonaws
+ aws-java-sdk-sts
+ software.amazon.awssdksqs
diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/storage/AWSS3Storage.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/storage/AWSS3Storage.java
index 0b8734efd769..2ce26d19cbeb 100644
--- a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/storage/AWSS3Storage.java
+++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/storage/AWSS3Storage.java
@@ -29,6 +29,9 @@
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.InputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
@@ -48,6 +51,11 @@ public AWSS3Storage(DefaultAWSCredentialsProviderChain credentialsProviderChain)
this (getAmazonS3Client(credentialsProviderChain , null, DEFAULT_S3_REGION));
}
+ public AWSS3Storage(final AWSCredentialsProvider credentialsProvider, final String endpoint,
+ final String region) {
+ this(getAmazonS3Client(credentialsProvider, endpoint, region));
+ }
+
public AWSS3Storage(final AWSS3Configuration configuration) {
this(getAmazonS3Client(configuration.getAccessKey(), configuration.getSecretKey(),
configuration.getEndPoint(), configuration.getRegion()));
@@ -63,14 +71,18 @@ private static AmazonS3 getAmazonS3Client(final AWSCredentialsProvider credentia
final String endPoint, final String region) {
- return AmazonS3ClientBuilder.standard()
+ final AmazonS3ClientBuilder builder = AmazonS3ClientBuilder.standard()
.withCredentials(credentialsProvider)
- .withClientConfiguration(getClientConfiguration())
- .withEndpointConfiguration(
+ .withClientConfiguration(getClientConfiguration());
+ if (!com.dotcms.storage.AssetStorageFeature.isEnabled() || UtilMethods.isSet(endPoint)) {
+ builder.withEndpointConfiguration(
new AwsClientBuilder.EndpointConfiguration(
UtilMethods.isSet(endPoint) ? endPoint : "s3.amazonaws.com",
- region))
- .build();
+ region));
+ } else if (UtilMethods.isSet(region)) {
+ builder.withRegion(region);
+ }
+ return builder.build();
}
private AWSS3Storage(AmazonS3 s3client) {
@@ -86,7 +98,7 @@ private AWSS3Storage(AmazonS3 s3client) {
private static ClientConfiguration getClientConfiguration(){
ClientConfiguration conf = new ClientConfiguration();
- conf.setSignerOverride("S3SignerType");
+ conf.setSignerOverride(com.dotcms.storage.AssetStorageFeature.isEnabled() ? "AWSS3V4SignerType" : "S3SignerType");
return conf;
}
@@ -184,7 +196,17 @@ public MultipleFileUpload uploadFolder(final String bucketName, final String fol
public ObjectListing listObjects(String bucketName, String folderPath) throws DotRuntimeException {
try {
ListObjectsRequest lor = new ListObjectsRequest().withBucketName(bucketName).withPrefix(folderPath);
- return s3client.listObjects(lor);
+ final ObjectListing result = s3client.listObjects(lor);
+ if (!com.dotcms.storage.AssetStorageFeature.isEnabled()) {
+ return result;
+ }
+ ObjectListing page = result;
+ while (page.isTruncated()) {
+ page = s3client.listNextBatchOfObjects(page);
+ result.getObjectSummaries().addAll(page.getObjectSummaries());
+ }
+ result.setTruncated(false);
+ return result;
} catch (AmazonServiceException ase) {
throw new DotRuntimeException("Caught an error from Amazon S3: request made but was rejected", ase);
} catch (AmazonClientException ace) {
@@ -233,6 +255,76 @@ public Upload uploadFile(final PutObjectRequest putObjectRequest)
}
}
+ @Override
+ public S3Object getObject(final String bucket, final String key) {
+ try {
+ return s3client.getObject(bucket, key);
+ } catch (AmazonS3Exception failure) {
+ if (failure.getStatusCode() == 404 && "NoSuchKey".equals(failure.getErrorCode())) return null;
+ throw failure;
+ }
+ }
+
+ @Override
+ public String uploadFileIfMatch(final String bucket, final String key, final File file, final String etag) {
+ if (!com.dotcms.storage.AssetStorageFeature.isEnabled()) throw new IllegalStateException("S3 asset storage is disabled");
+ final var request = new PutObjectRequest(bucket, key, file);
+ request.putCustomRequestHeader(etag == null ? "If-None-Match" : "If-Match", etag == null ? "*" : "\"" + etag + "\"");
+ try {
+ return s3client.putObject(request).getETag();
+ } catch (AmazonS3Exception failure) {
+ if (failure.getStatusCode() == 412 || failure.getStatusCode() == 409
+ || (etag != null && failure.getStatusCode() == 404 && "NoSuchKey".equals(failure.getErrorCode()))) return null;
+ throw failure;
+ }
+ }
+
+ /** Creates an object without replacing an existing key, including multipart completion. */
+ @Override
+ public void uploadFileIfAbsent(final String bucket, final String key, final File file) {
+ if (!com.dotcms.storage.AssetStorageFeature.isEnabled()) {
+ throw new IllegalStateException("S3 asset storage is disabled");
+ }
+ if (file.length() <= DEFAULT_STATIC_PUSH_MULTIPART_UPLOAD_THRESHOLD) {
+ final PutObjectRequest request = new PutObjectRequest(bucket, key, file);
+ request.putCustomRequestHeader("If-None-Match", "*");
+ s3client.putObject(request);
+ return;
+ }
+ // The bundled TransferManager does not carry custom headers to multipart completion.
+ final String uploadId = s3client.initiateMultipartUpload(new InitiateMultipartUploadRequest(bucket, key)).getUploadId();
+ try {
+ final List parts = new ArrayList<>();
+ final long length = file.length();
+ final long partSize = Math.max(8L * 1024 * 1024, (length + 9999) / 10000);
+ for (long offset = 0; offset < length; offset += partSize) {
+ parts.add(s3client.uploadPart(new UploadPartRequest().withBucketName(bucket).withKey(key)
+ .withUploadId(uploadId).withPartNumber(parts.size() + 1).withFile(file)
+ .withFileOffset(offset).withPartSize(Math.min(partSize, length - offset))).getPartETag());
+ }
+ final CompleteMultipartUploadRequest request = new CompleteMultipartUploadRequest(bucket, key, uploadId, parts);
+ request.putCustomRequestHeader("If-None-Match", "*");
+ s3client.completeMultipartUpload(request);
+ } catch (RuntimeException failure) {
+ try {
+ s3client.abortMultipartUpload(new AbortMultipartUploadRequest(bucket, key, uploadId));
+ } catch (RuntimeException abortFailure) {
+ failure.addSuppressed(abortFailure);
+ }
+ throw failure;
+ }
+ }
+
+ /** Verifies opaque/encrypted/multipart ETags by reading the actual object bytes. */
+ @Override
+ public boolean fileContentsMatch(final String bucket, final String key, final File file) throws IOException {
+ try (final S3Object object = s3client.getObject(bucket, key);
+ final InputStream local = Files.newInputStream(file.toPath())) {
+ return object.getObjectMetadata().getContentLength() == file.length()
+ && org.apache.commons.io.IOUtils.contentEquals(object.getObjectContent(), local);
+ }
+ }
+
@Override
public Download downloadFile(final String bucketName, final String filePath, File file) throws DotRuntimeException {
diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/storage/Storage.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/storage/Storage.java
index c34cf97ce572..3aa911badf9d 100644
--- a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/storage/Storage.java
+++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/storage/Storage.java
@@ -91,6 +91,26 @@ MultipleFileUpload uploadFolder(final String bucketName, final String folderPath
*/
Upload uploadFile(final PutObjectRequest putObjectRequest) throws DotRuntimeException;
+ /** Creates an object without replacing an existing key. Unsupported implementations must fail. */
+ default void uploadFileIfAbsent(final String bucketName, final String key, final File file) {
+ throw new UnsupportedOperationException("Conditional uploads are not supported");
+ }
+
+ /** Reads bytes and their version from the same S3 response. Caller closes the response. */
+ default com.amazonaws.services.s3.model.S3Object getObject(final String bucket, final String key) {
+ throw new UnsupportedOperationException("Versioned reads are not supported");
+ }
+
+ /** Writes a small record only if its ETag still matches; null expects absence. */
+ default String uploadFileIfMatch(final String bucket, final String key, final File file, final String etag) {
+ throw new UnsupportedOperationException("Conditional writes are not supported");
+ }
+
+ /** Compares object bytes when an ETag cannot verify file contents. */
+ default boolean fileContentsMatch(final String bucketName, final String key, final File file) throws java.io.IOException {
+ return false;
+ }
+
/**
* Async method: Schedules a new transfer to download data from Amazon S3 and save it to the specified file.
*/
diff --git a/dotCMS/src/main/java/com/dotcms/storage/AmazonS3StoragePersistenceAPIImpl.java b/dotCMS/src/main/java/com/dotcms/storage/AmazonS3StoragePersistenceAPIImpl.java
index c85092c29e39..5f9145d80b10 100644
--- a/dotCMS/src/main/java/com/dotcms/storage/AmazonS3StoragePersistenceAPIImpl.java
+++ b/dotCMS/src/main/java/com/dotcms/storage/AmazonS3StoragePersistenceAPIImpl.java
@@ -2,6 +2,8 @@
import com.amazonaws.auth.DefaultAWSCredentialsProviderChain;
import com.amazonaws.services.s3.model.Bucket;
+import com.amazonaws.services.s3.model.PutObjectRequest;
+import org.apache.commons.codec.digest.DigestUtils;
import com.amazonaws.services.s3.transfer.Download;
import com.amazonaws.services.s3.transfer.Transfer;
import com.amazonaws.services.s3.transfer.Upload;
@@ -47,8 +49,8 @@
import java.util.function.Function;
import java.util.stream.Collectors;
-import static com.liferay.util.StringPool.BLANK;
import static com.liferay.util.StringPool.FORWARD_SLASH;
+import static com.liferay.util.StringPool.BLANK;
/**
* Provides a Metadata Provider implementation that uses AWS S3 to persist the metadata files.
@@ -71,6 +73,8 @@ enum PathEncryptionMode {
}
private final Storage storage;
+ private final String namespace = configuredNamespace();
+
private final FileRepositoryManager fileRepositoryManager = this.getFileRepository();
private MessageDigest sha256;
private final Set groups = ConcurrentHashMap.newKeySet();
@@ -84,13 +88,29 @@ enum PathEncryptionMode {
public static final String AWS_S3_ACCESS_KEY_PROP = "storage.file-metadata.s3.access-key";
public static final String AWS_S3_SECRET_ACCESS_KEY_PROP = "storage.file-metadata.s3.secret-access-key";
public static final String AWS_S3_PATH_ENCRYPTION_MODE_PROP = "storage.file-metadata.s3.path-encryption-mode";
+ public static final String AWS_S3_ENDPOINT_PROP = "storage.file-metadata.s3.endpoint";
+ public static final String AWS_S3_NAMESPACE_PROP = "storage.file-metadata.s3.namespace";
+
+ private static String configuredNamespace() {
+ if (!AssetStorageFeature.isEnabled()) return BLANK;
+ final String value = Config.getStringProperty(AWS_S3_NAMESPACE_PROP, BLANK);
+ if (!value.isEmpty() && !value.matches("[A-Za-z0-9][A-Za-z0-9_-]{0,63}")) {
+ throw new DotRuntimeException("S3 namespace must be empty or 1-64 letters, digits, underscores or hyphens, starting with a letter or digit");
+ }
+ return value;
+ }
+
+ private String groupKey(final String group) {
+ return !AssetStorageFeature.isEnabled() || namespace.isEmpty() || SharedExtractedMetadata.GROUP.equals(group)
+ ? group : "asset-namespaces/" + namespace + FORWARD_SLASH + group;
+ }
+
+ private static final String METADATA_GROUP_NAME = Config.getStringProperty(
+ StoragePersistenceProvider.METADATA_GROUP_NAME, FileMetadataAPI.DOT_METADATA);
private static final String S3_BASE_STORAGE_FILE_REPO_TYPE = Config.getStringProperty(
"S3_STORAGE_FILE_REPO_TYPE", FileRepositoryManager.TEMP_REPO).toUpperCase();
- private static final String METADATA_GROUP_NAME = Config
- .getStringProperty(StoragePersistenceProvider.METADATA_GROUP_NAME, FileMetadataAPI.DOT_METADATA);
-
/**
* Returns the file repository manager based on the configuration.
*
@@ -116,19 +136,64 @@ public AmazonS3StoragePersistenceAPIImpl() {
this.pathEncryptionMode =
PathEncryptionMode.valueOf(Config.getStringProperty(AWS_S3_PATH_ENCRYPTION_MODE_PROP, PathEncryptionMode.SHA256.name()));
this.sha256 = Try.of(() -> MessageDigest.getInstance(Encryptor.SHA256_ALGORITHM)).getOrElseThrow(e -> new DotRuntimeException(e.getMessage(), e));
+ final String endpoint = AssetStorageFeature.isEnabled() ? Config.getStringProperty(AWS_S3_ENDPOINT_PROP, null) : null;
+ if (AssetStorageFeature.isEnabled()
+ && UtilMethods.isSet(accessKey) != UtilMethods.isSet(secretAccessKey)) {
+ throw new DotRuntimeException("Configure both S3 access key and secret key, or leave both unset to use the AWS credential provider chain");
+ }
+ if (AssetStorageFeature.isEnabled() && UtilMethods.isSet(endpoint)) {
+ try {
+ final var uri = java.net.URI.create(endpoint);
+ if (uri.getHost() == null || !("http".equalsIgnoreCase(uri.getScheme())
+ || "https".equalsIgnoreCase(uri.getScheme()))) {
+ throw new IllegalArgumentException("Expected an absolute HTTP(S) URL");
+ }
+ } catch (IllegalArgumentException invalid) {
+ throw new DotRuntimeException("Invalid S3 endpoint: configure an absolute HTTP(S) URL", invalid);
+ }
+ if (!UtilMethods.isSet(region)) {
+ throw new DotRuntimeException("A custom S3 endpoint requires storage.file-metadata.s3.bucket-region for request signing");
+ }
+ }
this.storage = !UtilMethods.isSet(accessKey) || !UtilMethods.isSet(secretAccessKey) ?
- new AWSS3Storage(new DefaultAWSCredentialsProviderChain()) :
- new AWSS3Storage(new AWSS3Configuration.Builder().accessKey(accessKey).secretKey(secretAccessKey).endPoint(null).region(region).build());
+ (AssetStorageFeature.isEnabled()
+ ? new AWSS3Storage(new DefaultAWSCredentialsProviderChain(), endpoint, region)
+ : new AWSS3Storage(new DefaultAWSCredentialsProviderChain())) :
+ new AWSS3Storage(new AWSS3Configuration.Builder().accessKey(accessKey).secretKey(secretAccessKey).endPoint(endpoint).region(region).build());
}
@SuppressWarnings("unused")
public AmazonS3StoragePersistenceAPIImpl(final Storage storage) {
+ this(storage, Config.getStringProperty(AWS_S3_BUCKET_NAME_PROP, null), PathEncryptionMode.NONE);
+ }
+
+ AmazonS3StoragePersistenceAPIImpl(final Storage storage, final String bucketName,
+ final PathEncryptionMode pathEncryptionMode) {
this.storage = storage;
+ this.bucketName = bucketName;
+ this.pathEncryptionMode = pathEncryptionMode;
+ this.lockManager = DotConcurrentFactory.getInstance().getIdentifierStripedLock();
+ this.sha256 = Try.of(() -> MessageDigest.getInstance(Encryptor.SHA256_ALGORITHM))
+ .getOrElseThrow(DotRuntimeException::new);
+ }
+
+ /**
+ * Creates an S3 provider with {@link PathEncryptionMode#NONE} — preserves the directory
+ * structure in S3 keys instead of hashing paths with SHA-256. Required for binary asset
+ * storage where prefix-based listing must work (SHA-256 hashing breaks prefix semantics).
+ *
+ * @return a new S3 provider configured with plain (unhashed) paths
+ */
+ public static AmazonS3StoragePersistenceAPIImpl withPlainPaths() {
+ final AmazonS3StoragePersistenceAPIImpl impl = new AmazonS3StoragePersistenceAPIImpl();
+ impl.pathEncryptionMode = PathEncryptionMode.NONE;
+ return impl;
}
@Override
@EnterpriseFeature(licenseLevel = LicenseLevel.PLATFORM, errorMsg = INVALID_LICENSE)
public boolean existsGroup(final String groupName) throws DotDataException {
+ if (!AssetStorageFeature.isEnabled()) {
if (this.groups.contains(groupName)) {
return true;
}
@@ -144,12 +209,42 @@ public boolean existsGroup(final String groupName) throws DotDataException {
}
this.groups.add(groupName);
return objectExists;
+
+ }
+
+ if (this.groups.contains(groupName)) {
+ return true;
+ }
+ boolean objectExists = this.storage.existsBucket(this.bucketName);
+ if (objectExists) {
+ objectExists = !this.storage.listObjects(this.bucketName,
+ groupKey(groupName) + FORWARD_SLASH).getObjectSummaries().isEmpty();
+ if (!objectExists) {
+ Logger.debug(this, () -> String.format("Group '%s' does not exist", groupName));
+ }
+ } else {
+ Logger.debug(this, () -> String.format("Bucket '%s' does not exist", this.bucketName));
+ }
+ if (objectExists) {
+ this.groups.add(groupName);
+ }
+ return objectExists;
}
@Override
@EnterpriseFeature(licenseLevel = LicenseLevel.PLATFORM, errorMsg = INVALID_LICENSE)
public boolean existsObject(final String groupName, final String objectPath) throws DotDataException {
final String correctedPath = transformReadPath(groupName, objectPath);
+ if (AssetStorageFeature.isEnabled()) {
+ try {
+ final var objects = this.storage.listObjects(this.bucketName, correctedPath);
+ final String directory = correctedPath.endsWith(FORWARD_SLASH) ? correctedPath : correctedPath + FORWARD_SLASH;
+ return objects != null && objects.getObjectSummaries().stream().anyMatch(object ->
+ object.getKey().equals(correctedPath) || object.getKey().startsWith(directory));
+ } catch (RuntimeException e) {
+ throw new DotDataException("Unable to check S3 object " + correctedPath, e);
+ }
+ }
final boolean exists = this.storage.existsBucket(this.bucketName) && !this.storage.listObjects(this.bucketName,
correctedPath).getObjectSummaries().isEmpty();
Logger.debug(this, () -> String.format("Object '%s' in group '%s' exists? %s", correctedPath, groupName, exists));
@@ -161,7 +256,7 @@ public boolean existsObject(final String groupName, final String objectPath) thr
public boolean createGroup(final String groupName) throws DotDataException {
if (!this.existsGroup(groupName)) {
Logger.debug(this, () -> String.format("Creating group with name '%s'", groupName));
- this.storage.createFolder(this.bucketName, groupName);
+ this.storage.createFolder(this.bucketName, groupKey(groupName));
}
this.groups.add(groupName);
return true;
@@ -177,6 +272,17 @@ public boolean createGroup(final String groupName, final Map ext
@Override
@EnterpriseFeature(licenseLevel = LicenseLevel.PLATFORM, errorMsg = INVALID_LICENSE)
public int deleteGroup(final String groupName) throws DotDataException {
+ if (AssetStorageFeature.isEnabled()) {
+ try {
+ for (final var object : storage.listObjects(bucketName, groupKey(groupName) + FORWARD_SLASH).getObjectSummaries()) {
+ storage.deleteFile(bucketName, object.getKey());
+ }
+ groups.remove(groupName);
+ return 0;
+ } catch (RuntimeException failure) {
+ throw new DotDataException("Unable to delete S3 group " + groupName, failure);
+ }
+ }
this.storage.deleteFolder(this.bucketName, groupName);
return 0;
}
@@ -184,14 +290,25 @@ public int deleteGroup(final String groupName) throws DotDataException {
@Override
@EnterpriseFeature(licenseLevel = LicenseLevel.PLATFORM, errorMsg = INVALID_LICENSE)
public boolean deleteObjectAndReferences(final String groupName, final String path) throws DotDataException {
+ if (!AssetStorageFeature.isEnabled()) {
this.storage.deleteFile(this.bucketName, groupName + path);
return true;
+
+ }
+
+ this.storage.deleteFile(this.bucketName, transformReadPath(groupName, path));
+ return true;
}
@Override
@EnterpriseFeature(licenseLevel = LicenseLevel.PLATFORM, errorMsg = INVALID_LICENSE)
public boolean deleteObjectReference(final String groupName, final String path) throws DotDataException {
+ if (!AssetStorageFeature.isEnabled()) {
return this.deleteObjectAndReferences(this.bucketName, groupName + path);
+
+ }
+
+ return this.deleteObjectAndReferences(groupName, path);
}
@Override
@@ -200,10 +317,36 @@ public List listGroups() {
return this.storage.listBuckets().stream().map(Bucket::getName).collect(Collectors.toList());
}
+ @Override
+ @EnterpriseFeature(licenseLevel = LicenseLevel.PLATFORM, errorMsg = INVALID_LICENSE)
+ public List listObjectPaths(final String groupName,
+ final String pathPrefix) throws DotDataException {
+
+ final String prefix = transformReadPath(groupName, pathPrefix);
+ final String s3Prefix = prefix.endsWith(FORWARD_SLASH) ? prefix : prefix + FORWARD_SLASH;
+ final com.amazonaws.services.s3.model.ObjectListing listing =
+ this.storage.listObjects(this.bucketName, s3Prefix);
+
+ if (listing == null || listing.getObjectSummaries().isEmpty()) {
+ return List.of();
+ }
+
+ final String groupPrefix = groupKey(groupName) + FORWARD_SLASH;
+ return listing.getObjectSummaries().stream()
+ .map(com.amazonaws.services.s3.model.S3ObjectSummary::getKey)
+ .filter(key -> key.startsWith(groupPrefix) && !key.endsWith(FORWARD_SLASH))
+ .map(key -> AssetStorageFeature.isEnabled() && pathEncryptionMode == PathEncryptionMode.SHA256
+ && pathPrefix.endsWith(FORWARD_SLASH)
+ ? pathPrefix + key.substring(s3Prefix.length())
+ : key.substring(groupPrefix.length()))
+ .collect(Collectors.toList());
+ }
+
@Override
@EnterpriseFeature(licenseLevel = LicenseLevel.PLATFORM, errorMsg = INVALID_LICENSE)
public Object pushFile(final String groupName, final String path, final File file,
final Map extraMeta) throws DotDataException {
+ if (!AssetStorageFeature.isEnabled()) {
final String pathForS3 = transformWritePath(groupName, path, file.getName());
final Upload upload = this.storage.uploadFile(this.bucketName, pathForS3,
file);
@@ -225,13 +368,38 @@ public Object pushFile(final String groupName, final String path, final File fil
throw new DotRuntimeException(String.format("Failed to push file '%s' to S3 group " +
"'%s': %s", path, groupName, ExceptionUtil.getErrorMessage(e)), e);
}
+
+ }
+
+ final String pathForS3 = transformReadPath(groupName, path);
+ final Upload upload = this.storage.uploadFile(new PutObjectRequest(this.bucketName, pathForS3, file));
+ try {
+ return lockManager.tryLock("s3_" + groupName + path, () -> {
+
+ Logger.debug(this, () -> String.format("Pushing file '%s' to group '%s' with " +
+ "path '%s' [ %s ]", file.getName(), groupName, pathForS3, path));
+ final UploadResult result =
+ Try.of(upload::waitForUploadResult).getOrElseThrow(e -> new DotDataException(e.getMessage(), e));
+ Logger.debug(this, () -> String.format("File '%s' in group '%s' with path '%s' " +
+ "[ %s ] was pushed successfully!", file.getName(), groupName,
+ pathForS3, path));
+ return result.getETag();
+
+ }
+ );
+ } catch (final Throwable e) {
+ throw new DotRuntimeException(String.format("Failed to push file '%s' to S3 group " +
+ "'%s': %s", path, groupName, ExceptionUtil.getErrorMessage(e)), e);
+ }
}
@Override
@EnterpriseFeature(licenseLevel = LicenseLevel.PLATFORM, errorMsg = INVALID_LICENSE)
public Object pushObject(final String groupName, final String path, final ObjectWriterDelegate writerDelegate,
final Serializable object, final Map extraMeta) throws DotDataException {
- final File file = new File(ConfigUtils.getAssetTempPath() + path);
+ final File file = AssetStorageFeature.isEnabled()
+ ? Try.of(() -> Files.createTempFile("s3-metadata-", ".tmp").toFile()).getOrElseThrow(DotDataException::new)
+ : new File(ConfigUtils.getAssetTempPath() + path);
try {
this.createTempFile(writerDelegate, object, file);
return this.pushFile(groupName, path, file, extraMeta);
@@ -272,17 +440,34 @@ private void createTempFile(final ObjectWriterDelegate writerDelegate,
@Override
@EnterpriseFeature(licenseLevel = LicenseLevel.PLATFORM, errorMsg = INVALID_LICENSE)
public Future
*/
public final class AssetStorageFeature {
- public static final String FLAG = "FEATURE_FLAG_S3_ASSET_STORAGE";
+ public static final String FLAG = FeatureFlagName.FEATURE_FLAG_S3_ASSET_STORAGE;
private static volatile Boolean enabled;
+ static {
+ // Registered before the first isEnabled() can latch, since that call loads this class.
+ Config.onOverride(FLAG, AssetStorageFeature::reset);
+ }
+
private AssetStorageFeature() { }
/**
@@ -45,8 +51,9 @@ public static boolean isEnabled() {
/**
* Forgets the value read at first use, so the next {@link #isEnabled()} reads the configuration
- * again. Called by {@link Config#setProperty(String, Object)} for in-memory overrides of the flag,
- * which tests use to switch modes.
+ * again. Registered with {@link Config#onOverride(String, Runnable)}, so in-memory overrides of the
+ * flag made with {@link Config#setProperty(String, Object)}, which tests use to switch modes, are
+ * re-read. Property reloads and the system table do not trigger it.
*/
public static void reset() {
enabled = null;
diff --git a/dotCMS/src/main/java/com/dotcms/storage/ChainableStoragePersistenceAPI.java b/dotCMS/src/main/java/com/dotcms/storage/ChainableStoragePersistenceAPI.java
index d3a8c48563b1..a119aeb749a8 100644
--- a/dotCMS/src/main/java/com/dotcms/storage/ChainableStoragePersistenceAPI.java
+++ b/dotCMS/src/main/java/com/dotcms/storage/ChainableStoragePersistenceAPI.java
@@ -7,6 +7,7 @@
import com.dotmarketing.util.Logger;
import com.dotmarketing.util.Config;
import com.google.common.annotations.VisibleForTesting;
+import com.google.common.util.concurrent.Striped;
import io.vavr.control.Try;
import java.io.File;
@@ -14,10 +15,13 @@
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
/**
* This API implements a chainable pattern that encapsulates a list of {@link StoragePersistenceAPI}
@@ -35,8 +39,21 @@ public class ChainableStoragePersistenceAPI implements StoragePersistenceAPI {
private final List storagePersistenceAPIList;
private final ObjectWriterDelegate defaultWriterDelegate;
private final Chainable404StorageCache cache;
- private final com.google.common.util.concurrent.Striped assetLocks =
- com.google.common.util.concurrent.Striped.lock(256);
+
+ /**
+ * Per-key locks used with S3 asset storage on, so reads, writes and deletes of one key do not
+ * interleave. They are shared by the whole process because they protect the key, not a chain:
+ * re-initializing the storage provider builds a new chain while the old one may still be in use.
+ * Each stripe is a {@link java.util.concurrent.locks.ReentrantLock}, so a thread can take the
+ * lock of a key it already holds. Code holding one of these locks must not take another one
+ * (for example by calling another locked operation of a chain for a different key): two keys
+ * can map to different stripes, and two threads taking them in opposite order deadlock until
+ * {@link #ASSET_LOCK_TIMEOUT_MINUTES} runs out.
+ */
+ private static final Striped ASSET_LOCKS = Striped.lock(256);
+
+ /** How long to wait for a key's lock. Generous because large binaries are transferred under it. */
+ private static final long ASSET_LOCK_TIMEOUT_MINUTES = 15;
private static final String SUBMITTER_NAME = Config.getStringProperty("COMPOSITE_STORAGE_SUBMITTER_NAME", "SubmitterCompositeStoragePersistenceAPI");
@@ -62,6 +79,55 @@ public Chainable404StorageCache getCache() {
return cache;
}
+ /**
+ * Takes the lock of a storage key, waiting at most {@link #ASSET_LOCK_TIMEOUT_MINUTES}. The key
+ * is lowercased because the filesystem provider stores keys that differ only by case in the same
+ * file; variants that a provider keeps apart merely wait for each other.
+ *
+ * @param groupName the group the key belongs to
+ * @param path the storage key
+ * @return the held lock, which the caller must unlock
+ * @throws DotDataException if the wait times out or the thread is interrupted
+ */
+ private static Lock lockKey(final String groupName, final String path) throws DotDataException {
+ final Lock lock = ASSET_LOCKS.get((groupName + "/" + path).toLowerCase(Locale.ROOT));
+ try {
+ if (!lock.tryLock(ASSET_LOCK_TIMEOUT_MINUTES, TimeUnit.MINUTES)) {
+ throw new DotDataException("Timed out waiting for storage key " + groupName + "/" + path);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new DotDataException("Interrupted waiting for storage key " + groupName + "/" + path, e);
+ }
+ return lock;
+ }
+
+ /**
+ * Called when the provider at {@code failedIndex} rejects a write. Writes go from the last
+ * provider to the first, so every provider after it already holds the new value while it and
+ * the providers before it still hold the previous one, and reads, which start at the first
+ * provider, would keep serving that stale copy. Deletes those copies, best effort, so the next
+ * read restores the new value from a later provider. Does nothing when the last provider, the
+ * first one written, failed, because then nothing has changed.
+ *
+ * @param groupName the group of the key being written
+ * @param path the key being written
+ * @param failedIndex the position in the chain of the provider whose write failed
+ */
+ private void dropCopiesOlderThanAcceptedWrite(final String groupName, final String path,
+ final int failedIndex) {
+ if (failedIndex == storagePersistenceAPIList.size() - 1) {
+ return;
+ }
+ for (int i = failedIndex; i >= 0; i--) {
+ final StoragePersistenceAPI storage = storagePersistenceAPIList.get(i);
+ Try.run(() -> storage.deleteObjectAndReferences(groupName, path))
+ .onFailure(e -> Logger.error(this, "Stale copy of " + groupName + "/" + path
+ + " left in " + storage + "; reads may serve it", e));
+ }
+ cache.remove(groupName, path);
+ }
+
@Override
public boolean existsGroup(final String groupName) {
@@ -137,12 +203,21 @@ public int deleteGroup(final String groupName) throws DotDataException {
return deleteCount;
}
+ /**
+ * Deletes the object from every provider. With S3 asset storage on, this happens under the key's
+ * lock, from the last provider to the first, so the local copy goes only after the durable
+ * providers accepted the deletion.
+ *
+ * @param groupName the group of the object
+ * @param path the object's path
+ * @return {@code true} if every provider reported the object deleted
+ * @throws DotDataException if a provider fails, or the key's lock cannot be taken in time
+ */
@Override
public boolean deleteObjectAndReferences(final String groupName, final String path) throws DotDataException {
if (AssetStorageFeature.isEnabled()) {
- final var lock = assetLocks.get(groupName + "/" + path);
- lock.lock();
+ final Lock lock = lockKey(groupName, path);
try {
// Keep the local copy until all durable providers accept the deletion.
boolean deleted = !storagePersistenceAPIList.isEmpty();
@@ -245,18 +320,36 @@ public boolean backfillObject(final String groupName, final String path, final O
return durable;
}
+ /**
+ * Stores the file in every provider. With S3 asset storage on, this happens under the key's lock,
+ * from the last provider to the first, so durable storage holds the file before the local copy
+ * changes. If a provider fails after a later one accepted the file, the now-stale copies of it
+ * and the providers before it are deleted, so the next read restores the new file, and the
+ * failure is rethrown.
+ *
+ * @param groupName the group to store into
+ * @param path the file's key
+ * @param file the file to store
+ * @param extraMeta extra metadata for providers that keep it
+ * @return the result of the first provider in the chain
+ * @throws DotDataException if a provider fails, or the key's lock cannot be taken in time
+ */
@Override
public Object pushFile(final String groupName, final String path, final File file,
final Map extraMeta) throws DotDataException {
if (AssetStorageFeature.isEnabled()) {
- final var lock = assetLocks.get(groupName + "/" + path);
- lock.lock();
+ final Lock lock = lockKey(groupName, path);
try {
Object result = null;
// Publish durably before exposing the new local cache contents.
for (int i = storagePersistenceAPIList.size() - 1; i >= 0; i--) {
- result = storagePersistenceAPIList.get(i).pushFile(groupName, path, file, extraMeta);
+ try {
+ result = storagePersistenceAPIList.get(i).pushFile(groupName, path, file, extraMeta);
+ } catch (DotDataException | RuntimeException failure) {
+ dropCopiesOlderThanAcceptedWrite(groupName, path, i);
+ throw failure;
+ }
}
cache.remove(groupName, path);
return result;
@@ -279,17 +372,34 @@ public Object pushFile(final String groupName, final String path, final File fil
return object;
}
+ /**
+ * Stores the object in every provider. With S3 asset storage on, this behaves like
+ * {@link #pushFile(String, String, File, Map)}: under the key's lock, durable providers first,
+ * and stale copies are deleted when a provider fails after a later one accepted the object.
+ *
+ * @param groupName the group to store into
+ * @param path the object's key
+ * @param writerDelegate serializes the object
+ * @param objectIn the object to store
+ * @param extraMeta extra metadata for providers that keep it
+ * @return the result of the first provider in the chain
+ * @throws DotDataException if a provider fails, or the key's lock cannot be taken in time
+ */
@Override
public Object pushObject(final String groupName, final String path,
final ObjectWriterDelegate writerDelegate, final Serializable objectIn,
final Map extraMeta) throws DotDataException {
if (AssetStorageFeature.isEnabled()) {
- final var lock = assetLocks.get(groupName + "/" + path);
- lock.lock();
+ final Lock lock = lockKey(groupName, path);
try {
Object result = null;
for (int i = storagePersistenceAPIList.size() - 1; i >= 0; i--) {
- result = storagePersistenceAPIList.get(i).pushObject(groupName, path, writerDelegate, objectIn, extraMeta);
+ try {
+ result = storagePersistenceAPIList.get(i).pushObject(groupName, path, writerDelegate, objectIn, extraMeta);
+ } catch (DotDataException | RuntimeException failure) {
+ dropCopiesOlderThanAcceptedWrite(groupName, path, i);
+ throw failure;
+ }
}
cache.remove(groupName, path);
return result;
@@ -367,12 +477,21 @@ public Future pushObjectAsync(final String bucketName, final String path
return futures.get(0); // well we return the first one at least
}
+ /**
+ * Returns the file from the first provider that has it. With S3 asset storage on, this happens
+ * under the key's lock, providers before the one that had it are refilled with the file, and
+ * read failures propagate instead of being reported as absence.
+ *
+ * @param groupName the group to read from
+ * @param path the file's key
+ * @return the file, or {@code null} if no provider has it
+ * @throws DotDataException if a provider fails, or the key's lock cannot be taken in time
+ */
@Override
public File pullFile(final String groupName, final String path) throws DotDataException {
if (AssetStorageFeature.isEnabled()) {
- final var lock = assetLocks.get(groupName + "/" + path);
- lock.lock();
+ final Lock lock = lockKey(groupName, path);
try {
final List missing = new ArrayList<>();
for (StoragePersistenceAPI storage : storagePersistenceAPIList) {
@@ -468,8 +587,12 @@ public Object pullObject(final String groupName,
if (AssetStorageFeature.isEnabled()) {
final List missing = new ArrayList<>();
UnreadableStoredObjectException unreadable = null;
- final var lock = assetLocks.get(groupName + "/" + path);
- lock.lock();
+ final Lock lock;
+ try {
+ lock = lockKey(groupName, path);
+ } catch (DotDataException e) {
+ throw new DotRuntimeException("Unable to retrieve stored object " + path, e);
+ }
try {
for (StoragePersistenceAPI storage : storagePersistenceAPIList) {
Object object;
diff --git a/dotCMS/src/main/java/com/dotcms/storage/FileSystemStoragePersistenceAPIImpl.java b/dotCMS/src/main/java/com/dotcms/storage/FileSystemStoragePersistenceAPIImpl.java
index 8443b7f164ce..d78f8bbddb19 100644
--- a/dotCMS/src/main/java/com/dotcms/storage/FileSystemStoragePersistenceAPIImpl.java
+++ b/dotCMS/src/main/java/com/dotcms/storage/FileSystemStoragePersistenceAPIImpl.java
@@ -23,6 +23,7 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Serializable;
+import java.nio.file.FileSystemException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
@@ -404,9 +405,11 @@ public File pullFile(final String groupName, final String path) throws DotDataEx
/**
* Reads and deserializes the stored object for the path. With S3 asset storage on, an unmapped
- * group or a missing file returns {@code null}. A zero-length or truncated file is kept and
- * reported as {@link UnreadableStoredObjectException}, so the chain can replace it from durable
- * storage and nothing treats it as absent and regenerates it. Other read failures throw.
+ * group or a missing file returns {@code null}. A file that opens but cannot be decoded, such as
+ * a zero-length or truncated file or one not in the configured {@code CONTENT_METADATA_COMPRESSOR}
+ * format, is kept and reported as {@link UnreadableStoredObjectException}, so the chain can
+ * replace it from durable storage and nothing treats it as absent and regenerates it. A file
+ * that cannot be opened throws {@link DotDataException}.
* With the flag off, a corrupt file is deleted and an empty map is returned, and a missing group
* or file throws {@link IllegalArgumentException}, as before.
*
@@ -426,19 +429,23 @@ public Object pullObject(final String groupName, final String path,
if (groupDir == null) {
return null;
}
+ final Path file;
try {
- final Path file = Paths.get(groupDir.getCanonicalPath(), normalizePath(groupName, path));
- final String compressor = Config.getStringProperty("CONTENT_METADATA_COMPRESSOR", "none");
- try (InputStream input = FileUtil.createInputStream(file, compressor)) {
- return readerDelegate.read(input);
- }
+ file = Paths.get(groupDir.getCanonicalPath(), normalizePath(groupName, path));
+ } catch (IOException e) {
+ throw new DotDataException("Unable to read stored object " + path, e);
+ }
+ try (InputStream input = FileUtil.createInputStream(file, contentMetadataCompressor.get())) {
+ return readerDelegate.read(input);
} catch (NoSuchFileException absent) {
return null;
- } catch (com.fasterxml.jackson.core.JsonProcessingException | java.io.EOFException corrupt) {
- // Keep the file: the chain replaces it from durable storage, or the read fails.
- throw new UnreadableStoredObjectException("Unable to read stored object " + path, corrupt);
- } catch (IOException e) {
+ } catch (FileSystemException e) {
+ // Opening the file failed (permissions, too many open files, ...): an I/O problem, not corruption.
throw new DotDataException("Unable to read stored object " + path, e);
+ } catch (IOException corrupt) {
+ // The file opened but could not be decoded (bad compression header, truncated or invalid
+ // contents). Keep it: the chain replaces it from durable storage, or the read fails.
+ throw new UnreadableStoredObjectException("Unable to read stored object " + path, corrupt);
}
}
diff --git a/dotCMS/src/main/java/com/dotcms/storage/S3ContentAddressedStorage.java b/dotCMS/src/main/java/com/dotcms/storage/S3ContentAddressedStorage.java
index d9def8ea94b1..a20c95f07ecc 100644
--- a/dotCMS/src/main/java/com/dotcms/storage/S3ContentAddressedStorage.java
+++ b/dotCMS/src/main/java/com/dotcms/storage/S3ContentAddressedStorage.java
@@ -45,6 +45,19 @@ static String hash(File file) throws IOException {
return HexFormat.of().formatHex(digest.digest());
}
+ /**
+ * Stores the file's bytes once under their SHA-256 and writes a small reference to them at the
+ * owner's key. A new blob is uploaded with a create-only write and is not downloaded again. A
+ * blob that already existed, or that another writer created first, is downloaded once and
+ * compared byte for byte. The method finishes by reading back the reference it published.
+ *
+ * @param ownerKey the content-owned key that will hold the reference
+ * @param source the file to store; a private copy is hashed and uploaded
+ * @param backfill {@code true} to convert an existing raw object or keep an existing matching
+ * reference instead of overwriting it, failing if either conflicts
+ * @return the SHA-256 of the stored bytes
+ * @throws DotDataException if the bytes or the reference cannot be stored or verified
+ */
String store(String ownerKey, File source, boolean backfill) throws DotDataException {
Path snapshot = null;
Path reference = null;
@@ -76,14 +89,19 @@ String store(String ownerKey, File source, boolean backfill) throws DotDataExcep
}
}
}
- if (!blobMatches(blob, snapshot.toFile())) {
+ // The SDK checks the MD5 of each upload request against S3's response, and the key is the
+ // SHA-256 of the snapshot we sent, so only a blob someone else wrote needs a full comparison.
+ final var existing = storage.listFirstObject(bucket, blob);
+ boolean writtenElsewhere = existing != null && blob.equals(existing.getKey());
+ if (!writtenElsewhere) {
try { storage.uploadFileIfAbsent(bucket, blob, snapshot.toFile()); }
catch (RuntimeException conflict) {
if (!preconditionFailure(conflict)) throw conflict;
+ writtenElsewhere = true;
}
- if (!blobMatches(blob, snapshot.toFile())) {
- throw new DotDataException("Shared asset bytes do not match their SHA-256 key");
- }
+ }
+ if (writtenElsewhere && !blobMatches(blob, snapshot.toFile())) {
+ throw new DotDataException("Shared asset bytes do not match their SHA-256 key");
}
reference = Files.createTempFile("s3-asset-reference-", ".json");
Files.writeString(reference, "{\"sha256\":\"" + hash + "\",\"length\":" + Files.size(snapshot) + "}");
@@ -105,7 +123,6 @@ String store(String ownerKey, File source, boolean backfill) throws DotDataExcep
}
consumeReference(published);
}
- if (!matches(ownerKey, snapshot.toFile())) throw new DotDataException("Shared asset bytes were not verified after publication");
return hash;
} catch (Exception failure) {
if (failure instanceof InterruptedException) Thread.currentThread().interrupt();
@@ -116,7 +133,15 @@ String store(String ownerKey, File source, boolean backfill) throws DotDataExcep
}
}
- private static boolean preconditionFailure(Throwable failure) {
+ /**
+ * Reports whether a failed S3 write was a conditional write that lost to an existing object:
+ * HTTP 412 when the condition was false, or 409 {@code ConditionalRequestConflict} when two
+ * conditional writes of one key raced. Causes are checked too, since callers may wrap the error.
+ *
+ * @param failure the error thrown by the write
+ * @return {@code true} if the write was refused because another write got there first
+ */
+ static boolean preconditionFailure(Throwable failure) {
for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
if (cause instanceof com.amazonaws.services.s3.model.AmazonS3Exception s3
&& (s3.getStatusCode() == 412 || s3.getStatusCode() == 409)) return true;
diff --git a/dotCMS/src/main/java/com/dotcms/storage/SharedExtractedMetadata.java b/dotCMS/src/main/java/com/dotcms/storage/SharedExtractedMetadata.java
index 099571bc95ad..0004cfa4afc3 100644
--- a/dotCMS/src/main/java/com/dotcms/storage/SharedExtractedMetadata.java
+++ b/dotCMS/src/main/java/com/dotcms/storage/SharedExtractedMetadata.java
@@ -23,10 +23,25 @@ private static class Holder {
static SharedExtractedMetadata getInstance() { return Holder.INSTANCE; }
SharedExtractedMetadata(StoragePersistenceAPI storage) { this.storage = storage; }
+ /**
+ * Returns the byte-derived metadata for a file, sharing one extraction across every copy of the
+ * same bytes when S3 asset storage is on. Extracts directly, without the shared cache, when the
+ * feature is off or the extractor version is unknown, because unknown parser bundles must not
+ * share one cached result.
+ *
+ * @param source the file to extract from
+ * @param extractorVersion the Tika bundle version, or {@code null} when it cannot be determined
+ * @param schemaVersion the metadata schema version, part of the cache key
+ * @param textLimit the maximum extracted text length, part of the cache key
+ * @param extract the extraction to run on a cache miss
+ * @return the extracted metadata, as a mutable map
+ * @throws Exception when copying, hashing, or publishing the extraction fails
+ */
@SuppressWarnings("unchecked")
Map get(File source, String extractorVersion, int schemaVersion, int textLimit,
Function> extract) throws Exception {
if (!AssetStorageFeature.isEnabled()) return extract.apply(source);
+ if (extractorVersion == null || extractorVersion.isBlank()) return extract.apply(source);
final var snapshot = Files.createTempFile("asset-extraction-", ".tmp");
try {
Files.copy(source.toPath(), snapshot, StandardCopyOption.REPLACE_EXISTING);
diff --git a/dotCMS/src/main/java/com/dotmarketing/util/Config.java b/dotCMS/src/main/java/com/dotmarketing/util/Config.java
index 6a9a92e45be3..4a3c73359f8a 100644
--- a/dotCMS/src/main/java/com/dotmarketing/util/Config.java
+++ b/dotCMS/src/main/java/com/dotmarketing/util/Config.java
@@ -27,6 +27,7 @@
import java.nio.file.Files;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
@@ -53,6 +54,8 @@ public class Config {
public static final Map testOverrideTracker = new ConcurrentHashMap<>();
+ private static final Map> overrideCallbacks = new ConcurrentHashMap<>();
+
private static final Set environmentSetKeys = ConcurrentHashMap.newKeySet();
private static SystemTableConfigSource systemTableConfigSource = null;
@@ -782,25 +785,39 @@ public static boolean getBooleanProperty(String name, boolean defaultVal) {
}
/**
- * @param key
- * @param value
+ * Overrides a property in memory. When the {@code DOT_}-prefixed form of the key is already
+ * present, that form is overridden instead. Afterwards, runs the callbacks registered with
+ * {@link #onOverride(String, Runnable)} for the key exactly as it was passed in.
+ *
+ * @param key the property name
+ * @param value the new value
*/
public static void setProperty(String key, Object value) {
if (props != null) {
- final boolean assetStorageFlag = com.dotcms.storage.AssetStorageFeature.FLAG.equals(key);
+ final List callbacks = overrideCallbacks.getOrDefault(key, List.of());
if(props.containsKey(envKey(key))) {
key = envKey(key);
}
trackOverrides(key, value);
Logger.info(Config.class, "Setting property: " + key + " to " + value);
props.setProperty(key, value);
- if (assetStorageFlag) {
- // Only in-memory overrides re-read the latched flag; reloads and the system table stay latched.
- com.dotcms.storage.AssetStorageFeature.reset();
- }
+ callbacks.forEach(Runnable::run);
}
}
+ /**
+ * Registers a callback that runs after {@link #setProperty(String, Object)} overrides the given
+ * key in memory. Lets code that caches a property, for example a flag read once per process,
+ * forget that value when a test switches it. Property file reloads and system table changes do
+ * not run the callback.
+ *
+ * @param key the property name, as callers pass it to {@code setProperty}
+ * @param callback the action to run after each in-memory override of that key
+ */
+ public static void onOverride(final String key, final Runnable callback) {
+ overrideCallbacks.computeIfAbsent(key, k -> new CopyOnWriteArrayList<>()).add(callback);
+ }
+
private static void trackOverrides(String key, Object value) {
if (props.getBoolean(USE_CONFIG_TEST_OVERRIDE_TRACKER, false)) {
if (value == null) {
diff --git a/dotCMS/src/test/java/com/dotcms/storage/AssetStorageFeatureLatchTest.java b/dotCMS/src/test/java/com/dotcms/storage/AssetStorageFeatureLatchTest.java
index 722bd390ad81..62e76aa17bcc 100644
--- a/dotCMS/src/test/java/com/dotcms/storage/AssetStorageFeatureLatchTest.java
+++ b/dotCMS/src/test/java/com/dotcms/storage/AssetStorageFeatureLatchTest.java
@@ -4,6 +4,8 @@
import static org.mockito.Mockito.mockStatic;
import com.dotmarketing.util.Config;
+import java.util.ArrayList;
+import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -43,4 +45,21 @@ void inMemoryOverridesSwitchTheMode() {
Config.setProperty(AssetStorageFeature.FLAG, false);
assertFalse(AssetStorageFeature.isEnabled());
}
+
+ @Test
+ void overrideCallbacksRunOnlyForTheirKeyAfterTheValueIsSet() {
+ final String key = "ASSET_STORAGE_LATCH_TEST_HOOKED_KEY";
+ final String otherKey = "ASSET_STORAGE_LATCH_TEST_OTHER_KEY";
+ final List seen = new ArrayList<>();
+ Config.onOverride(key, () -> seen.add(Config.getStringProperty(key, null)));
+ try {
+ Config.setProperty(otherKey, "ignored");
+ assertTrue(seen.isEmpty(), "Overriding another key must not run the callback");
+ Config.setProperty(key, "updated");
+ assertEquals(List.of("updated"), seen, "The callback runs once and sees the new value");
+ } finally {
+ Config.setProperty(key, null);
+ Config.setProperty(otherKey, null);
+ }
+ }
}
diff --git a/dotCMS/src/test/java/com/dotcms/storage/AssetStorageFeatureTest.java b/dotCMS/src/test/java/com/dotcms/storage/AssetStorageFeatureTest.java
index 21dfa0dc32b0..eda8870e58b0 100644
--- a/dotCMS/src/test/java/com/dotcms/storage/AssetStorageFeatureTest.java
+++ b/dotCMS/src/test/java/com/dotcms/storage/AssetStorageFeatureTest.java
@@ -156,6 +156,47 @@ void failedRemoteWriteKeepsPreviousLocalContents() throws Exception {
assertEquals("previous version", Files.readString(destination));
}
+ @Test
+ void failedLocalWriteAfterDurableWriteServesTheDurableFile() throws Exception {
+ var local = spy(filesystem());
+ Path destination = root.resolve(KEY.toLowerCase());
+ Files.createDirectories(destination.getParent());
+ Files.writeString(destination, "previous version");
+ File upload = Files.writeString(root.resolve("upload"), "replacement").toFile();
+ File download = Files.writeString(root.resolve("download.tmp"), "replacement").toFile();
+ var remote = mock(StoragePersistenceAPI.class);
+ when(remote.pullFile(GROUP, KEY)).thenReturn(download);
+ doThrow(new DotDataException("disk full")).doCallRealMethod()
+ .when(local).pushFile(eq(GROUP), eq(KEY), any(), any());
+ var chain = chain(local, remote);
+
+ assertThrows(DotDataException.class, () -> chain.pushFile(GROUP, KEY, upload, Map.of()),
+ "The local failure reaches the caller");
+ verify(remote).pushFile(GROUP, KEY, upload, Map.of());
+ assertEquals("replacement", Files.readString(chain.pullFile(GROUP, KEY).toPath()),
+ "The next read restores the durable copy instead of serving the stale local one");
+ }
+
+ @Test
+ void failedLocalObjectWriteAfterDurableWriteServesTheDurableValue() throws Exception {
+ var local = spy(filesystem());
+ var reader = new JsonReaderDelegate<>(String.class);
+ local.pushObject(GROUP, KEY, new JsonWriterDelegate(), "Original", Map.of());
+ var remote = mock(StoragePersistenceAPI.class);
+ when(remote.pullObject(GROUP, KEY, reader)).thenReturn("Replacement");
+ // The filesystem provider reports object write failures as unchecked exceptions.
+ doThrow(new RuntimeException("disk full")).doCallRealMethod()
+ .when(local).pushObject(eq(GROUP), eq(KEY), any(), any(), any());
+ var chain = chain(local, remote);
+
+ assertThrows(RuntimeException.class,
+ () -> chain.pushObject(GROUP, KEY, new JsonWriterDelegate(), "Replacement", Map.of()),
+ "The local failure reaches the caller");
+ assertEquals("Replacement", chain.pullObject(GROUP, KEY, reader),
+ "The next read returns the durable value instead of the stale local one");
+ assertEquals("Replacement", local.pullObject(GROUP, KEY, reader), "The local copy is restored");
+ }
+
@Test
void asyncWriteReportsRemoteFailureAndDoesNotPublishLocalReplacement() throws Exception {
var fs = filesystem();
@@ -249,6 +290,42 @@ void deleteCannotBeUndoneBySameChainInflightObjectRestore() throws Exception {
}
}
+ @Test
+ void sameKeyIsLockedAcrossChainInstancesAndLetterCase() throws Exception {
+ var remote = mock(StoragePersistenceAPI.class);
+ File snapshot = Files.writeString(root.resolve("download.tmp"), "remote").toFile();
+ var fetching = new CountDownLatch(1);
+ var finishFetch = new CountDownLatch(1);
+ when(remote.pullFile(GROUP, KEY)).thenAnswer(call -> {
+ fetching.countDown();
+ assertTrue(finishFetch.await(10, TimeUnit.SECONDS));
+ return snapshot;
+ });
+ // Re-initializing the storage provider builds a new chain while the old one may still be in use.
+ var readingChain = chain(filesystem(), remote);
+ var deletingChain = chain(filesystem(), remote);
+ try (var executor = Executors.newFixedThreadPool(2)) {
+ var read = executor.submit(() -> readingChain.pullFile(GROUP, KEY));
+ assertTrue(fetching.await(5, TimeUnit.SECONDS));
+ var deleting = new CountDownLatch(1);
+ // The filesystem keeps one file for both spellings, so they must share a lock.
+ var delete = executor.submit(() -> {
+ deleting.countDown();
+ return deletingChain.deleteObjectAndReferences(GROUP, KEY.toUpperCase());
+ });
+ assertTrue(deleting.await(5, TimeUnit.SECONDS));
+ try {
+ assertThrows(TimeoutException.class, () -> delete.get(100, TimeUnit.MILLISECONDS),
+ "A delete through another chain, spelled differently, must wait for the restore");
+ } finally {
+ finishFetch.countDown();
+ }
+ read.get(5, TimeUnit.SECONDS);
+ delete.get(5, TimeUnit.SECONDS);
+ assertFalse(Files.exists(root.resolve(KEY.toLowerCase())), "The deleted file must not come back locally");
+ }
+ }
+
@Test
void unreadableLocalMetadataIsReplacedFromADurableCopyAndOtherwiseFails() throws Exception {
// Metadata keeps lowercased keys in every slice; the binary groups later become case-preserving.
@@ -274,6 +351,33 @@ void unreadableLocalMetadataIsReplacedFromADurableCopyAndOtherwiseFails() throws
assertEquals("\"dura", Files.readString(local), "An unreadable copy without a replacement is kept");
}
+ @Test
+ void localMetadataNotInTheConfiguredCompressionIsReplacedFromADurableCopy() throws Exception {
+ final String group = "dotmetadata";
+ final String previousCompressor = Config.getStringProperty("CONTENT_METADATA_COMPRESSOR", null);
+ var reader = new JsonReaderDelegate<>(String.class);
+ Path local = root.resolve(KEY.toLowerCase());
+ Files.createDirectories(local.getParent());
+ try {
+ for (String compressor : List.of("gzip", "bzip2")) {
+ Config.setProperty("CONTENT_METADATA_COMPRESSOR", compressor);
+ // The provider reads the compressor once, so it is created after setting it.
+ var fs = new FileSystemStoragePersistenceAPIImpl();
+ fs.addGroupMapping(group, root.toFile());
+ Files.writeString(local, "\"durable\"");
+ var remote = mock(StoragePersistenceAPI.class);
+ when(remote.pullObject(group, KEY, reader)).thenReturn("durable");
+
+ assertEquals("durable", chain(fs, remote).pullObject(group, KEY, reader),
+ "A " + compressor + " file that cannot be decoded is replaced, not a failed read");
+ assertEquals("durable", fs.pullObject(group, KEY, reader),
+ "The local copy is replaced in the " + compressor + " format");
+ }
+ } finally {
+ Config.setProperty("CONTENT_METADATA_COMPRESSOR", previousCompressor);
+ }
+ }
+
@Test
void filesystemListingFindsKeysWhateverTheCaseOfThePrefix() throws Exception {
// Metadata keeps lowercased keys in every slice; the binary groups later become case-preserving.
diff --git a/dotCMS/src/test/java/com/dotcms/storage/S3ContentAddressedStorageTest.java b/dotCMS/src/test/java/com/dotcms/storage/S3ContentAddressedStorageTest.java
new file mode 100644
index 000000000000..d951061b62d3
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/storage/S3ContentAddressedStorageTest.java
@@ -0,0 +1,112 @@
+package com.dotcms.storage;
+
+import com.amazonaws.services.s3.model.AmazonS3Exception;
+import com.amazonaws.services.s3.model.ObjectMetadata;
+import com.amazonaws.services.s3.model.PutObjectRequest;
+import com.amazonaws.services.s3.model.S3Object;
+import com.amazonaws.services.s3.model.S3ObjectSummary;
+import com.amazonaws.services.s3.transfer.Upload;
+import com.dotcms.enterprise.publishing.storage.Storage;
+import com.dotmarketing.exception.DotDataException;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/** Store verifies shared bytes with at most one full blob download. */
+class S3ContentAddressedStorageTest {
+ private static final String BUCKET = "bucket";
+ private static final String OWNER = "binary-assets/a/b/file";
+ private static final byte[] BYTES = "shared asset bytes".getBytes(StandardCharsets.UTF_8);
+ private static final String HASH = DigestUtils.sha256Hex(BYTES);
+ private static final String BLOB = S3ContentAddressedStorage.blobKey(HASH);
+
+ @TempDir Path root;
+ private Storage storage;
+ private File source;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ source = Files.write(root.resolve("source"), BYTES).toFile();
+ storage = mock(Storage.class);
+ when(storage.uploadFile(any(PutObjectRequest.class))).thenReturn(mock(Upload.class));
+ when(storage.getObject(BUCKET, OWNER)).thenAnswer(call ->
+ object("{}".getBytes(StandardCharsets.UTF_8), HASH));
+ }
+
+ @Test
+ void storingANewBlobDoesNotDownloadIt() throws Exception {
+ final var uploaded = new java.util.concurrent.atomic.AtomicBoolean();
+ doAnswer(call -> { uploaded.set(true); return null; })
+ .when(storage).uploadFileIfAbsent(eq(BUCKET), eq(BLOB), any(File.class));
+ when(storage.getObject(BUCKET, BLOB)).thenAnswer(call -> uploaded.get() ? object(BYTES, null) : null);
+
+ assertEquals(HASH, new S3ContentAddressedStorage(storage, BUCKET).store(OWNER, source, false));
+ verify(storage).uploadFileIfAbsent(eq(BUCKET), eq(BLOB), any(File.class));
+ verify(storage, never()).getObject(BUCKET, BLOB);
+ }
+
+ @Test
+ void storingAnExistingBlobComparesItsBytesOnce() throws Exception {
+ when(storage.listFirstObject(BUCKET, BLOB)).thenReturn(summary());
+ when(storage.getObject(BUCKET, BLOB)).thenAnswer(call -> object(BYTES, null));
+
+ assertEquals(HASH, new S3ContentAddressedStorage(storage, BUCKET).store(OWNER, source, false));
+ verify(storage, times(1)).getObject(BUCKET, BLOB);
+ verify(storage, never()).uploadFileIfAbsent(any(), any(), any());
+ }
+
+ @Test
+ void storingABlobWrittenConcurrentlyByAnotherNodeComparesItsBytesOnce() throws Exception {
+ // The blob is absent when listed, then another node's conditional write wins the race.
+ final var written = new java.util.concurrent.atomic.AtomicBoolean();
+ final var conflict = new AmazonS3Exception("ConditionalRequestConflict");
+ conflict.setStatusCode(409);
+ doAnswer(call -> { written.set(true); throw conflict; })
+ .when(storage).uploadFileIfAbsent(eq(BUCKET), eq(BLOB), any(File.class));
+ when(storage.getObject(BUCKET, BLOB)).thenAnswer(call -> written.get() ? object(BYTES, null) : null);
+
+ assertEquals(HASH, new S3ContentAddressedStorage(storage, BUCKET).store(OWNER, source, false));
+ verify(storage, times(1)).getObject(BUCKET, BLOB);
+ }
+
+ @Test
+ void anExistingBlobWithDifferentBytesFailsWithoutPublishingAReference() throws Exception {
+ final byte[] other = BYTES.clone();
+ other[0] ^= 1;
+ when(storage.listFirstObject(BUCKET, BLOB)).thenReturn(summary());
+ when(storage.getObject(BUCKET, BLOB)).thenAnswer(call -> object(other, null));
+
+ assertThrows(DotDataException.class,
+ () -> new S3ContentAddressedStorage(storage, BUCKET).store(OWNER, source, false));
+ verify(storage, never()).uploadFile(any(PutObjectRequest.class));
+ }
+
+ private static S3ObjectSummary summary() {
+ final var summary = new S3ObjectSummary();
+ summary.setKey(BLOB);
+ summary.setSize(BYTES.length);
+ return summary;
+ }
+
+ private static S3Object object(final byte[] bytes, final String hashHeader) {
+ final var metadata = new ObjectMetadata();
+ metadata.setContentLength(bytes.length);
+ if (hashHeader != null) {
+ metadata.addUserMetadata(S3ContentAddressedStorage.HASH_HEADER, hashHeader);
+ }
+ final var object = new S3Object();
+ object.setObjectMetadata(metadata);
+ object.setObjectContent(new ByteArrayInputStream(bytes));
+ return object;
+ }
+}
diff --git a/dotCMS/src/test/java/com/dotcms/storage/S3StorageConfigurationTest.java b/dotCMS/src/test/java/com/dotcms/storage/S3StorageConfigurationTest.java
index eba855768d7b..254fe84ab38f 100644
--- a/dotCMS/src/test/java/com/dotcms/storage/S3StorageConfigurationTest.java
+++ b/dotCMS/src/test/java/com/dotcms/storage/S3StorageConfigurationTest.java
@@ -1,17 +1,39 @@
package com.dotcms.storage;
import com.amazonaws.services.s3.AmazonS3;
+import com.amazonaws.services.s3.model.AmazonS3Exception;
+import com.amazonaws.services.s3.model.PutObjectRequest;
+import com.amazonaws.services.s3.model.S3ObjectSummary;
+import com.amazonaws.services.s3.transfer.Download;
+import com.amazonaws.services.s3.transfer.Upload;
+import com.amazonaws.services.s3.transfer.model.UploadResult;
+import com.dotcms.concurrent.DotConcurrentFactory;
import com.dotcms.enterprise.publishing.staticpublishing.AWSS3Configuration;
import com.dotcms.enterprise.publishing.storage.AWSS3Storage;
+import com.dotcms.enterprise.publishing.storage.Storage;
+import com.dotcms.storage.repository.LocalFileRepositoryManager;
import com.dotmarketing.exception.DotRuntimeException;
import com.dotmarketing.util.Config;
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.codec.digest.DigestUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
class S3StorageConfigurationTest {
private final Map previous = new HashMap<>();
@@ -140,6 +162,91 @@ void staticPushClientsWithoutARegionUseTheGlobalEndpointWithRegionLookup() throw
} finally { legacy.shutdownTransferManager(); }
}
+ @Test
+ void enabledUploadsDoNotHoldTheSharedIdentifierLock(@TempDir Path root) throws Exception {
+ // The identifier lock is shared with content saves; an upload must not make them time out.
+ final var storage = mock(Storage.class);
+ final var upload = mock(Upload.class);
+ final var result = new UploadResult();
+ result.setETag("etag");
+ when(storage.uploadFile(any(PutObjectRequest.class))).thenReturn(upload);
+ final var provider = new AmazonS3StoragePersistenceAPIImpl(storage, "bucket",
+ AmazonS3StoragePersistenceAPIImpl.PathEncryptionMode.NONE);
+ final File file = Files.writeString(root.resolve("upload.tmp"), "contents").toFile();
+ try (var executor = Executors.newSingleThreadExecutor()) {
+ when(upload.waitForUploadResult()).thenAnswer(call -> executor.submit(() -> {
+ try {
+ return DotConcurrentFactory.getInstance().getIdentifierStripedLock()
+ .tryLock("s3_binary-assets/a/b/file", () -> result);
+ } catch (Throwable lockTimeout) {
+ throw new Exception(lockTimeout);
+ }
+ }).get(10, TimeUnit.SECONDS));
+ assertEquals("etag", provider.pushFile("binary-assets", "/a/b/file", file, Map.of()));
+ }
+ }
+
+ @Test
+ void enabledDownloadsUseTheirOwnFileWhateverTheRepositoryAndAreDeletedOnRelease(@TempDir Path root) throws Exception {
+ final var storage = mock(Storage.class);
+ when(storage.downloadFile(eq("bucket"), eq("binary-assets/a/b/file"), any(File.class))).thenAnswer(call -> {
+ Files.writeString(call.getArgument(2).toPath(), "remote");
+ return mock(Download.class);
+ });
+ final var provider = new AmazonS3StoragePersistenceAPIImpl(storage, "bucket",
+ AmazonS3StoragePersistenceAPIImpl.PathEncryptionMode.NONE);
+ // S3_STORAGE_FILE_REPO_TYPE=LOCAL maps every download of a key to one fixed path.
+ final Path repository = root.resolve("local-repo");
+ Files.createDirectories(repository.resolve("a/b"));
+ final var field = AmazonS3StoragePersistenceAPIImpl.class.getDeclaredField("fileRepositoryManager");
+ field.setAccessible(true);
+ field.set(provider, new LocalFileRepositoryManager(repository.toString()) { });
+
+ final File first = provider.pullFile("binary-assets", "/a/b/file");
+ final File second = provider.pullFile("binary-assets", "/a/b/file");
+ assertNotEquals(first, second, "Concurrent downloads of one key must not share a file");
+ assertEquals("remote", Files.readString(first.toPath()));
+ assertEquals("remote", Files.readString(second.toPath()));
+ provider.releaseRetrievedFile(first);
+ provider.releaseRetrievedFile(second);
+ assertFalse(first.exists());
+ assertFalse(second.exists());
+ try (var files = Files.walk(repository)) {
+ assertEquals(0, files.filter(Files::isRegularFile).count(), "Downloads are not kept in the local repository");
+ }
+ }
+
+ @Test
+ void backfillTreatsAConflictingConcurrentWriteLikeAnExistingObject(@TempDir Path root) throws Exception {
+ // AWS answers 409 ConditionalRequestConflict, not 412, when two conditional writes race.
+ final var conflict = new AmazonS3Exception("ConditionalRequestConflict");
+ conflict.setStatusCode(409);
+ final var storage = mock(Storage.class);
+ doThrow(conflict).when(storage).uploadFileIfAbsent(eq("bucket"), anyString(), any(File.class));
+ final var provider = new AmazonS3StoragePersistenceAPIImpl(storage, "bucket",
+ AmazonS3StoragePersistenceAPIImpl.PathEncryptionMode.NONE);
+
+ final File file = Files.writeString(root.resolve("source"), "contents").toFile();
+ final var written = new S3ObjectSummary();
+ written.setKey("binary-assets/a/b/file");
+ written.setSize(file.length());
+ written.setETag(DigestUtils.md5Hex("contents"));
+ when(storage.listFirstObject("bucket", "binary-assets/a/b/file")).thenReturn(null, written);
+ assertTrue(provider.backfillFile("binary-assets", "/a/b/file", file));
+
+ final var missing = new AmazonS3Exception("missing");
+ missing.setStatusCode(404);
+ missing.setErrorCode("NoSuchKey");
+ when(storage.downloadFile(eq("bucket"), eq("dotmetadata/a/b/meta.json"), any(File.class)))
+ .thenThrow(missing)
+ .thenAnswer(call -> {
+ Files.writeString(call.getArgument(2).toPath(), "\"value\"");
+ return mock(Download.class);
+ });
+ assertTrue(provider.backfillObject("dotmetadata", "/a/b/meta.json", new JsonWriterDelegate(),
+ new JsonReaderDelegate<>(String.class), "value"));
+ }
+
private static String signerOverride(AWSS3Storage storage) throws Exception {
return ((com.amazonaws.AmazonWebServiceClient) client(storage)).getClientConfiguration().getSignerOverride();
}
diff --git a/dotCMS/src/test/java/com/dotcms/storage/SharedExtractedMetadataTest.java b/dotCMS/src/test/java/com/dotcms/storage/SharedExtractedMetadataTest.java
new file mode 100644
index 000000000000..345712eff2ed
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/storage/SharedExtractedMetadataTest.java
@@ -0,0 +1,54 @@
+package com.dotcms.storage;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import com.dotmarketing.util.Config;
+import java.io.File;
+import java.io.Serializable;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class SharedExtractedMetadataTest {
+ @TempDir Path root;
+ private String previousFlag;
+
+ @BeforeEach
+ void enable() {
+ previousFlag = Config.getStringProperty(AssetStorageFeature.FLAG, null);
+ Config.setProperty(AssetStorageFeature.FLAG, true);
+ }
+
+ @AfterEach
+ void restore() {
+ Config.setProperty(AssetStorageFeature.FLAG, previousFlag);
+ }
+
+ @Test
+ void unknownExtractorVersionExtractsWithoutTheSharedCache() throws Exception {
+ assertTrue(AssetStorageFeature.isEnabled());
+ final File source = Files.writeString(root.resolve("asset.txt"), "same bytes").toFile();
+ final StoragePersistenceAPI storage = mock(StoragePersistenceAPI.class);
+ final List extractedFrom = new ArrayList<>();
+ final Map extracted = Map.of("content", "same bytes");
+
+ final Map result = new SharedExtractedMetadata(storage)
+ .get(source, null, 1, 100, file -> {
+ extractedFrom.add(file);
+ return extracted;
+ });
+
+ assertEquals(extracted, result);
+ assertEquals(List.of(source), extractedFrom, "Extraction must run on the source, not a cached copy");
+ verifyNoInteractions(storage);
+ }
+}
From 7af5fa528363be5c7a47b68e3ee79176cbc3755a Mon Sep 17 00:00:00 2001
From: Scott Wicken <1562170+swicken@users.noreply.github.com>
Date: Tue, 13 Oct 2026 10:51:23 -0400
Subject: [PATCH 7/7] test(storage): exercise the plain S3 key path with the
metadata group
---
.../storage/S3StorageConfigurationTest.java | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/dotCMS/src/test/java/com/dotcms/storage/S3StorageConfigurationTest.java b/dotCMS/src/test/java/com/dotcms/storage/S3StorageConfigurationTest.java
index 254fe84ab38f..4451c753b7f1 100644
--- a/dotCMS/src/test/java/com/dotcms/storage/S3StorageConfigurationTest.java
+++ b/dotCMS/src/test/java/com/dotcms/storage/S3StorageConfigurationTest.java
@@ -177,19 +177,19 @@ void enabledUploadsDoNotHoldTheSharedIdentifierLock(@TempDir Path root) throws E
when(upload.waitForUploadResult()).thenAnswer(call -> executor.submit(() -> {
try {
return DotConcurrentFactory.getInstance().getIdentifierStripedLock()
- .tryLock("s3_binary-assets/a/b/file", () -> result);
+ .tryLock("s3_dotmetadata/a/b/file", () -> result);
} catch (Throwable lockTimeout) {
throw new Exception(lockTimeout);
}
}).get(10, TimeUnit.SECONDS));
- assertEquals("etag", provider.pushFile("binary-assets", "/a/b/file", file, Map.of()));
+ assertEquals("etag", provider.pushFile("dotmetadata", "/a/b/file", file, Map.of()));
}
}
@Test
void enabledDownloadsUseTheirOwnFileWhateverTheRepositoryAndAreDeletedOnRelease(@TempDir Path root) throws Exception {
final var storage = mock(Storage.class);
- when(storage.downloadFile(eq("bucket"), eq("binary-assets/a/b/file"), any(File.class))).thenAnswer(call -> {
+ when(storage.downloadFile(eq("bucket"), eq("dotmetadata/a/b/file"), any(File.class))).thenAnswer(call -> {
Files.writeString(call.getArgument(2).toPath(), "remote");
return mock(Download.class);
});
@@ -202,8 +202,8 @@ void enabledDownloadsUseTheirOwnFileWhateverTheRepositoryAndAreDeletedOnRelease(
field.setAccessible(true);
field.set(provider, new LocalFileRepositoryManager(repository.toString()) { });
- final File first = provider.pullFile("binary-assets", "/a/b/file");
- final File second = provider.pullFile("binary-assets", "/a/b/file");
+ final File first = provider.pullFile("dotmetadata", "/a/b/file");
+ final File second = provider.pullFile("dotmetadata", "/a/b/file");
assertNotEquals(first, second, "Concurrent downloads of one key must not share a file");
assertEquals("remote", Files.readString(first.toPath()));
assertEquals("remote", Files.readString(second.toPath()));
@@ -228,11 +228,11 @@ void backfillTreatsAConflictingConcurrentWriteLikeAnExistingObject(@TempDir Path
final File file = Files.writeString(root.resolve("source"), "contents").toFile();
final var written = new S3ObjectSummary();
- written.setKey("binary-assets/a/b/file");
+ written.setKey("dotmetadata/a/b/file");
written.setSize(file.length());
written.setETag(DigestUtils.md5Hex("contents"));
- when(storage.listFirstObject("bucket", "binary-assets/a/b/file")).thenReturn(null, written);
- assertTrue(provider.backfillFile("binary-assets", "/a/b/file", file));
+ when(storage.listFirstObject("bucket", "dotmetadata/a/b/file")).thenReturn(null, written);
+ assertTrue(provider.backfillFile("dotmetadata", "/a/b/file", file));
final var missing = new AmazonS3Exception("missing");
missing.setStatusCode(404);