Skip to content

Commit 4ae3e32

Browse files
praba2210Prabakaran Annadurai
andauthored
feat(jdbc): add OCC retry with exponential backoff (#542)
Add retry utilities for Aurora DSQL OCC conflicts with exponential backoff and jitter. Includes DataSource (fresh connection per attempt) and Connection (reuse with rollback) overloads, immutable config with builder pattern, and OCCTransactionRunner convenience wrapper. - OCCRetry: static execute() methods, isOCCError() detection, backoff - OCCRetryConfig: validated builder (maxRetries, baseDelay, jitter, etc) - OCCTransactionRunner: bind-once wrapper with run()/runVoid() - VoidTransactionCallback: ergonomic void lambda interface - Unit tests covering all retry paths and edge cases - Integration tests with deterministic OCC conflict scenarios - Update examples to demonstrate OCC retry usage - Add README documentation with integration framework examples *Issue #, if available:* N/A *Description of changes:* By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. Co-authored-by: Prabakaran Annadurai <prabaw@amazon.com>
1 parent b64be99 commit 4ae3e32

16 files changed

Lines changed: 1958 additions & 73 deletions

File tree

.github/workflows/java-jdbc-ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,16 @@ jobs:
106106
CLUSTER_USER: "admin"
107107
run: ./gradlew integrationTest
108108

109+
- name: Publish connector to Maven Local
110+
run: ./gradlew publishToMavenLocal
111+
109112
- name: Run example smoke tests
110113
working-directory: java/jdbc/examples/pgjdbc
111114
env:
112115
CLUSTER_ENDPOINT: ${{ needs.create-cluster.outputs.cluster-endpoint }}
113116
CLUSTER_USER: "admin"
117+
USE_MAVEN_LOCAL: "true"
118+
CONNECTOR_VERSION: "0.0.0-SNAPSHOT"
114119
run: ../../gradlew test
115120

116121
integration-tests:

java/jdbc/README.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,115 @@ java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
160160
java.util.logging.SimpleFormatter.format = %1$tH:%1$tM:%1$tS.%1$tL [%4$s] %3$s - %5$s%n
161161
```
162162

163+
## OCC Retry
164+
165+
Aurora DSQL uses Optimistic Concurrency Control (OCC) — conflicts are detected at commit time. When two transactions modify the same data, the second to commit receives an OCC error. This connector provides utilities for automatic retry with exponential backoff.
166+
167+
### Quick Start
168+
169+
```java
170+
// Bind once, reuse across call sites
171+
DataSource ds = new HikariDataSource(config);
172+
OCCTransactionRunner runner = OCCTransactionRunner.create(ds);
173+
174+
int count = runner.run(conn -> {
175+
Statement stmt = conn.createStatement();
176+
stmt.executeUpdate("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
177+
stmt.executeUpdate("UPDATE accounts SET balance = balance + 100 WHERE id = 2");
178+
return 2;
179+
});
180+
181+
// Void variant — no need to return null
182+
runner.runVoid(conn -> {
183+
conn.createStatement().executeUpdate("DELETE FROM expired_sessions");
184+
});
185+
```
186+
187+
### Custom Configuration
188+
189+
```java
190+
OCCRetryConfig config = OCCRetryConfig.builder()
191+
.maxRetries(5)
192+
.baseDelayMs(200)
193+
.maxDelayMs(10000)
194+
.multiplier(2.0)
195+
.jitterFactor(0.5)
196+
.build();
197+
198+
OCCTransactionRunner runner = OCCTransactionRunner.create(ds, config);
199+
```
200+
201+
### Static API (for one-off use)
202+
203+
```java
204+
OCCRetry.execute(dataSource, OCCRetryConfig.defaults(), conn -> {
205+
// transaction work
206+
return result;
207+
});
208+
209+
// With an existing connection (rolled back between attempts, not closed)
210+
OCCRetry.execute(connection, OCCRetryConfig.defaults(), conn -> {
211+
// transaction work
212+
return result;
213+
});
214+
```
215+
216+
### Integration with Retry Frameworks
217+
218+
Use `OCCRetry.isOCCError(SQLException)` as the predicate for any retry framework:
219+
220+
**Spring Retry:**
221+
```java
222+
RetryTemplate template = RetryTemplate.builder()
223+
.maxAttempts(4)
224+
.exponentialBackoff(100, 2.0, 5000)
225+
.retryOn(SQLException.class)
226+
.traversingCauses()
227+
.build();
228+
229+
template.execute(ctx -> {
230+
try (Connection conn = ds.getConnection()) {
231+
conn.setAutoCommit(false);
232+
// ... transaction work ...
233+
conn.commit();
234+
return null;
235+
}
236+
});
237+
```
238+
239+
**Resilience4j:**
240+
```java
241+
RetryConfig retryConfig = RetryConfig.custom()
242+
.maxAttempts(4)
243+
.retryOnException(e -> e instanceof SQLException && OCCRetry.isOCCError((SQLException) e))
244+
.build();
245+
```
246+
247+
**Failsafe:**
248+
```java
249+
RetryPolicy<Object> policy = RetryPolicy.builder()
250+
.handleIf(e -> e instanceof SQLException && OCCRetry.isOCCError((SQLException) e))
251+
.withMaxAttempts(4)
252+
.withBackoff(Duration.ofMillis(100), Duration.ofSeconds(5))
253+
.build();
254+
```
255+
256+
**Plain loop:**
257+
```java
258+
SQLException lastErr = null;
259+
for (int i = 0; i <= maxRetries; i++) {
260+
try {
261+
doTransaction(ds);
262+
return;
263+
} catch (SQLException e) {
264+
if (!OCCRetry.isOCCError(e)) throw e;
265+
lastErr = e;
266+
Thread.sleep(backoff(i));
267+
}
268+
}
269+
throw lastErr;
270+
```
271+
163272
## Examples
164273

165274
| Description | Examples |

java/jdbc/examples/pgjdbc/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ The example demonstrates the following operations:
7373
- Opening a connection to an Aurora DSQL cluster
7474
- Creating a table
7575
- Inserting and querying data
76+
- Automatic OCC (Optimistic Concurrency Control) retry for write operations
7677

7778
The example is designed to work with both admin and non-admin users:
7879

java/jdbc/examples/pgjdbc/build.gradle.kts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,18 @@ application {
1515
group = "software.amazon.dsql.examples"
1616
version = "1.0-SNAPSHOT"
1717

18+
val connectorVersion: String = providers.environmentVariable("CONNECTOR_VERSION").getOrElse("1.4.0")
19+
1820
repositories {
21+
if (providers.environmentVariable("USE_MAVEN_LOCAL").isPresent) {
22+
mavenLocal()
23+
}
1924
mavenCentral()
2025
}
2126

2227
dependencies {
2328
implementation("com.zaxxer:HikariCP:7.0.2")
24-
implementation("software.amazon.dsql:aurora-dsql-jdbc-connector:1.4.0")
29+
implementation("software.amazon.dsql:aurora-dsql-jdbc-connector:$connectorVersion")
2530

2631
testImplementation(platform("org.junit:junit-bom:6.1.0"))
2732
testImplementation("org.junit.jupiter:junit-jupiter")

java/jdbc/examples/pgjdbc/src/main/java/software/amazon/dsql/examples/ExamplePreferred.java

Lines changed: 43 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import java.sql.SQLException;
1515
import java.sql.Statement;
1616

17+
import software.amazon.dsql.jdbc.OCCTransactionRunner;
18+
1719
/**
1820
* Aurora DSQL example using HikariCP with Aurora DSQL JDBC Connector
1921
* <p>
@@ -30,9 +32,11 @@
3032
public class ExamplePreferred {
3133

3234
private final HikariDataSource dataSource;
35+
private final OCCTransactionRunner transactionRunner;
3336

3437
public ExamplePreferred(String endpoint, String user) {
3538
this.dataSource = initializeConnectionPool(endpoint, user);
39+
this.transactionRunner = OCCTransactionRunner.create(dataSource);
3640
}
3741

3842
private HikariDataSource initializeConnectionPool(String endpoint, String username) {
@@ -82,30 +86,35 @@ public Connection getConnection() throws SQLException {
8286
return this.dataSource.getConnection();
8387
}
8488

85-
private void executeExample(Connection conn, int connectionNumber) throws SQLException {
89+
private void executeExample(int connectionNumber) throws SQLException {
8690
// Create a new table named owner
87-
try (Statement create = conn.createStatement()) {
88-
create.executeUpdate("""
89-
CREATE TABLE IF NOT EXISTS owner(
90-
id uuid NOT NULL DEFAULT gen_random_uuid(),
91-
name varchar(30) NOT NULL,
92-
city varchar(80) NOT NULL,
93-
telephone varchar(20) DEFAULT NULL,
94-
PRIMARY KEY (id))""");
95-
}
91+
transactionRunner.runVoid(conn -> {
92+
try (Statement create = conn.createStatement()) {
93+
create.executeUpdate("""
94+
CREATE TABLE IF NOT EXISTS owner(
95+
id uuid NOT NULL DEFAULT gen_random_uuid(),
96+
name varchar(30) NOT NULL,
97+
city varchar(80) NOT NULL,
98+
telephone varchar(20) DEFAULT NULL,
99+
PRIMARY KEY (id))""");
100+
}
101+
});
96102

97103
// Insert some data with a unique identifier
98104
String uniqueName = "John Doe " + System.currentTimeMillis() + "_" + connectionNumber;
99-
try (PreparedStatement insert = conn.prepareStatement(
100-
"INSERT INTO owner (name, city, telephone) VALUES (?, ?, ?)")) {
101-
insert.setString(1, uniqueName);
102-
insert.setString(2, "Anytown");
103-
insert.setString(3, "555-555-1991");
104-
insert.executeUpdate();
105-
}
105+
transactionRunner.runVoid(conn -> {
106+
try (PreparedStatement insert = conn.prepareStatement(
107+
"INSERT INTO owner (name, city, telephone) VALUES (?, ?, ?)")) {
108+
insert.setString(1, uniqueName);
109+
insert.setString(2, "Anytown");
110+
insert.setString(3, "555-555-1991");
111+
insert.executeUpdate();
112+
}
113+
});
106114

107115
// Read back the data and verify
108-
try (PreparedStatement read = conn.prepareStatement("SELECT * FROM owner WHERE name = ?")) {
116+
try (Connection conn = dataSource.getConnection();
117+
PreparedStatement read = conn.prepareStatement("SELECT * FROM owner WHERE name = ?")) {
109118
read.setString(1, uniqueName);
110119
try (ResultSet rs = read.executeQuery()) {
111120
while (rs.next()) {
@@ -136,30 +145,26 @@ public static void main(String[] args) throws SQLException {
136145

137146
try {
138147

139-
// Demonstrate connection pooling with multiple concurrent connections
140-
System.out.println("Testing connection pool with multiple connections...");
148+
// Demonstrate connection pooling with OCC retry
149+
System.out.println("Testing connection pool with OCC retry...");
141150

142-
try (Connection conn1 = example.getConnection();
143-
Connection conn2 = example.getConnection();
144-
Connection conn3 = example.getConnection()) {
151+
System.out.println("Connection 1 obtained from pool");
152+
example.executeExample(1);
145153

146-
System.out.println("Connection 1 obtained from pool");
147-
example.executeExample(conn1, 1);
154+
System.out.println("Connection 2 obtained from pool");
155+
example.executeExample(2);
148156

149-
System.out.println("Connection 2 obtained from pool");
150-
example.executeExample(conn2, 2);
157+
System.out.println("Connection 3 obtained from pool");
158+
example.executeExample(3);
151159

152-
System.out.println("Connection 3 obtained from pool");
153-
example.executeExample(conn3, 3);
154-
}
155-
156-
try (Connection conn = example.getConnection();
157-
PreparedStatement cleanup = conn.prepareStatement("DELETE FROM owner WHERE name LIKE ?")) {
158-
cleanup.setString(1, "%John Doe%");
159-
int deletedRows = cleanup.executeUpdate();
160-
161-
System.out.println("Cleaned up " + deletedRows + " test records");
162-
}
160+
// Cleanup
161+
example.transactionRunner.runVoid(conn -> {
162+
try (PreparedStatement cleanup = conn.prepareStatement("DELETE FROM owner WHERE name LIKE ?")) {
163+
cleanup.setString(1, "%John Doe%");
164+
int deletedRows = cleanup.executeUpdate();
165+
System.out.println("Cleaned up " + deletedRows + " test records");
166+
}
167+
});
163168

164169
} finally {
165170
// Graceful shutdown

java/jdbc/examples/pgjdbc/src/main/java/software/amazon/dsql/examples/alternatives/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@ The connector + pool combination handles this automatically:
1818

1919
### `no_connection_pool/`
2020
Examples without pooling:
21-
- `ExampleWithNoConnectionPool.java` - Single connection with connector
21+
- `ExampleWithNoConnectionPool.java` - Single connection with connector and OCC retry

java/jdbc/examples/pgjdbc/src/main/java/software/amazon/dsql/examples/alternatives/no_connection_pool/ExampleWithNoConnectionPool.java

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
import java.sql.Statement;
1515
import java.util.Properties;
1616

17+
import software.amazon.dsql.jdbc.OCCRetry;
18+
import software.amazon.dsql.jdbc.OCCRetryConfig;
19+
1720
public class ExampleWithNoConnectionPool {
1821

1922
// Get a connection to Aurora DSQL.
@@ -42,25 +45,35 @@ public static void main(String[] args) throws SQLException {
4245
setSchema.execute("SET search_path=myschema");
4346
}
4447
}
48+
49+
// Use OCC retry with existing connection for write operations
50+
OCCRetryConfig retryConfig = OCCRetryConfig.defaults();
51+
4552
// Create a new table named owner
46-
try (Statement create = conn.createStatement()) {
47-
create.executeUpdate("""
48-
CREATE TABLE IF NOT EXISTS owner(
49-
id uuid NOT NULL DEFAULT gen_random_uuid(),
50-
name varchar(30) NOT NULL,
51-
city varchar(80) NOT NULL,
52-
telephone varchar(20) DEFAULT NULL,
53-
PRIMARY KEY (id))""");
54-
}
53+
OCCRetry.execute(conn, retryConfig, c -> {
54+
try (Statement create = c.createStatement()) {
55+
create.executeUpdate("""
56+
CREATE TABLE IF NOT EXISTS owner(
57+
id uuid NOT NULL DEFAULT gen_random_uuid(),
58+
name varchar(30) NOT NULL,
59+
city varchar(80) NOT NULL,
60+
telephone varchar(20) DEFAULT NULL,
61+
PRIMARY KEY (id))""");
62+
}
63+
return null;
64+
});
5565

5666
// Insert some data
57-
try (PreparedStatement insert = conn.prepareStatement(
58-
"INSERT INTO owner (name, city, telephone) VALUES (?, ?, ?)")) {
59-
insert.setString(1, "John Doe");
60-
insert.setString(2, "Anytown");
61-
insert.setString(3, "555-555-1999");
62-
insert.executeUpdate();
63-
}
67+
OCCRetry.execute(conn, retryConfig, c -> {
68+
try (PreparedStatement insert = c.prepareStatement(
69+
"INSERT INTO owner (name, city, telephone) VALUES (?, ?, ?)")) {
70+
insert.setString(1, "John Doe");
71+
insert.setString(2, "Anytown");
72+
insert.setString(3, "555-555-1999");
73+
insert.executeUpdate();
74+
}
75+
return null;
76+
});
6477

6578
// Read back the data and assert they are present
6679
try (PreparedStatement read = conn.prepareStatement("SELECT * FROM owner WHERE name = ?")) {
@@ -76,10 +89,13 @@ telephone varchar(20) DEFAULT NULL,
7689
}
7790

7891
// Delete some data
79-
try (PreparedStatement delete = conn.prepareStatement("DELETE FROM owner WHERE name = ?")) {
80-
delete.setString(1, "John Doe");
81-
delete.executeUpdate();
82-
}
92+
OCCRetry.execute(conn, retryConfig, c -> {
93+
try (PreparedStatement delete = c.prepareStatement("DELETE FROM owner WHERE name = ?")) {
94+
delete.setString(1, "John Doe");
95+
delete.executeUpdate();
96+
}
97+
return null;
98+
});
8399
}
84100
System.out.println("Connection exercised successfully");
85101
}

0 commit comments

Comments
 (0)