Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/docker-build-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ jobs:
tag: firestore
- profile: etcd
tag: etcd
- profile: dynamodb
tag: dynamodb

steps:
- name: Checkout code
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
```
Expand All @@ -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 .
```
11 changes: 11 additions & 0 deletions api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,17 @@
</dependency>
</dependencies>
</profile>
<profile>
<id>dynamodb</id>
<dependencies>
<dependency>
<groupId>com.unitvectory.lockservicecentral</groupId>
<artifactId>locker-dynamodb</artifactId>
<version>${project.version}</version>
<scope>runtime</scope>
</dependency>
</dependencies>
</profile>
</profiles>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
*/
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down
3 changes: 3 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"
190 changes: 190 additions & 0 deletions locker-dynamodb/README.md
Original file line number Diff line number Diff line change
@@ -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
```
97 changes: 97 additions & 0 deletions locker-dynamodb/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.unitvectory</groupId>
<artifactId>lockservicecentral</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>

<groupId>com.unitvectory.lockservicecentral</groupId>
<artifactId>locker-dynamodb</artifactId>

<properties>
<aws.sdk.version>2.29.49</aws.sdk.version>
</properties>

<dependencies>
<dependency>
<groupId>com.unitvectory.lockservicecentral</groupId>
<artifactId>locker</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.unitvectory.lockservicecentral</groupId>
<artifactId>logging</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>dynamodb</artifactId>
<version>${aws.sdk.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>com.unitvectory.lockservicecentral</groupId>
<artifactId>locker-tests</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- Required as this is a library that needs to not be repackaged -->
<skip>true</skip>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
Loading
Loading