From 203ecf548fb8f5caac954e2e9104f49817059179 Mon Sep 17 00:00:00 2001 From: Caio Oliveira Date: Thu, 2 Jan 2025 19:51:30 -0300 Subject: [PATCH 1/5] feat: add support for Amazon S3 as a lock storage backend using conditional writes --- README.md | 23 +++ pom.xml | 1 + providers/s3/shedlock-provider-s3/pom.xml | 77 ++++++++ .../javacrumbs/shedlock/provider/s3/Lock.java | 5 + .../shedlock/provider/s3/S3LockProvider.java | 31 ++++ .../provider/s3/S3StorageAccessor.java | 167 ++++++++++++++++++ .../s3/S3LockProviderIntegrationTest.java | 91 ++++++++++ .../src/test/resources/logback-test.xml | 32 ++++ shedlock-bom/pom.xml | 5 + 9 files changed, 432 insertions(+) create mode 100644 providers/s3/shedlock-provider-s3/pom.xml create mode 100644 providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/Lock.java create mode 100644 providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3LockProvider.java create mode 100644 providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3StorageAccessor.java create mode 100644 providers/s3/shedlock-provider-s3/src/test/java/net/javacrumbs/shedlock/provider/s3/S3LockProviderIntegrationTest.java create mode 100644 providers/s3/shedlock-provider-s3/src/test/resources/logback-test.xml diff --git a/README.md b/README.md index 5cdc95d53..ff51d5e33 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ executed repeatedly. Moreover, the locks are time-based and ShedLock assumes tha - [In-Memory](#in-memory) - [Memcached](#memcached-using-spymemcached) - [Datastore](#datastore) + - [S3](#s3) + [Multi-tenancy](#multi-tenancy) + [Customization](#customization) + [Duration specification](#duration-specification) @@ -885,6 +886,28 @@ public LockProvider lockProvider(DatabaseClient databaseClient) { } ``` +#### S3 + +Import the project +```xml + + net.javacrumbs.shedlock + shedlock-provider-s3 + 6.0.2 + +``` + +and configure +```java +import net.javacrumbs.shedlock.provider.s3.S3LockProvider; + +... + +@Bean +public LockProvider lockProvider(com.amazonaws.services.s3.AmazonS3 amazonS3) { + return new S3LockProvider(amazonS3); +} +``` ## Multi-tenancy If you have multi-tenancy use-case you can use a lock provider similar to this one diff --git a/pom.xml b/pom.xml index cce2be1c0..7929e8b0d 100644 --- a/pom.xml +++ b/pom.xml @@ -63,6 +63,7 @@ providers/datastore/shedlock-provider-datastore providers/spanner/shedlock-provider-spanner providers/neo4j/shedlock-provider-neo4j + providers/s3/shedlock-provider-s3 diff --git a/providers/s3/shedlock-provider-s3/pom.xml b/providers/s3/shedlock-provider-s3/pom.xml new file mode 100644 index 000000000..a5cbb4bb2 --- /dev/null +++ b/providers/s3/shedlock-provider-s3/pom.xml @@ -0,0 +1,77 @@ + + + + shedlock-parent + net.javacrumbs.shedlock + 6.0.3-SNAPSHOT + + 4.0.0 + + shedlock-provider-s3 + 6.0.3-SNAPSHOT + + + 1.12.747 + + + + + net.javacrumbs.shedlock + shedlock-core + ${project.version} + + + + com.amazonaws + aws-java-sdk-s3 + ${aws-java-sdk-s3.version} + + + + org.testcontainers + junit-jupiter + ${test-containers.ver} + test + + + + org.testcontainers + localstack + ${test-containers.ver} + test + + + + net.javacrumbs.shedlock + shedlock-test-support + ${project.version} + test + + + + ch.qos.logback + logback-classic + ${logback.ver} + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + + net.javacrumbs.shedlock.provider.s3 + + + + + + + + + diff --git a/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/Lock.java b/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/Lock.java new file mode 100644 index 000000000..d62371f6e --- /dev/null +++ b/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/Lock.java @@ -0,0 +1,5 @@ +package net.javacrumbs.shedlock.provider.s3; + +import java.time.Instant; + +record Lock(Instant lockUntil, Instant lockedAt, String lockedBy, String eTag) {} diff --git a/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3LockProvider.java b/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3LockProvider.java new file mode 100644 index 000000000..c9f465eb3 --- /dev/null +++ b/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3LockProvider.java @@ -0,0 +1,31 @@ +package net.javacrumbs.shedlock.provider.s3; +import com.amazonaws.services.s3.AmazonS3; +import net.javacrumbs.shedlock.support.StorageBasedLockProvider; + +/** + * Lock provider implementation for S3. + */ +public class S3LockProvider extends StorageBasedLockProvider { + + /** + * Constructs an S3LockProvider. + * + * @param s3Client Amazon S3 client used to interact with the S3 bucket. + * @param bucketName The name of the S3 bucket where locks are stored. + * @param objectPrefix The prefix of the S3 object lock. + */ + public S3LockProvider(AmazonS3 s3Client, String bucketName, String objectPrefix) { + super(new S3StorageAccessor(s3Client, bucketName, objectPrefix)); + } + + /** + * Constructs an S3LockProvider. + * + * @param s3Client Amazon S3 client used to interact with the S3 bucket. + * @param bucketName The name of the S3 bucket where locks are stored. + */ + public S3LockProvider(AmazonS3 s3Client, String bucketName) { + this(s3Client, bucketName, "shedlock/"); + } +} + diff --git a/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3StorageAccessor.java b/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3StorageAccessor.java new file mode 100644 index 000000000..72334047a --- /dev/null +++ b/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3StorageAccessor.java @@ -0,0 +1,167 @@ +package net.javacrumbs.shedlock.provider.s3; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.AmazonServiceException; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.amazonaws.services.s3.model.PutObjectResult; +import net.javacrumbs.shedlock.core.ClockProvider; +import net.javacrumbs.shedlock.core.LockConfiguration; +import net.javacrumbs.shedlock.support.AbstractStorageAccessor; + +import java.io.ByteArrayInputStream; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +/** + * Implementation of StorageAccessor for S3 as a lock storage backend. + * Manages locks using S3 objects with metadata for expiration and conditional writes. + */ +public class S3StorageAccessor extends AbstractStorageAccessor { + + private static final String LOCK_UNTIL = "lockUntil"; + private static final String LOCKED_AT = "lockedAt"; + private static final String LOCKED_BY = "lockedBy"; + + private final AmazonS3 s3Client; + private final String bucketName; + private final String objectPrefix; + + public S3StorageAccessor(AmazonS3 s3Client, String bucketName, String objectPrefix) { + this.s3Client = s3Client; + this.bucketName = bucketName; + this.objectPrefix = objectPrefix; + } + + /** + * Finds the lock in the S3 bucket. + */ + Optional find(String name, String action) { + try { + ObjectMetadata metadata = s3Client.getObjectMetadata(bucketName, objectName(name)); + Instant lockUntil = Instant.parse(metadata.getUserMetaDataOf(LOCK_UNTIL)); + Instant lockedAt = Instant.parse(metadata.getUserMetaDataOf(LOCKED_AT)); + String lockedBy = metadata.getUserMetaDataOf(LOCKED_BY); + String eTag = metadata.getETag(); + + logger.debug("Lock found. action: {}, name: {}, lockUntil: {}, e-tag: {}", action, name, lockUntil, eTag); + return Optional.of(new Lock(lockUntil, lockedAt, lockedBy, eTag)); + } catch (AmazonServiceException e) { + if (e.getStatusCode() == 404) { + logger.debug("Lock not found. action: {}, name: {}", action, name); + return Optional.empty(); + } + throw e; + } + } + + @Override + public boolean insertRecord(LockConfiguration lockConfiguration) { + String name = lockConfiguration.getName(); + if (find(name, "insertRecord").isPresent()) { + logger.debug("Lock already exists. name: {}", name); + return false; + } + + try { + var lockContent = UUID.randomUUID().toString().getBytes(); + ObjectMetadata metadata = createMetadata(lockConfiguration.getLockAtMostUntil(), ClockProvider.now(), getHostname()); + metadata.setContentLength(lockContent.length); + + PutObjectRequest request = new PutObjectRequest( + bucketName, objectName(name), new ByteArrayInputStream(lockContent), metadata + ); + request.putCustomRequestHeader("If-None-Match", "*"); + + s3Client.putObject(request); + logger.debug("Lock created successfully. name: {}, metadata: {}", name, metadata.getUserMetadata()); + return true; + } catch (AmazonServiceException e) { + if (e.getStatusCode() == 412) { + logger.debug("Lock already in use. name: {}", name); + } else { + logger.warn("Failed to create lock. name: {}", name, e); + } + return false; + } + } + + @Override + public boolean updateRecord(LockConfiguration lockConfiguration) { + Optional lock = find(lockConfiguration.getName(), "updateRecord"); + if (lock.isEmpty() || lock.get().lockUntil().isAfter(ClockProvider.now())) { + logger.debug("Update skipped. Lock still valid or not found. name: {}, lock: {}", lockConfiguration.getName(), lock); + return false; + } + + ObjectMetadata newMetadata = createMetadata(lockConfiguration.getLockAtMostUntil(), ClockProvider.now(), getHostname()); + return replaceObjectMetadata(lockConfiguration.getName(), newMetadata, lock.get().eTag(), "updateRecord"); + } + + @Override + public void unlock(LockConfiguration lockConfiguration) { + Optional lock = find(lockConfiguration.getName(), "unlock"); + if (lock.isEmpty()) { + logger.debug("Unlock skipped. Lock not found. name: {}, lock: {}", lockConfiguration.getName(), lock); + return; + } + + updateUntil(lockConfiguration.getName(), lock.get(), lockConfiguration.getUnlockTime(), "unlock"); + } + + @Override + public boolean extend(LockConfiguration lockConfiguration) { + Optional lock = find(lockConfiguration.getName(), "extend"); + if (lock.isEmpty() + || lock.get().lockUntil().isBefore(ClockProvider.now()) + || !lock.get().lockedBy().equals(getHostname())) { + logger.debug("Extend skipped. Lock invalid or not owned by host. name: {}, lock: {}", lockConfiguration.getName(), lock); + return false; + } + + return updateUntil(lockConfiguration.getName(), lock.get(), lockConfiguration.getLockAtMostUntil(), "extend"); + } + + private boolean updateUntil(String name, Lock lock, Instant until, String action) { + ObjectMetadata existingMetadata = s3Client.getObjectMetadata(bucketName, objectName(name)); + ObjectMetadata newMetadata = createMetadata(until, Instant.parse(existingMetadata.getUserMetaDataOf(LOCKED_AT)), getHostname()); + + return replaceObjectMetadata(name, newMetadata, lock.eTag(), action); + } + + private boolean replaceObjectMetadata(String name, ObjectMetadata newMetadata, String eTag, String action) { + var lockContent = UUID.randomUUID().toString().getBytes(); + newMetadata.setContentLength(lockContent.length); + + PutObjectRequest request = new PutObjectRequest( + bucketName, objectName(name), new ByteArrayInputStream(lockContent), newMetadata + ); + request.putCustomRequestHeader("If-Match", eTag); + + try { + PutObjectResult response = s3Client.putObject(request); + logger.debug("Lock {} successfully. name: {}, old e-tag: {}, new e-tag: {}", action, name, eTag, response.getETag()); + return true; + } catch (AmazonServiceException e) { + if (e.getStatusCode() == 412) { + logger.debug("Lock not exists to {}. name: {}, e-tag {}", action, name, eTag); + } else { + logger.warn("Failed to create lock. name: {}", name, e); + } + return false; + } + } + + private ObjectMetadata createMetadata(Instant lockUntil, Instant lockedAt, String lockedBy) { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.addUserMetadata(LOCK_UNTIL, lockUntil.toString()); + metadata.addUserMetadata(LOCKED_AT, lockedAt.toString()); + metadata.addUserMetadata(LOCKED_BY, lockedBy); + return metadata; + } + + private String objectName(String name) { + return objectPrefix + name; + } +} diff --git a/providers/s3/shedlock-provider-s3/src/test/java/net/javacrumbs/shedlock/provider/s3/S3LockProviderIntegrationTest.java b/providers/s3/shedlock-provider-s3/src/test/java/net/javacrumbs/shedlock/provider/s3/S3LockProviderIntegrationTest.java new file mode 100644 index 000000000..1837ff7be --- /dev/null +++ b/providers/s3/shedlock-provider-s3/src/test/java/net/javacrumbs/shedlock/provider/s3/S3LockProviderIntegrationTest.java @@ -0,0 +1,91 @@ +package net.javacrumbs.shedlock.provider.s3; + +import static net.javacrumbs.shedlock.core.ClockProvider.now; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import net.javacrumbs.shedlock.support.StorageBasedLockProvider; +import net.javacrumbs.shedlock.test.support.AbstractStorageBasedLockProviderIntegrationTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +/** + * Integration test uses local instance of LocalStack S3 running on localhost at + * port 9042 using bucket shedlock and folder shedlock + * + * @see net.javacrumbs.shedlock.provider.s3.S3LockProvider + */ +@Testcontainers +public class S3LockProviderIntegrationTest extends AbstractStorageBasedLockProviderIntegrationTest { + + @Container + public static final MyLocalStackS3Container localStackS3 = new MyLocalStackS3Container(); + private static AmazonS3 s3Client; + private static final String BUCKET_NAME = "my-bucket"; + private static final String OBJECT_PREFIX = "prefix"; + + @BeforeAll + public static void startLocalStackS3() { + s3Client = AmazonS3ClientBuilder.standard().withEndpointConfiguration( + new AwsClientBuilder.EndpointConfiguration(localStackS3.getEndpoint().toString(), localStackS3.getRegion()) + ).withCredentials( + new AWSStaticCredentialsProvider( + new BasicAWSCredentials(localStackS3.getAccessKey(), localStackS3.getSecretKey()) + ) + ).build(); + } + + @BeforeEach + public void before() { + s3Client.createBucket(BUCKET_NAME); + } + + @AfterEach + public void after() { + s3Client.listObjects(BUCKET_NAME).getObjectSummaries().forEach(obj ->{ + s3Client.deleteObject(BUCKET_NAME, obj.getKey()); + }); + } + + @Override + protected StorageBasedLockProvider getLockProvider() { + return new S3LockProvider(s3Client, BUCKET_NAME, OBJECT_PREFIX); + } + + @Override + protected void assertUnlocked(String lockName) { + Lock lock = findLock(lockName); + assertThat(lock.lockUntil()).isBefore(now()); + assertThat(lock.lockedAt()).isBefore(now()); + assertThat(lock.lockedBy()).isNotEmpty(); + } + + @Override + protected void assertLocked(String lockName) { + Lock lock = findLock(lockName); + assertThat(lock.lockUntil()).isAfter(now()); + assertThat(lock.lockedAt()).isBefore(now()); + assertThat(lock.lockedBy()).isNotEmpty(); + } + + private Lock findLock(String lockName) { + return new S3StorageAccessor(s3Client, BUCKET_NAME, OBJECT_PREFIX).find(lockName, "test").get(); + } + + private static class MyLocalStackS3Container extends LocalStackContainer { + public MyLocalStackS3Container() { + super(DockerImageName.parse("localstack/localstack:4.0.3")); + } + } +} + diff --git a/providers/s3/shedlock-provider-s3/src/test/resources/logback-test.xml b/providers/s3/shedlock-provider-s3/src/test/resources/logback-test.xml new file mode 100644 index 000000000..c44bac998 --- /dev/null +++ b/providers/s3/shedlock-provider-s3/src/test/resources/logback-test.xml @@ -0,0 +1,32 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/shedlock-bom/pom.xml b/shedlock-bom/pom.xml index c03e792f1..1c629029c 100644 --- a/shedlock-bom/pom.xml +++ b/shedlock-bom/pom.xml @@ -144,6 +144,11 @@ shedlock-provider-spanner ${project.version} + + net.javacrumbs.shedlock + shedlock-provider-s3 + ${project.version} + From 5eb4f659ce19b1516eb78efdd4a610d6d68c16d7 Mon Sep 17 00:00:00 2001 From: Caio Oliveira Date: Thu, 2 Jan 2025 19:57:39 -0300 Subject: [PATCH 2/5] Fix s3 relativePath --- providers/s3/shedlock-provider-s3/pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/providers/s3/shedlock-provider-s3/pom.xml b/providers/s3/shedlock-provider-s3/pom.xml index a5cbb4bb2..58fc4b7e8 100644 --- a/providers/s3/shedlock-provider-s3/pom.xml +++ b/providers/s3/shedlock-provider-s3/pom.xml @@ -4,6 +4,7 @@ shedlock-parent net.javacrumbs.shedlock 6.0.3-SNAPSHOT + ../../../pom.xml 4.0.0 From 8fbbc28f2898c81795db0ac9413d35e84b0810ff Mon Sep 17 00:00:00 2001 From: Caio Oliveira Date: Thu, 2 Jan 2025 20:38:08 -0300 Subject: [PATCH 3/5] Fix code format --- README.md | 2 +- providers/s3/shedlock-provider-s3/pom.xml | 1 - .../shedlock/provider/s3/S3LockProvider.java | 2 +- .../provider/s3/S3StorageAccessor.java | 48 ++++++++++++------- .../s3/S3LockProviderIntegrationTest.java | 22 ++++----- 5 files changed, 43 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index ff51d5e33..36c39e03c 100644 --- a/README.md +++ b/README.md @@ -905,7 +905,7 @@ import net.javacrumbs.shedlock.provider.s3.S3LockProvider; @Bean public LockProvider lockProvider(com.amazonaws.services.s3.AmazonS3 amazonS3) { - return new S3LockProvider(amazonS3); + return new S3LockProvider(amazonS3, "BUKET_NAME"); } ``` diff --git a/providers/s3/shedlock-provider-s3/pom.xml b/providers/s3/shedlock-provider-s3/pom.xml index 58fc4b7e8..f8364f2ec 100644 --- a/providers/s3/shedlock-provider-s3/pom.xml +++ b/providers/s3/shedlock-provider-s3/pom.xml @@ -74,5 +74,4 @@ - diff --git a/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3LockProvider.java b/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3LockProvider.java index c9f465eb3..050fc8c5c 100644 --- a/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3LockProvider.java +++ b/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3LockProvider.java @@ -1,4 +1,5 @@ package net.javacrumbs.shedlock.provider.s3; + import com.amazonaws.services.s3.AmazonS3; import net.javacrumbs.shedlock.support.StorageBasedLockProvider; @@ -28,4 +29,3 @@ public S3LockProvider(AmazonS3 s3Client, String bucketName) { this(s3Client, bucketName, "shedlock/"); } } - diff --git a/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3StorageAccessor.java b/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3StorageAccessor.java index 72334047a..4ef31db3a 100644 --- a/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3StorageAccessor.java +++ b/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3StorageAccessor.java @@ -1,18 +1,17 @@ package net.javacrumbs.shedlock.provider.s3; +import com.amazonaws.AmazonServiceException; import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.model.ObjectMetadata; -import com.amazonaws.AmazonServiceException; import com.amazonaws.services.s3.model.PutObjectRequest; import com.amazonaws.services.s3.model.PutObjectResult; -import net.javacrumbs.shedlock.core.ClockProvider; -import net.javacrumbs.shedlock.core.LockConfiguration; -import net.javacrumbs.shedlock.support.AbstractStorageAccessor; - import java.io.ByteArrayInputStream; import java.time.Instant; import java.util.Optional; import java.util.UUID; +import net.javacrumbs.shedlock.core.ClockProvider; +import net.javacrumbs.shedlock.core.LockConfiguration; +import net.javacrumbs.shedlock.support.AbstractStorageAccessor; /** * Implementation of StorageAccessor for S3 as a lock storage backend. @@ -66,12 +65,12 @@ public boolean insertRecord(LockConfiguration lockConfiguration) { try { var lockContent = UUID.randomUUID().toString().getBytes(); - ObjectMetadata metadata = createMetadata(lockConfiguration.getLockAtMostUntil(), ClockProvider.now(), getHostname()); + ObjectMetadata metadata = + createMetadata(lockConfiguration.getLockAtMostUntil(), ClockProvider.now(), getHostname()); metadata.setContentLength(lockContent.length); - PutObjectRequest request = new PutObjectRequest( - bucketName, objectName(name), new ByteArrayInputStream(lockContent), metadata - ); + PutObjectRequest request = + new PutObjectRequest(bucketName, objectName(name), new ByteArrayInputStream(lockContent), metadata); request.putCustomRequestHeader("If-None-Match", "*"); s3Client.putObject(request); @@ -91,12 +90,17 @@ bucketName, objectName(name), new ByteArrayInputStream(lockContent), metadata public boolean updateRecord(LockConfiguration lockConfiguration) { Optional lock = find(lockConfiguration.getName(), "updateRecord"); if (lock.isEmpty() || lock.get().lockUntil().isAfter(ClockProvider.now())) { - logger.debug("Update skipped. Lock still valid or not found. name: {}, lock: {}", lockConfiguration.getName(), lock); + logger.debug( + "Update skipped. Lock still valid or not found. name: {}, lock: {}", + lockConfiguration.getName(), + lock); return false; } - ObjectMetadata newMetadata = createMetadata(lockConfiguration.getLockAtMostUntil(), ClockProvider.now(), getHostname()); - return replaceObjectMetadata(lockConfiguration.getName(), newMetadata, lock.get().eTag(), "updateRecord"); + ObjectMetadata newMetadata = + createMetadata(lockConfiguration.getLockAtMostUntil(), ClockProvider.now(), getHostname()); + return replaceObjectMetadata( + lockConfiguration.getName(), newMetadata, lock.get().eTag(), "updateRecord"); } @Override @@ -116,7 +120,10 @@ public boolean extend(LockConfiguration lockConfiguration) { if (lock.isEmpty() || lock.get().lockUntil().isBefore(ClockProvider.now()) || !lock.get().lockedBy().equals(getHostname())) { - logger.debug("Extend skipped. Lock invalid or not owned by host. name: {}, lock: {}", lockConfiguration.getName(), lock); + logger.debug( + "Extend skipped. Lock invalid or not owned by host. name: {}, lock: {}", + lockConfiguration.getName(), + lock); return false; } @@ -125,7 +132,8 @@ public boolean extend(LockConfiguration lockConfiguration) { private boolean updateUntil(String name, Lock lock, Instant until, String action) { ObjectMetadata existingMetadata = s3Client.getObjectMetadata(bucketName, objectName(name)); - ObjectMetadata newMetadata = createMetadata(until, Instant.parse(existingMetadata.getUserMetaDataOf(LOCKED_AT)), getHostname()); + ObjectMetadata newMetadata = + createMetadata(until, Instant.parse(existingMetadata.getUserMetaDataOf(LOCKED_AT)), getHostname()); return replaceObjectMetadata(name, newMetadata, lock.eTag(), action); } @@ -134,14 +142,18 @@ private boolean replaceObjectMetadata(String name, ObjectMetadata newMetadata, S var lockContent = UUID.randomUUID().toString().getBytes(); newMetadata.setContentLength(lockContent.length); - PutObjectRequest request = new PutObjectRequest( - bucketName, objectName(name), new ByteArrayInputStream(lockContent), newMetadata - ); + PutObjectRequest request = + new PutObjectRequest(bucketName, objectName(name), new ByteArrayInputStream(lockContent), newMetadata); request.putCustomRequestHeader("If-Match", eTag); try { PutObjectResult response = s3Client.putObject(request); - logger.debug("Lock {} successfully. name: {}, old e-tag: {}, new e-tag: {}", action, name, eTag, response.getETag()); + logger.debug( + "Lock {} successfully. name: {}, old e-tag: {}, new e-tag: {}", + action, + name, + eTag, + response.getETag()); return true; } catch (AmazonServiceException e) { if (e.getStatusCode() == 412) { diff --git a/providers/s3/shedlock-provider-s3/src/test/java/net/javacrumbs/shedlock/provider/s3/S3LockProviderIntegrationTest.java b/providers/s3/shedlock-provider-s3/src/test/java/net/javacrumbs/shedlock/provider/s3/S3LockProviderIntegrationTest.java index 1837ff7be..f310f656a 100644 --- a/providers/s3/shedlock-provider-s3/src/test/java/net/javacrumbs/shedlock/provider/s3/S3LockProviderIntegrationTest.java +++ b/providers/s3/shedlock-provider-s3/src/test/java/net/javacrumbs/shedlock/provider/s3/S3LockProviderIntegrationTest.java @@ -1,7 +1,6 @@ package net.javacrumbs.shedlock.provider.s3; import static net.javacrumbs.shedlock.core.ClockProvider.now; - import static org.assertj.core.api.Assertions.assertThat; import com.amazonaws.auth.AWSStaticCredentialsProvider; @@ -30,19 +29,19 @@ public class S3LockProviderIntegrationTest extends AbstractStorageBasedLockProvi @Container public static final MyLocalStackS3Container localStackS3 = new MyLocalStackS3Container(); + private static AmazonS3 s3Client; private static final String BUCKET_NAME = "my-bucket"; private static final String OBJECT_PREFIX = "prefix"; @BeforeAll public static void startLocalStackS3() { - s3Client = AmazonS3ClientBuilder.standard().withEndpointConfiguration( - new AwsClientBuilder.EndpointConfiguration(localStackS3.getEndpoint().toString(), localStackS3.getRegion()) - ).withCredentials( - new AWSStaticCredentialsProvider( - new BasicAWSCredentials(localStackS3.getAccessKey(), localStackS3.getSecretKey()) - ) - ).build(); + s3Client = AmazonS3ClientBuilder.standard() + .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration( + localStackS3.getEndpoint().toString(), localStackS3.getRegion())) + .withCredentials(new AWSStaticCredentialsProvider( + new BasicAWSCredentials(localStackS3.getAccessKey(), localStackS3.getSecretKey()))) + .build(); } @BeforeEach @@ -52,7 +51,7 @@ public void before() { @AfterEach public void after() { - s3Client.listObjects(BUCKET_NAME).getObjectSummaries().forEach(obj ->{ + s3Client.listObjects(BUCKET_NAME).getObjectSummaries().forEach(obj -> { s3Client.deleteObject(BUCKET_NAME, obj.getKey()); }); } @@ -79,7 +78,9 @@ protected void assertLocked(String lockName) { } private Lock findLock(String lockName) { - return new S3StorageAccessor(s3Client, BUCKET_NAME, OBJECT_PREFIX).find(lockName, "test").get(); + return new S3StorageAccessor(s3Client, BUCKET_NAME, OBJECT_PREFIX) + .find(lockName, "test") + .get(); } private static class MyLocalStackS3Container extends LocalStackContainer { @@ -88,4 +89,3 @@ public MyLocalStackS3Container() { } } } - From e0d8abf59f741dae81d9c2c5197418ead1753ef5 Mon Sep 17 00:00:00 2001 From: Lukas Krecan Date: Fri, 3 Jan 2025 20:17:12 +0100 Subject: [PATCH 4/5] S3 cleanup --- .../provider/s3/S3StorageAccessor.java | 46 +++++++++++-------- .../s3/S3LockProviderIntegrationTest.java | 2 +- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3StorageAccessor.java b/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3StorageAccessor.java index 4ef31db3a..2918e53e0 100644 --- a/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3StorageAccessor.java +++ b/providers/s3/shedlock-provider-s3/src/main/java/net/javacrumbs/shedlock/provider/s3/S3StorageAccessor.java @@ -1,15 +1,17 @@ package net.javacrumbs.shedlock.provider.s3; +import static net.javacrumbs.shedlock.core.ClockProvider.now; + import com.amazonaws.AmazonServiceException; import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.model.ObjectMetadata; import com.amazonaws.services.s3.model.PutObjectRequest; import com.amazonaws.services.s3.model.PutObjectResult; import java.io.ByteArrayInputStream; +import java.nio.ByteBuffer; import java.time.Instant; import java.util.Optional; import java.util.UUID; -import net.javacrumbs.shedlock.core.ClockProvider; import net.javacrumbs.shedlock.core.LockConfiguration; import net.javacrumbs.shedlock.support.AbstractStorageAccessor; @@ -17,11 +19,12 @@ * Implementation of StorageAccessor for S3 as a lock storage backend. * Manages locks using S3 objects with metadata for expiration and conditional writes. */ -public class S3StorageAccessor extends AbstractStorageAccessor { +class S3StorageAccessor extends AbstractStorageAccessor { private static final String LOCK_UNTIL = "lockUntil"; private static final String LOCKED_AT = "lockedAt"; private static final String LOCKED_BY = "lockedBy"; + private static final int PRECONDITION_FAILED = 412; private final AmazonS3 s3Client; private final String bucketName; @@ -64,9 +67,8 @@ public boolean insertRecord(LockConfiguration lockConfiguration) { } try { - var lockContent = UUID.randomUUID().toString().getBytes(); - ObjectMetadata metadata = - createMetadata(lockConfiguration.getLockAtMostUntil(), ClockProvider.now(), getHostname()); + var lockContent = getLockContent(); + ObjectMetadata metadata = createMetadata(lockConfiguration.getLockAtMostUntil(), now(), getHostname()); metadata.setContentLength(lockContent.length); PutObjectRequest request = @@ -77,7 +79,7 @@ public boolean insertRecord(LockConfiguration lockConfiguration) { logger.debug("Lock created successfully. name: {}, metadata: {}", name, metadata.getUserMetadata()); return true; } catch (AmazonServiceException e) { - if (e.getStatusCode() == 412) { + if (e.getStatusCode() == PRECONDITION_FAILED) { logger.debug("Lock already in use. name: {}", name); } else { logger.warn("Failed to create lock. name: {}", name, e); @@ -89,16 +91,16 @@ public boolean insertRecord(LockConfiguration lockConfiguration) { @Override public boolean updateRecord(LockConfiguration lockConfiguration) { Optional lock = find(lockConfiguration.getName(), "updateRecord"); - if (lock.isEmpty() || lock.get().lockUntil().isAfter(ClockProvider.now())) { - logger.debug( - "Update skipped. Lock still valid or not found. name: {}, lock: {}", - lockConfiguration.getName(), - lock); + if (lock.isEmpty()) { + logger.warn("Update skipped. Lock not found. name: {}, lock: {}", lockConfiguration.getName(), lock); + return false; + } + if (lock.get().lockUntil().isAfter(now())) { + logger.debug("Update skipped. Lock still valid. name: {}, lock: {}", lockConfiguration.getName(), lock); return false; } - ObjectMetadata newMetadata = - createMetadata(lockConfiguration.getLockAtMostUntil(), ClockProvider.now(), getHostname()); + ObjectMetadata newMetadata = createMetadata(lockConfiguration.getLockAtMostUntil(), now(), getHostname()); return replaceObjectMetadata( lockConfiguration.getName(), newMetadata, lock.get().eTag(), "updateRecord"); } @@ -107,7 +109,7 @@ public boolean updateRecord(LockConfiguration lockConfiguration) { public void unlock(LockConfiguration lockConfiguration) { Optional lock = find(lockConfiguration.getName(), "unlock"); if (lock.isEmpty()) { - logger.debug("Unlock skipped. Lock not found. name: {}, lock: {}", lockConfiguration.getName(), lock); + logger.warn("Unlock skipped. Lock not found. name: {}, lock: {}", lockConfiguration.getName(), lock); return; } @@ -118,7 +120,7 @@ public void unlock(LockConfiguration lockConfiguration) { public boolean extend(LockConfiguration lockConfiguration) { Optional lock = find(lockConfiguration.getName(), "extend"); if (lock.isEmpty() - || lock.get().lockUntil().isBefore(ClockProvider.now()) + || lock.get().lockUntil().isBefore(now()) || !lock.get().lockedBy().equals(getHostname())) { logger.debug( "Extend skipped. Lock invalid or not owned by host. name: {}, lock: {}", @@ -139,7 +141,7 @@ private boolean updateUntil(String name, Lock lock, Instant until, String action } private boolean replaceObjectMetadata(String name, ObjectMetadata newMetadata, String eTag, String action) { - var lockContent = UUID.randomUUID().toString().getBytes(); + var lockContent = getLockContent(); newMetadata.setContentLength(lockContent.length); PutObjectRequest request = @@ -156,15 +158,23 @@ private boolean replaceObjectMetadata(String name, ObjectMetadata newMetadata, S response.getETag()); return true; } catch (AmazonServiceException e) { - if (e.getStatusCode() == 412) { + if (e.getStatusCode() == PRECONDITION_FAILED) { logger.debug("Lock not exists to {}. name: {}, e-tag {}", action, name, eTag); } else { - logger.warn("Failed to create lock. name: {}", name, e); + logger.warn("Failed to {} lock. name: {}", action, name, e); } return false; } } + private static byte[] getLockContent() { + var uuid = UUID.randomUUID(); + ByteBuffer bb = ByteBuffer.wrap(new byte[16]); + bb.putLong(uuid.getMostSignificantBits()); + bb.putLong(uuid.getLeastSignificantBits()); + return bb.array(); + } + private ObjectMetadata createMetadata(Instant lockUntil, Instant lockedAt, String lockedBy) { ObjectMetadata metadata = new ObjectMetadata(); metadata.addUserMetadata(LOCK_UNTIL, lockUntil.toString()); diff --git a/providers/s3/shedlock-provider-s3/src/test/java/net/javacrumbs/shedlock/provider/s3/S3LockProviderIntegrationTest.java b/providers/s3/shedlock-provider-s3/src/test/java/net/javacrumbs/shedlock/provider/s3/S3LockProviderIntegrationTest.java index f310f656a..a658eaa71 100644 --- a/providers/s3/shedlock-provider-s3/src/test/java/net/javacrumbs/shedlock/provider/s3/S3LockProviderIntegrationTest.java +++ b/providers/s3/shedlock-provider-s3/src/test/java/net/javacrumbs/shedlock/provider/s3/S3LockProviderIntegrationTest.java @@ -28,7 +28,7 @@ public class S3LockProviderIntegrationTest extends AbstractStorageBasedLockProviderIntegrationTest { @Container - public static final MyLocalStackS3Container localStackS3 = new MyLocalStackS3Container(); + static final MyLocalStackS3Container localStackS3 = new MyLocalStackS3Container(); private static AmazonS3 s3Client; private static final String BUCKET_NAME = "my-bucket"; From 5d9583d79f26457c453cadc83791fb162955b9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20K=C5=99e=C4=8Dan?= Date: Sat, 4 Jan 2025 12:55:00 +0100 Subject: [PATCH 5/5] Update README.md Co-authored-by: Caio Oliveira --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 36c39e03c..aa0981643 100644 --- a/README.md +++ b/README.md @@ -905,7 +905,7 @@ import net.javacrumbs.shedlock.provider.s3.S3LockProvider; @Bean public LockProvider lockProvider(com.amazonaws.services.s3.AmazonS3 amazonS3) { - return new S3LockProvider(amazonS3, "BUKET_NAME"); + return new S3LockProvider(amazonS3, "BUCKET_NAME"); } ```