diff --git a/.github/workflows/docker-build-dev.yml b/.github/workflows/docker-build-dev.yml index 6c9a8f5..372489e 100644 --- a/.github/workflows/docker-build-dev.yml +++ b/.github/workflows/docker-build-dev.yml @@ -23,6 +23,8 @@ jobs: tag: firestore - profile: etcd tag: etcd + - profile: dynamodb + tag: dynamodb steps: - name: Checkout code diff --git a/README.md b/README.md index 71f51f0..65e7c8d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ The API documentation is available via Swagger UI at the root of the running ser | Memory | `locker-memory` | In-memory lock storage for single-instance deployments and testing | | Firestore | `locker-firestore` | Google Cloud Firestore for distributed deployments on GCP | | etcd | `locker-etcd` | etcd for distributed deployments using Kubernetes or other etcd-based infrastructure | +| DynamoDB | `locker-dynamodb` | AWS DynamoDB for distributed deployments on AWS | ## Building @@ -44,6 +45,9 @@ mvn clean package -P firestore # etcd backend mvn clean package -P etcd +# DynamoDB backend +mvn clean package -P dynamodb + # Build all backends for testing mvn clean package -P everything ``` @@ -61,4 +65,7 @@ docker build --build-arg LOCKER=firestore -t lockservicecentral-firestore . # etcd backend docker build --build-arg LOCKER=etcd -t lockservicecentral-etcd . + +# DynamoDB backend +docker build --build-arg LOCKER=dynamodb -t lockservicecentral-dynamodb . ``` diff --git a/api/pom.xml b/api/pom.xml index 3fb891d..015c6d1 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -121,6 +121,17 @@ + + dynamodb + + + com.unitvectory.lockservicecentral + locker-dynamodb + ${project.version} + runtime + + + diff --git a/api/src/test/java/com/unitvectory/lockservicecentral/api/APILockServiceTest.java b/api/src/test/java/com/unitvectory/lockservicecentral/api/APILockServiceTest.java index 3f999b6..595567d 100644 --- a/api/src/test/java/com/unitvectory/lockservicecentral/api/APILockServiceTest.java +++ b/api/src/test/java/com/unitvectory/lockservicecentral/api/APILockServiceTest.java @@ -19,6 +19,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.json.JsonCompareMode; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; @@ -37,7 +38,7 @@ import java.io.File; /** - * The paramaterized tests for the Spring Boot API + * The parameterized tests for the Spring Boot API * * @author Jared Hatfield (UnitVectorY Labs) */ @@ -85,7 +86,7 @@ public void exampleTest(String fileName) throws Exception { // Run the GET request mockMvc.perform(MockMvcRequestBuilders.get(path)) .andExpect(MockMvcResultMatchers.status().is(status)) - .andExpect(MockMvcResultMatchers.content().json(response, true)); + .andExpect(MockMvcResultMatchers.content().json(response, JsonCompareMode.STRICT)); } else if (verb.equals("POST")) { assertTrue(node.has("request"), "Missing request"); String request = node.get("request").toString(); @@ -101,7 +102,7 @@ public void exampleTest(String fileName) throws Exception { .contentType("application/json") .content(request)) .andExpect(MockMvcResultMatchers.status().is(status)) - .andExpect(MockMvcResultMatchers.content().json(response, true)); + .andExpect(MockMvcResultMatchers.content().json(response, JsonCompareMode.STRICT)); } else { fail("Unknown verb: " + verb); } diff --git a/api/src/test/java/com/unitvectory/lockservicecentral/api/config/StaticUuidGeneratorConfiguration.java b/api/src/test/java/com/unitvectory/lockservicecentral/api/config/StaticUuidGeneratorConfiguration.java index 45d2881..45e77a9 100644 --- a/api/src/test/java/com/unitvectory/lockservicecentral/api/config/StaticUuidGeneratorConfiguration.java +++ b/api/src/test/java/com/unitvectory/lockservicecentral/api/config/StaticUuidGeneratorConfiguration.java @@ -21,7 +21,7 @@ import com.unitvectory.consistgen.uuid.UuidGenerator; /** - * The Static UUUID Generator Configuration + * The Static UUID Generator Configuration * * @author Jared Hatfield (UnitVectorY Labs) */ diff --git a/codecov.yml b/codecov.yml index 90faf79..1017fef 100644 --- a/codecov.yml +++ b/codecov.yml @@ -15,6 +15,9 @@ component_management: - component_id: "locker-etcd" paths: - "locker-etcd/**" + - component_id: "locker-dynamodb" + paths: + - "locker-dynamodb/**" - component_id: "logging" paths: - "logging/**" diff --git a/locker-dynamodb/README.md b/locker-dynamodb/README.md new file mode 100644 index 0000000..d3e0262 --- /dev/null +++ b/locker-dynamodb/README.md @@ -0,0 +1,190 @@ +# locker-dynamodb + +AWS DynamoDB backend implementation for LockServiceCentral. + +## Quick Start + +Build and run with the DynamoDB backend: + +```bash +mvn clean package -DskipTests -Pdynamodb -ntp +SPRING_PROFILES_ACTIVE=dynamodb LOCKER_DYNAMODB_REGION=us-east-1 AUTHENTICATION_DISABLED=true java -jar ./api/target/api-0.0.1-SNAPSHOT.jar +``` + +Or with Docker: + +```bash +docker build --build-arg LOCKER=dynamodb -t lockservicecentral-dynamodb . +docker run -p 8080:8080 \ + -e SPRING_PROFILES_ACTIVE=dynamodb \ + -e LOCKER_DYNAMODB_REGION=us-east-1 \ + -e AUTHENTICATION_DISABLED=true \ + lockservicecentral-dynamodb +``` + +## Overview + +This module provides a distributed lock implementation backed by [AWS DynamoDB](https://aws.amazon.com/dynamodb/). It uses DynamoDB's conditional write operations to ensure atomic lock operations across distributed instances. + +## Configuration + +All configuration properties are prefixed with `locker.dynamodb.*`. + +### DynamoDB Properties + +| Property | Default | Description | +|----------|---------|-------------| +| `locker.dynamodb.tableName` | `locks` | DynamoDB table name for storing locks | +| `locker.dynamodb.region` | `us-east-1` | AWS region for DynamoDB | +| `locker.dynamodb.endpoint` | | Custom endpoint URL (optional, for local development) | + +### AWS Credentials + +| Property | Default | Description | +|----------|---------|-------------| +| `locker.dynamodb.accessKeyId` | | AWS access key ID (optional) | +| `locker.dynamodb.secretAccessKey` | | AWS secret access key (optional) | + +**Note:** When running on AWS (ECS, EKS, Lambda, etc.), credentials are automatically detected from the environment using the default credentials provider chain. For local development, you can either: +1. Set `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables +2. Configure the properties above +3. Use AWS CLI credentials (`~/.aws/credentials`) + +## Building + +Build the DynamoDB-enabled API jar: + +```bash +mvn clean package -DskipTests -Pdynamodb -ntp +java -jar ./api/target/api-0.0.1-SNAPSHOT.jar +``` + +Build Docker image: + +```bash +docker build --build-arg LOCKER=dynamodb -t lockservicecentral-dynamodb . +``` + +## DynamoDB Setup + +### Required Table Configuration + +Create a DynamoDB table with the following schema: + +1. **Table Name**: `locks` (or your configured table name) +2. **Partition Key**: `lockId` (String) +3. **Billing Mode**: On-Demand or Provisioned (based on your needs) + +Example AWS CLI command to create the table: + +```bash +aws dynamodb create-table \ + --table-name locks \ + --attribute-definitions AttributeName=lockId,AttributeType=S \ + --key-schema AttributeName=lockId,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST \ + --region us-east-1 +``` + +### TTL Configuration (Recommended) + +Configure a TTL (Time To Live) attribute on the `expiry` field to automatically delete expired lock items: + +```bash +aws dynamodb update-time-to-live \ + --table-name locks \ + --time-to-live-specification "Enabled=true, AttributeName=expiry" \ + --region us-east-1 +``` + +This ensures expired locks are automatically cleaned up by DynamoDB, typically within 48 hours of expiration. + +## Implementation Details + +### Item Structure + +Lock items are stored with the following attributes: + +- `lockId`: Partition key in format `{namespace}:{lockName}` +- `namespace`: The lock namespace +- `lockName`: The lock name +- `owner`: The lock owner +- `instanceId`: The client instance ID +- `leaseDuration`: The lease duration in seconds +- `expiry`: The expiry timestamp in epoch seconds (used for TTL) + +### Atomicity + +All lock operations use DynamoDB's conditional write operations to ensure fully atomic lock semantics. Each operation performs all condition checks and the mutation in a single atomic DynamoDB call, eliminating race conditions that would occur with read-then-write patterns. + +**Key atomicity guarantees:** + +- **Single-operation mutations**: Acquire, renew, and release each complete in a single DynamoDB API call with conditions evaluated atomically. +- **Expiry checks in conditions**: Lock expiration is evaluated within DynamoDB's condition expressions using the current timestamp, ensuring no time-of-check to time-of-use (TOCTOU) vulnerabilities. +- **ReturnValuesOnConditionCheckFailure**: For release operations, when the condition fails, the existing item is returned atomically with the exception, avoiding a separate read. + +### Lock Expiry + +Lock expiry is handled in two ways: + +1. **Condition-level**: Expiry is checked atomically within DynamoDB condition expressions during lock operations +2. **DynamoDB TTL**: If configured, DynamoDB automatically deletes expired items based on the `expiry` field + +### Behavior + +- **Acquire**: Uses a single conditional `PutItem` operation with a compound condition: + - Lock doesn't exist (`attribute_not_exists`), OR + - Lock is expired (`expiry < :now`), OR + - Lock belongs to the same owner/instance (`owner = :owner AND instanceId = :instanceId`) + +- **Renew**: Uses a single conditional `UpdateItem` operation that: + - Validates the lock exists (via `attribute_exists`), is not expired, and matches owner/instance + - Atomically adds the requested duration to both `leaseDuration` and `expiry` + - Returns the updated values via `ReturnValues.ALL_NEW` + +- **Release**: Uses a single conditional `DeleteItem` operation that: + - Validates ownership (`owner` and `instanceId` must match) + - Allows releasing expired locks owned by the same owner + - Uses `ReturnValuesOnConditionCheckFailure.ALL_OLD` to distinguish between non-existent locks (success) and locks owned by others (conflict) + +## Example Configuration + +```properties +# DynamoDB table +locker.dynamodb.tableName=my-locks + +# AWS region +locker.dynamodb.region=us-west-2 + +# Custom endpoint (for local DynamoDB) +locker.dynamodb.endpoint=http://localhost:8000 + +# Static credentials (optional, not recommended for production) +locker.dynamodb.accessKeyId=YOUR_ACCESS_KEY +locker.dynamodb.secretAccessKey=YOUR_SECRET_KEY +``` + +## Local Development with DynamoDB Local + +For local development, you can use [DynamoDB Local](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html): + +```bash +# Run DynamoDB Local with Docker +docker run -p 8000:8000 amazon/dynamodb-local + +# Create the locks table +aws dynamodb create-table \ + --table-name locks \ + --attribute-definitions AttributeName=lockId,AttributeType=S \ + --key-schema AttributeName=lockId,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST \ + --endpoint-url http://localhost:8000 \ + --region us-east-1 + +# Run the application +LOCKER_DYNAMODB_ENDPOINT=http://localhost:8000 \ +LOCKER_DYNAMODB_REGION=us-east-1 \ +SPRING_PROFILES_ACTIVE=dynamodb \ +AUTHENTICATION_DISABLED=true \ +java -jar ./api/target/api-0.0.1-SNAPSHOT.jar +``` diff --git a/locker-dynamodb/pom.xml b/locker-dynamodb/pom.xml new file mode 100644 index 0000000..330815c --- /dev/null +++ b/locker-dynamodb/pom.xml @@ -0,0 +1,97 @@ + + + 4.0.0 + + com.unitvectory + lockservicecentral + 0.0.1-SNAPSHOT + + + com.unitvectory.lockservicecentral + locker-dynamodb + + + 2.29.49 + + + + + com.unitvectory.lockservicecentral + locker + ${project.version} + + + com.unitvectory.lockservicecentral + logging + ${project.version} + + + org.springframework + spring-context + + + software.amazon.awssdk + dynamodb + ${aws.sdk.version} + + + org.slf4j + slf4j-api + + + com.unitvectory.lockservicecentral + locker-tests + ${project.version} + test + + + org.mockito + mockito-core + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.projectlombok + lombok + provided + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.springframework.boot + spring-boot-maven-plugin + + + true + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + diff --git a/locker-dynamodb/src/main/java/com/unitvectory/lockservicecentral/locker/dynamodb/DynamoDbLockService.java b/locker-dynamodb/src/main/java/com/unitvectory/lockservicecentral/locker/dynamodb/DynamoDbLockService.java new file mode 100644 index 0000000..354d933 --- /dev/null +++ b/locker-dynamodb/src/main/java/com/unitvectory/lockservicecentral/locker/dynamodb/DynamoDbLockService.java @@ -0,0 +1,355 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.unitvectory.lockservicecentral.locker.dynamodb; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.ObjectProvider; + +import com.unitvectory.lockservicecentral.locker.Lock; +import com.unitvectory.lockservicecentral.locker.LockService; +import com.unitvectory.lockservicecentral.logging.CanonicalLogContext; + +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; +import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; +import software.amazon.awssdk.services.dynamodb.model.DynamoDbException; +import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; +import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; +import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; +import software.amazon.awssdk.services.dynamodb.model.ReturnValue; +import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemResponse; + +/** + * DynamoDB implementation of {@link LockService} providing distributed lock functionality. + * + *

This implementation uses DynamoDB's conditional write operations to ensure atomic + * lock operations across distributed instances. All lock mutations (acquire, renew, release) + * are performed using single atomic operations with comprehensive condition expressions, + * eliminating race conditions that would occur with read-then-write patterns.

+ * + *

Atomicity Guarantees

+ * + * + *

Lock Expiry Handling

+ *

Lock expiry is checked atomically within DynamoDB condition expressions using the + * current timestamp passed to each operation. This ensures that expiry checks cannot + * be affected by clock skew between read and write operations.

+ * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Slf4j +public class DynamoDbLockService implements LockService { + + private final DynamoDbClient dynamoDbClient; + private final String tableName; + private final ObjectProvider canonicalLogContextProvider; + + /** + * Constructs a new DynamoDbLockService. + * + * @param dynamoDbClient the DynamoDB client + * @param tableName the DynamoDB table name for locks + * @param canonicalLogContextProvider provider for the canonical log context + */ + public DynamoDbLockService(DynamoDbClient dynamoDbClient, String tableName, + ObjectProvider canonicalLogContextProvider) { + this.dynamoDbClient = dynamoDbClient; + this.tableName = tableName; + this.canonicalLogContextProvider = canonicalLogContextProvider; + } + + /** + * Records the lock service outcome to the canonical log context. + * + * @param outcome the screaming snake case outcome + */ + private void recordOutcome(String outcome) { + try { + CanonicalLogContext context = canonicalLogContextProvider.getObject(); + context.put("lock_service_outcome", outcome); + } catch (Exception e) { + // Don't break lock operations if logging fails + } + } + + /** + * Generates the DynamoDB primary key for a lock based on namespace and lock name. + * + * @param namespace the namespace + * @param lockName the lock name + * @return the primary key value + */ + private String generateKey(String namespace, String lockName) { + return namespace + ":" + lockName; + } + + /** + * Converts a DynamoDB item to a Lock object. + * + * @param item the DynamoDB item + * @return the Lock object, or null if item is empty + */ + private Lock itemToLock(Map item) { + if (item == null || item.isEmpty()) { + return null; + } + + Map map = new HashMap<>(); + map.put("namespace", item.get("namespace").s()); + map.put("lockName", item.get("lockName").s()); + map.put("owner", item.get("owner").s()); + map.put("instanceId", item.get("instanceId").s()); + map.put("leaseDuration", Long.parseLong(item.get("leaseDuration").n())); + map.put("expiry", Long.parseLong(item.get("expiry").n())); + + return new Lock(map); + } + + /** + * Converts a Lock object to a DynamoDB item. + * + * @param lock the Lock object + * @return the DynamoDB item + */ + private Map lockToItem(Lock lock) { + Map item = new HashMap<>(); + String key = generateKey(lock.getNamespace(), lock.getLockName()); + + item.put("lockId", AttributeValue.builder().s(key).build()); + item.put("namespace", AttributeValue.builder().s(lock.getNamespace()).build()); + item.put("lockName", AttributeValue.builder().s(lock.getLockName()).build()); + item.put("owner", AttributeValue.builder().s(lock.getOwner()).build()); + item.put("instanceId", AttributeValue.builder().s(lock.getInstanceId()).build()); + item.put("leaseDuration", AttributeValue.builder().n(String.valueOf(lock.getLeaseDuration())).build()); + item.put("expiry", AttributeValue.builder().n(String.valueOf(lock.getExpiry())).build()); + + return item; + } + + @Override + public Lock getLock(@NonNull String namespace, @NonNull String lockName) { + String key = generateKey(namespace, lockName); + + try { + GetItemRequest request = GetItemRequest.builder() + .tableName(tableName) + .key(Map.of("lockId", AttributeValue.builder().s(key).build())) + .build(); + + GetItemResponse response = dynamoDbClient.getItem(request); + + if (!response.hasItem() || response.item().isEmpty()) { + return null; + } + + return itemToLock(response.item()); + + } catch (DynamoDbException e) { + log.error("Error getting lock: {} {}", namespace, lockName, e); + return null; + } + } + + @Override + public Lock acquireLock(@NonNull Lock originalLock, long now) { + Lock lock = originalLock.copy(); + + try { + Map item = lockToItem(lock); + + // Atomic condition: lock doesn't exist OR lock is expired OR same owner/instance + // This handles all acquire scenarios in a single atomic operation + String conditionExpression = + "attribute_not_exists(lockId) OR " + + "expiry < :now OR " + + "(#owner = :owner AND instanceId = :instanceId)"; + + Map expressionValues = new HashMap<>(); + expressionValues.put(":now", AttributeValue.builder().n(String.valueOf(now)).build()); + expressionValues.put(":owner", AttributeValue.builder().s(lock.getOwner()).build()); + expressionValues.put(":instanceId", AttributeValue.builder().s(lock.getInstanceId()).build()); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#owner", "owner"); + + PutItemRequest putRequest = PutItemRequest.builder() + .tableName(tableName) + .item(item) + .conditionExpression(conditionExpression) + .expressionAttributeValues(expressionValues) + .expressionAttributeNames(expressionNames) + .build(); + + dynamoDbClient.putItem(putRequest); + lock.setSuccess(); + recordOutcome("ACQUIRED"); + + } catch (ConditionalCheckFailedException e) { + // Condition failed: lock exists, is not expired, and belongs to different owner + lock.setFailed(); + recordOutcome("ACQUIRE_CONFLICT"); + + } catch (DynamoDbException e) { + log.error("Error acquiring lock: {}", lock, e); + lock.setFailed(); + recordOutcome("ACQUIRE_ERROR"); + } + + return lock; + } + + @Override + public Lock renewLock(@NonNull Lock originalLock, long now) { + Lock lock = originalLock.copy(); + String key = generateKey(lock.getNamespace(), lock.getLockName()); + + try { + // Atomic condition: lock exists, is not expired, and matches owner/instance + // Note: attribute_exists is required because UpdateItem would otherwise create the item + String conditionExpression = + "attribute_exists(lockId) AND " + + "expiry >= :now AND " + + "#owner = :owner AND " + + "instanceId = :instanceId"; + + // Update expression: add leaseDuration to both leaseDuration and expiry + String updateExpression = + "SET leaseDuration = leaseDuration + :addDuration, " + + "expiry = expiry + :addDuration"; + + Map expressionValues = new HashMap<>(); + expressionValues.put(":now", AttributeValue.builder().n(String.valueOf(now)).build()); + expressionValues.put(":owner", AttributeValue.builder().s(lock.getOwner()).build()); + expressionValues.put(":instanceId", AttributeValue.builder().s(lock.getInstanceId()).build()); + expressionValues.put(":addDuration", AttributeValue.builder().n(String.valueOf(lock.getLeaseDuration())).build()); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#owner", "owner"); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(tableName) + .key(Map.of("lockId", AttributeValue.builder().s(key).build())) + .conditionExpression(conditionExpression) + .updateExpression(updateExpression) + .expressionAttributeValues(expressionValues) + .expressionAttributeNames(expressionNames) + .returnValues(ReturnValue.ALL_NEW) + .build(); + + UpdateItemResponse response = dynamoDbClient.updateItem(updateRequest); + + // Extract the updated values from the response + Map attrs = response.attributes(); + if (attrs != null && !attrs.isEmpty()) { + Lock updatedLock = itemToLock(attrs); + if (updatedLock != null) { + lock.setLeaseDuration(updatedLock.getLeaseDuration()); + lock.setExpiry(updatedLock.getExpiry()); + } + } + + lock.setSuccess(); + recordOutcome("RENEWED"); + + } catch (ConditionalCheckFailedException e) { + // Condition failed: lock doesn't exist, is expired, or belongs to different owner + lock.setFailed(); + recordOutcome("RENEW_CONFLICT"); + + } catch (DynamoDbException e) { + log.error("Error renewing lock: {}", lock, e); + lock.setFailed(); + recordOutcome("RENEW_ERROR"); + } + + return lock; + } + + @Override + public Lock releaseLock(@NonNull Lock originalLock, long now) { + Lock lock = originalLock.copy(); + String key = generateKey(lock.getNamespace(), lock.getLockName()); + + try { + // Atomic condition: lock must match owner and instanceId + // We allow releasing expired locks that we own + String conditionExpression = + "#owner = :owner AND instanceId = :instanceId"; + + Map expressionValues = new HashMap<>(); + expressionValues.put(":owner", AttributeValue.builder().s(lock.getOwner()).build()); + expressionValues.put(":instanceId", AttributeValue.builder().s(lock.getInstanceId()).build()); + + Map expressionNames = new HashMap<>(); + expressionNames.put("#owner", "owner"); + + DeleteItemRequest deleteRequest = DeleteItemRequest.builder() + .tableName(tableName) + .key(Map.of("lockId", AttributeValue.builder().s(key).build())) + .conditionExpression(conditionExpression) + .expressionAttributeValues(expressionValues) + .expressionAttributeNames(expressionNames) + .returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD) + .build(); + + dynamoDbClient.deleteItem(deleteRequest); + lock.setCleared(); + recordOutcome("RELEASED"); + + } catch (ConditionalCheckFailedException e) { + // Condition failed: could mean lock doesn't exist or belongs to different owner + // Check the item from the exception to determine which case + Map item = e.item(); + + if (item == null || item.isEmpty()) { + // Lock doesn't exist - treat as success (already released) + lock.setCleared(); + recordOutcome("RELEASED_NOT_FOUND"); + } else { + // Lock exists but belongs to different owner + Lock existingLock = itemToLock(item); + if (existingLock != null && existingLock.isExpired(now)) { + // Lock is expired, treat as effectively released + lock.setCleared(); + recordOutcome("RELEASED_EXPIRED"); + } else { + // Lock exists and belongs to different owner, and is not expired + lock.setFailed(); + recordOutcome("RELEASE_CONFLICT"); + } + } + + } catch (DynamoDbException e) { + log.error("Error releasing lock: {}", lock, e); + lock.setFailed(); + recordOutcome("RELEASE_ERROR"); + } + + return lock; + } +} diff --git a/locker-dynamodb/src/main/java/com/unitvectory/lockservicecentral/locker/dynamodb/LockerAwsDynamoDbClientConfig.java b/locker-dynamodb/src/main/java/com/unitvectory/lockservicecentral/locker/dynamodb/LockerAwsDynamoDbClientConfig.java new file mode 100644 index 0000000..1967f77 --- /dev/null +++ b/locker-dynamodb/src/main/java/com/unitvectory/lockservicecentral/locker/dynamodb/LockerAwsDynamoDbClientConfig.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.unitvectory.lockservicecentral.locker.dynamodb; + +import java.net.URI; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.DynamoDbClientBuilder; + +/** + * The Configuration for the DynamoDB Client. + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Configuration +public class LockerAwsDynamoDbClientConfig { + + @Value("${locker.dynamodb.region:us-east-1}") + private String region; + + @Value("${locker.dynamodb.endpoint:#{null}}") + private String endpoint; + + @Value("${locker.dynamodb.accessKeyId:#{null}}") + private String accessKeyId; + + @Value("${locker.dynamodb.secretAccessKey:#{null}}") + private String secretAccessKey; + + @Bean + public DynamoDbClient dynamoDbClient() { + DynamoDbClientBuilder builder = DynamoDbClient.builder() + .region(Region.of(region)); + + // Configure credentials + AwsCredentialsProvider credentialsProvider; + if (accessKeyId != null && !accessKeyId.isEmpty() && secretAccessKey != null && !secretAccessKey.isEmpty()) { + // Use static credentials if provided + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKeyId, secretAccessKey); + credentialsProvider = StaticCredentialsProvider.create(credentials); + } else { + // Use default credentials provider chain (environment variables, instance profile, etc.) + credentialsProvider = DefaultCredentialsProvider.create(); + } + builder.credentialsProvider(credentialsProvider); + + // Configure endpoint if provided (for local development or custom endpoints) + if (endpoint != null && !endpoint.isEmpty()) { + builder.endpointOverride(URI.create(endpoint)); + } + + return builder.build(); + } +} diff --git a/locker-dynamodb/src/main/java/com/unitvectory/lockservicecentral/locker/dynamodb/LockerAwsDynamoDbConfig.java b/locker-dynamodb/src/main/java/com/unitvectory/lockservicecentral/locker/dynamodb/LockerAwsDynamoDbConfig.java new file mode 100644 index 0000000..2f91675 --- /dev/null +++ b/locker-dynamodb/src/main/java/com/unitvectory/lockservicecentral/locker/dynamodb/LockerAwsDynamoDbConfig.java @@ -0,0 +1,45 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.unitvectory.lockservicecentral.locker.dynamodb; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.unitvectory.lockservicecentral.locker.LockService; +import com.unitvectory.lockservicecentral.logging.CanonicalLogContext; + +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; + +/** + * The Configuration for the DynamoDB LockService. + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Configuration +public class LockerAwsDynamoDbConfig { + + @Autowired + private DynamoDbClient dynamoDbClient; + + @Value("${locker.dynamodb.tableName:locks}") + private String tableName; + + @Bean + public LockService lockService(ObjectProvider canonicalLogContextProvider) { + return new DynamoDbLockService(this.dynamoDbClient, this.tableName, canonicalLogContextProvider); + } +} diff --git a/locker-dynamodb/src/test/java/com/unitvectory/lockservicecentral/locker/dynamodb/DynamoDbLockServiceActualTest.java b/locker-dynamodb/src/test/java/com/unitvectory/lockservicecentral/locker/dynamodb/DynamoDbLockServiceActualTest.java new file mode 100644 index 0000000..3ca6d4c --- /dev/null +++ b/locker-dynamodb/src/test/java/com/unitvectory/lockservicecentral/locker/dynamodb/DynamoDbLockServiceActualTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.unitvectory.lockservicecentral.locker.dynamodb; + +import org.junit.jupiter.api.Disabled; +import org.springframework.beans.factory.ObjectProvider; + +import com.unitvectory.lockservicecentral.locker.LockService; +import com.unitvectory.lockservicecentral.locker.tests.AbstractLockServiceTest; +import com.unitvectory.lockservicecentral.logging.CanonicalLogContext; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; + +import java.net.URI; + +/** + * The DynamoDbLockService test with actual DynamoDB server. + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +@Disabled +public class DynamoDbLockServiceActualTest extends AbstractLockServiceTest { + + @Override + protected LockService createLockService() { + // These tests are disabled because they require interaction with an actual + // DynamoDB server (or DynamoDB Local) to run. These are only intended to be used for manual + // local testing. + + // For DynamoDB Local running on localhost:8000 + DynamoDbClient client = DynamoDbClient.builder() + .region(Region.US_EAST_1) + .endpointOverride(URI.create("http://localhost:8000")) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create("dummy", "dummy"))) + .build(); + + // Use a no-op ObjectProvider for testing + ObjectProvider noOpProvider = new ObjectProvider<>() { + @Override + public CanonicalLogContext getObject() { + return new CanonicalLogContext(); + } + + @Override + public CanonicalLogContext getObject(Object... args) { + return new CanonicalLogContext(); + } + + @Override + public CanonicalLogContext getIfAvailable() { + return new CanonicalLogContext(); + } + + @Override + public CanonicalLogContext getIfUnique() { + return new CanonicalLogContext(); + } + }; + + return new DynamoDbLockService(client, "locks", noOpProvider); + } + +} diff --git a/locker-dynamodb/src/test/java/com/unitvectory/lockservicecentral/locker/dynamodb/DynamoDbLockServiceTest.java b/locker-dynamodb/src/test/java/com/unitvectory/lockservicecentral/locker/dynamodb/DynamoDbLockServiceTest.java new file mode 100644 index 0000000..d23e3c8 --- /dev/null +++ b/locker-dynamodb/src/test/java/com/unitvectory/lockservicecentral/locker/dynamodb/DynamoDbLockServiceTest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.unitvectory.lockservicecentral.locker.dynamodb; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; + +import com.unitvectory.lockservicecentral.locker.Lock; +import com.unitvectory.lockservicecentral.logging.CanonicalLogContext; + +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; +import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; + +/** + * The DynamoDbLockService test. + * + * @author Jared Hatfield (UnitVectorY Labs) + */ +public class DynamoDbLockServiceTest { + + @Test + public void getLockTest() { + DynamoDbClient mockDynamoDb = mock(DynamoDbClient.class); + // Use a no-op ObjectProvider for testing + ObjectProvider noOpProvider = new ObjectProvider<>() { + @Override + public CanonicalLogContext getObject() { + return new CanonicalLogContext(); + } + + @Override + public CanonicalLogContext getObject(Object... args) { + return new CanonicalLogContext(); + } + + @Override + public CanonicalLogContext getIfAvailable() { + return new CanonicalLogContext(); + } + + @Override + public CanonicalLogContext getIfUnique() { + return new CanonicalLogContext(); + } + }; + DynamoDbLockService service = new DynamoDbLockService(mockDynamoDb, "locks", noOpProvider); + + // Mock the GetItemResponse + GetItemResponse mockResponse = GetItemResponse.builder() + .item(new HashMap<>()) + .build(); + + when(mockDynamoDb.getItem(any(GetItemRequest.class))).thenReturn(mockResponse); + + // Call the method under test + Lock lock = service.getLock("foo", "bar"); + + // Verify the result + assertNull(lock); + } +} diff --git a/pom.xml b/pom.xml index d299b54..689f85e 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,7 @@ locker-firestore locker-etcd + locker-dynamodb @@ -49,6 +50,12 @@ locker-etcd + + dynamodb + + locker-dynamodb + +