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
+ *
+ *
Acquire: Single PutItem with condition that succeeds only if the lock doesn't
+ * exist, is expired, or belongs to the same owner/instance
+ *
Renew: Single UpdateItem with condition that succeeds only if the lock exists,
+ * is not expired, and matches the owner/instance
+ *
Release: Single DeleteItem with condition that succeeds only if the lock
+ * matches the owner/instance (expired locks are also deletable)
+ *
+ *
+ *
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-firestorelocker-etcd
+ locker-dynamodb
@@ -49,6 +50,12 @@
locker-etcd
+
+ dynamodb
+
+ locker-dynamodb
+
+