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: 0 additions & 2 deletions .github/workflows/qodana_code_quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@ jobs:

- name: Qodana Scan
uses: JetBrains/qodana-action@v2025.3
env:
#QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
with:
# When pr-mode is set to true, Qodana analyzes only the files that have been changed
pr-mode: false
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Idempotent is a lightweight Java library that provides support for idempotency in APIs, making it easier to handle duplicate
requests and ensuring reliable operation in distributed systems. This library integrates seamlessly with Spring applications
and offers idempotency support using Redis, DynamoDB, and NATS stores.
and offers idempotency support using Redis, DynamoDB, NATS, and RDS stores.

<img src="./idempotent.png" alt="Idempotent">

Expand All @@ -29,7 +29,7 @@ In API development, idempotency helps in the following ways:
## Features
* **Two API Approaches**: Choose between annotation-based (AOP) or programmatic service-based idempotency depending on your needs.
* **Integration with Spring**: Seamlessly integrates with Spring applications, providing annotations and utilities to easily add idempotency support to APIs.
* **Support for [Redis](idempotent-redis/README.md), [DynamoDB](idempotent-dynamo/README.md) or [NATS](idempotent-nats/README.md)**: Storage adapters for Redis, DynamoDB and NATS, allowing developers to choose the backend that best suits their requirements.
* **Support for [Redis](idempotent-redis/README.md), [DynamoDB](idempotent-dynamo/README.md), [NATS](idempotent-nats/README.md) or [RDS](idempotent-rds/README.md)**: Storage adapters for Redis, DynamoDB, NATS, and RDS, allowing developers to choose the backend that best suits their requirements.
* **Simple Configuration**: Adding idempotency is as simple as annotating methods with [@Idempotent](idempotent-core/src/main/java/io/github/arun0009/idempotent/core/annotation/Idempotent.java) or using the `IdempotentService` programmatically.
* **Client-Specified or Server-Specified Idempotent Keys**: Clients can dictate what the idempotent key should be via a configurable HTTP header, or the server can specify the idempotency key specified in the @Idempotent annotation configuration.
* **Handling In-Progress Concurrent/Duplicate Requests**: Concurrent or duplicate requests will wait for the first request to complete (within a given configurable time frame and retries) and return the same response as the first request.
Expand All @@ -38,8 +38,8 @@ In API development, idempotency helps in the following ways:

### Annotation-Based Approach (AOP)

1. Add the Idempotent maven dependency([redis](https://central.sonatype.com/artifact/io.github.arun0009/idempotent-redis), [dynamo](https://central.sonatype.com/artifact/io.github.arun0009/idempotent-dynamo) or [nats](https://central.sonatype.com/artifact/io.github.arun0009/idempotent-nats)) to your project.
2. Configure the storage backend ([Redis](idempotent-redis/README.md), [DynamoDB](idempotent-dynamo/README.md) or [NATS](idempotent-nats/README.md)) in your Spring application context.
1. Add the Idempotent maven dependency([redis](https://central.sonatype.com/artifact/io.github.arun0009/idempotent-redis), [dynamo](https://central.sonatype.com/artifact/io.github.arun0009/idempotent-dynamo), [nats](https://central.sonatype.com/artifact/io.github.arun0009/idempotent-nats) or [rds](https://central.sonatype.com/artifact/io.github.arun0009/idempotent-rds)) to your project.
2. Configure the storage backend ([Redis](idempotent-redis/README.md), [DynamoDB](idempotent-dynamo/README.md), [NATS](idempotent-nats/README.md) or [RDS](idempotent-rds/README.md)) in your Spring application context.
3. Annotate the desired API methods with [@Idempotent](idempotent-core/src/main/java/io/github/arun0009/idempotent/core/annotation/Idempotent.java):
4. Specify the key, time-to-live (TTL) as a Duration string, and/or if you want to hash the key for idempotent requests.

Expand Down
2 changes: 1 addition & 1 deletion idempotent-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<groupId>io.github.arun0009</groupId>
<artifactId>idempotent</artifactId>
<version>2.0.1</version>
<version>2.1.0</version>
</parent>

<artifactId>idempotent-core</artifactId>
Expand Down
2 changes: 1 addition & 1 deletion idempotent-dynamo/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<groupId>io.github.arun0009</groupId>
<artifactId>idempotent</artifactId>
<version>2.0.1</version>
<version>2.1.0</version>
</parent>
<artifactId>idempotent-dynamo</artifactId>
<packaging>jar</packaging>
Expand Down
2 changes: 1 addition & 1 deletion idempotent-nats/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<parent>
<groupId>io.github.arun0009</groupId>
<artifactId>idempotent</artifactId>
<version>2.0.1</version>
<version>2.1.0</version>
</parent>

<artifactId>idempotent-nats</artifactId>
Expand Down
92 changes: 92 additions & 0 deletions idempotent-rds/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Idempotent Cache with RDS Storage (JDBC)

To integrate the idempotent cache with RDS (JDBC) storage into your project, add the following dependency to your pom.xml file:

```xml
<dependency>
<groupId>io.github.arun0009</groupId>
<artifactId>idempotent-rds</artifactId>
<version>${idempotent.version}</version>
</dependency>
```

## Overview

This module provides an idempotent request handling mechanism using a Relational Database (via JDBC/JdbcTemplate) for storage. It requires a database table to be created.

## Database Schema

You must create a table for storing idempotent keys. The default table name is `idempotent`.

**MySQL / PostgreSQL Example:**

```sql
CREATE TABLE idempotent (
key_id VARCHAR(255) NOT NULL,
process_name VARCHAR(255) NOT NULL,
status VARCHAR(50),
expiration_time_millis BIGINT,
response TEXT,
PRIMARY KEY (key_id, process_name)
);

CREATE INDEX idx_expiration_time ON idempotent(expiration_time_millis);
```

## Configuration Properties

### General Properties

See main [README](../README.md) for general idempotent configuration.

### RDS Configuration

* Table Name

Property: `idempotent.rds.table-name`
Default Value: `idempotent`
Description: The name of the database table to use.

* Cleanup Schedule

Property: `idempotent.rds.cleanup.fixed-delay`
Default Value: `60000` (1 minute)
Description: Fixed delay in milliseconds for the cleanup task that removes expired keys.

* Cleanup Batch Size

Property: `idempotent.rds.cleanup.batch-size`
Default Value: `1000`
Description: Number of expired keys to delete in each batch to prevent long-running database locks.

## Cleanup

Unlike Redis or DynamoDb, RDS does not support native TTL for records. This module includes a `RdsCleanupTask` that:

1. **Scheduled Execution**: Runs on a configurable schedule on all application instances.
2. **Batch Deletion**: Efficiently removes expired records in batches to avoid long-running locks.
3. **Safe Concurrency**: It is safe to run on multiple instances; database transactions ensure that a row is deleted exactly once.


## Dependencies

This module provides the core JDBC logic but does **not** bundle a connection pool or database drivers. You must provide them in your application:

**The Recommended Way (via Spring Boot Starter):**
1. Add `spring-boot-starter-jdbc` to your application (this automatically provides `HikariCP` and configures the `DataSource` bean).
2. Add the JDBC driver for your database (e.g., `mysql-connector-j` or `postgresql`).
3. Configure your `DataSource` as per standard Spring Boot configuration (e.g., `spring.datasource.url`).

**Manual Setup:**
If you prefer not to use the starter, you must manually provide a `DataSource` bean, a connection pool library (like `HikariCP`), and your database driver.

## Performance Tuning

Since this implementation relies on a relational database, performance is critical. Here are some tips to ensure low latency:

1. **Indexes**: The provided schema uses a composite Primary Key `(key_id, process_name)`. This creates a clustered index (in MySQL/InnoDB) which is the fastest way to look up records. **Do not remove this.**
2. **Connection Pooling**: Use a production-grade connection pool like HikariCP (default in Spring Boot).
* Set `maximum-pool-size` appropriately for your concurrency level.
* Set `minimum-idle` to keep connections warm.
3. **Payload Size**: The `response` column stores the JSON response. If your responses are very large (MBs), retrieving them will add latency. Consider keeping responses concise or using a hybrid approach if payloads are massive.
4. **Database Hardware**: Ensure your RDS instance has sufficient IOPS, as idempotency checks involve frequent reads and writes.
98 changes: 98 additions & 0 deletions idempotent-rds/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?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>io.github.arun0009</groupId>
<artifactId>idempotent</artifactId>
<version>2.1.0</version>
</parent>
<artifactId>idempotent-rds</artifactId>
<packaging>jar</packaging>
<name>idempotent-rds</name>
<description>idempotent-rds</description>
<url>https://github.com/arun0009/idempotent/tree/main/idempotent-rds</url>

<dependencies>
<dependency>
<groupId>io.github.arun0009</groupId>
<artifactId>idempotent-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<version>${spring-boot.version}</version>
<scope>provided</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<scope>test</scope>
</dependency>
<!-- TestContainers for real database testing -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<!-- JDBC drivers for TestContainers -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<!-- H2 for fast development testing -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.github.arun0009</groupId>
<artifactId>idempotent-core</artifactId>
<type>test-jar</type>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package io.github.arun0009.idempotent.rds;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.github.arun0009.idempotent.core.persistence.IdempotentStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
import org.springframework.util.Assert;
import tools.jackson.databind.DefaultTyping;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import tools.jackson.databind.jsontype.impl.DefaultTypeResolverBuilder;

import javax.sql.DataSource;
import java.time.Duration;

@AutoConfiguration(
afterName = {
"org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration",
"org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration"
})
@ConditionalOnClass({DataSource.class, JdbcTemplate.class})
@ConditionalOnBean(DataSource.class)
@EnableConfigurationProperties(RdsIdempotentProperties.class)
public class RdsAutoConfiguration {

private static final Logger log = LoggerFactory.getLogger(RdsAutoConfiguration.class);

@Bean
@ConditionalOnMissingBean
public IdempotentStore idempotentStore(
JdbcTemplate jdbcTemplate, RdsIdempotentProperties properties, RdsJacksonJsonBuilderCustomizer customizer) {
Assert.hasText(properties.tableName(), "idempotent.rds.table-name must not be blank");

var jsonBuilder = JsonMapper.builder();
customizer.customize(jsonBuilder);

return new RdsIdempotentStore(jdbcTemplate, properties.tableName(), jsonBuilder.build());
}

@Bean
@ConditionalOnMissingBean
public RdsJacksonJsonBuilderCustomizer rdsJacksonJsonBuilderCustomizer() {
return builder -> {
log.warn("Using an unrestricted polymorphic type validator for RDS idempotent store. "
+ "Without restrictions of the PolymorphicTypeValidator, deserialization is "
+ "vulnerable to arbitrary code execution when reading from untrusted sources.");
var ptv = BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Object.class)
.allowIfSubType((ctx, clazz) -> true)
.build();
builder.polymorphicTypeValidator(ptv)
.setDefaultTyping(new DefaultTypeResolverBuilder(
ptv, DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY, JsonTypeInfo.Id.CLASS, "@class"));
};
}

@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "idempotent.rds.cleanup", name = "enabled", matchIfMissing = true)
public RdsCleanupTask rdsCleanupTask(
JdbcTemplate jdbcTemplate, RdsIdempotentProperties properties, TaskScheduler rdsCleanupTaskScheduler) {
Assert.isTrue(properties.cleanup().batchSize() > 0, "idempotent.rds.cleanup.batch-size must be positive");
Assert.isTrue(properties.cleanup().fixedDelay() > 0, "idempotent.rds.cleanup.fixed-delay must be positive");

RdsDialect dialect = RdsDialect.detect(jdbcTemplate);
var cleanupTask = new RdsCleanupTask(
jdbcTemplate,
properties.tableName(),
dialect,
properties.cleanup().batchSize());
rdsCleanupTaskScheduler.scheduleWithFixedDelay(
cleanupTask::cleanup, Duration.ofMillis(properties.cleanup().fixedDelay()));
return cleanupTask;
}

@Bean
@ConditionalOnMissingBean(name = "rdsCleanupTaskScheduler")
@ConditionalOnProperty(prefix = "idempotent.rds.cleanup", name = "enabled", matchIfMissing = true)
public TaskScheduler rdsCleanupTaskScheduler() {
var scheduler = new SimpleAsyncTaskScheduler();
scheduler.setThreadNamePrefix("idempotent-rds-cleanup-");
return scheduler;
}
}
Loading
Loading