Skip to content

Commit 467b961

Browse files
praba2210Prabakaran Annadurai
andauthored
feat(postgres-js): add OCC retry with exponential backoff (#495)
## Summary Added automatic retry with exponential backoff + jitter for OCC conflicts on begin() transactions, with constructor-level and per-call configuration. Fixed region parsing to gracefully fall back to `AWS_REGION` / `AWS_DEFAULT_REGION` env vars. ## Changes - `src/occ-retry.ts` — retry module (resolveRetryConfig, executeWithRetry, isOCCError, validateRetryConfig, calculateBackoff) - `src/client.ts` — wrapBegin() patches sql.begin() for both TCP and WebSocket clients; region parsing fix - `src/index.ts` — export isOCCError, OCCRetryConfig, Logger types - `test/occ-retry.test.ts` — unit tests for isOCCError, resolveRetryConfig, validation - `test/integration/dsql.integration.test.ts` — 3 OCC integration tests - `README.md` — OCC Retry documentation section + config table entries - `example/` — updated preferred and no-pool examples with retry usage ## Test plan - [x] Unit tests pass (46 tests — 2 suites) - [x] TypeScript compiles clean - [x] Integration tests pass against DSQL cluster (33 tests — constructor opt-in, per-call opt-in, non-OCC passthrough) *Issue #, if available:* N/A 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 53c8cbf commit 467b961

9 files changed

Lines changed: 442 additions & 8 deletions

File tree

node/postgres-js/README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ The Aurora DSQL Connector for Postgres.js is designed to understand these requir
4242
- **Full TypeScript Support** - Provides full type safety
4343
- **AWS Credentials Support** - Supports various AWS credential providers (default, profile-based, custom)
4444
- **Connection Pooling Compatibility** - Works seamlessly with Postgres.js' built-in connection pooling
45+
- **OCC Retry** - Automatic retry with exponential backoff on optimistic concurrency conflicts
4546

4647
## Quick start guide
4748

@@ -127,6 +128,50 @@ const sql = AuroraDSQLPostgres({
127128
});
128129
```
129130

131+
### OCC Retry
132+
133+
Aurora DSQL uses optimistic concurrency control (OCC). Transactions that conflict are rejected at
134+
commit time with error codes `OC000`, `OC001`, or `40001`. The connector can automatically retry
135+
`begin()` transactions on these conflicts with exponential backoff.
136+
137+
```typescript
138+
import { auroraDSQLPostgres } from '@aws/aurora-dsql-postgresjs-connector';
139+
140+
// Opt-in at connection level — all begin() calls will retry on OCC conflicts
141+
const sql = auroraDSQLPostgres({
142+
host: 'your-cluster.dsql.us-east-1.on.aws',
143+
username: 'admin',
144+
retry: true,
145+
});
146+
147+
// No per-call config needed
148+
await sql.begin(async (tx) => {
149+
await tx`INSERT INTO accounts(id, balance) VALUES(${1}, ${100})`;
150+
});
151+
```
152+
153+
The retry config accepts a partial object — unset fields use defaults:
154+
155+
| Field | Default | Description |
156+
| -------------- | ------- | ---------------------------------- |
157+
| `maxRetries` | 3 | Number of retry attempts after the initial try (0 = no retry) |
158+
| `baseDelayMs` | 1 | Initial backoff delay in ms |
159+
| `maxDelayMs` | 100 | Maximum base backoff delay in ms (jitter is added on top) |
160+
| `jitterFactor` | 0.25 | Jitter as a fraction of delay |
161+
162+
Per-call opt-in/out:
163+
164+
```typescript
165+
// Per-call opt-in (no constructor config needed)
166+
await sql.begin(async (tx) => { ... }, { retry: true });
167+
await sql.begin(async (tx) => { ... }, { retry: { maxRetries: 10 } });
168+
169+
// Per-call opt-out (overrides constructor config)
170+
await sql.begin(async (tx) => { ... }, { retry: false });
171+
```
172+
173+
**Note:** The callback may be invoked multiple times on OCC conflicts. Keep it idempotent - avoid non-database side effects (HTTP calls, message publishes, external counters) inside the transaction body.
174+
130175
### Configuration Options
131176

132177
| Option | Type | Required | Description |
@@ -137,6 +182,8 @@ const sql = AuroraDSQLPostgres({
137182
| `region` | `string?` | No | AWS region (auto-detected from hostname if not provided) |
138183
| `customCredentialsProvider` | `AwsCredentialIdentityProvider?` | No | Custom AWS credentials provider |
139184
| `tokenDurationSecs` | `number?` | No | Token expiration time in seconds |
185+
| `retry` | `Partial<OCCRetryConfig> \| boolean` | No | OCC retry configuration (see above) |
186+
| `logger` | `Logger` | No | Logger with `error` method and optional `debug` (e.g. `console`) |
140187

141188
All standard [Postgres.js options](https://github.com/porsager/postgres?tab=readme-ov-file#connection-details) are also supported.
142189

node/postgres-js/example/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ The example demonstrates the following operations:
5959

6060
- Opening a connection to an Aurora DSQL cluster
6161
- Creating a table
62+
- Transactional writes with automatic OCC retry
6263
- Inserting and querying data
6364

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

node/postgres-js/example/src/alternatives/no_connection_pool/example_with_no_connection_pool.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ function getConnection(clusterEndpoint, user) {
1414
return auroraDSQLPostgres({
1515
host: clusterEndpoint,
1616
user: user,
17+
retry: true,
1718
// Other DSQL options:
1819
// region: 'us-east-1',
1920
// profile: awsProfile,
@@ -46,9 +47,11 @@ async function example() {
4647
telephone VARCHAR(20)
4748
)`;
4849

49-
// Insert some data
50-
await client`INSERT INTO ${client(schema)}.owner(name, city, telephone)
51-
VALUES ('John Doe', 'Anytown', '555-555-0150')`;
50+
// Transactional write with OCC retry
51+
await client.begin(async (tx) => {
52+
await tx`INSERT INTO ${tx(schema)}.owner(name, city, telephone)
53+
VALUES ('John Doe', 'Anytown', '555-555-0150')`;
54+
});
5255

5356
// Check that data is inserted by reading it back
5457
const result = await client`SELECT id, city

node/postgres-js/example/src/example_preferred.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ function createPooledConnection(clusterEndpoint, user) {
1515
max: 10, // Connection pool size
1616
idle_timeout: 30, // Idle connection timeout in seconds
1717
connect_timeout: 10, // Connection timeout in seconds
18+
retry: true,
1819
});
1920
}
2021

@@ -43,6 +44,30 @@ async function example() {
4344
await Promise.all(workers);
4445

4546
console.log("Connection pool with concurrent connections exercised successfully");
47+
48+
// Create table
49+
await sql`CREATE TABLE IF NOT EXISTS owner (
50+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
51+
name VARCHAR(30) NOT NULL,
52+
city VARCHAR(80) NOT NULL,
53+
telephone VARCHAR(20)
54+
)`;
55+
56+
// Transactional write
57+
await sql.begin(async (tx) => {
58+
await tx`INSERT INTO owner(name, city, telephone) VALUES(${"John Doe"}, ${"Anytown"}, ${"555-555-1900"})`;
59+
});
60+
61+
// Verify the write
62+
const result = await sql`SELECT name, city FROM owner WHERE name = ${"John Doe"}`;
63+
assert.strictEqual(result[0].name, "John Doe");
64+
assert.strictEqual(result[0].city, "Anytown");
65+
console.log(`Inserted: name=${result[0].name}, city=${result[0].city}`);
66+
67+
// Clean up
68+
await sql`DELETE FROM owner WHERE name = ${"John Doe"}`;
69+
70+
console.log("Transactional write completed successfully");
4671
} catch (error) {
4772
console.error(error);
4873
throw error;

node/postgres-js/src/client.ts

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "@aws-sdk/types";
1010
import { DsqlSigner, DsqlSignerConfig } from "@aws-sdk/dsql-signer";
1111
import { createPostgresWs } from "./postgres-web-socket";
12+
import { Logger, OCCRetryConfig, resolveRetryConfig, executeWithRetry } from "./occ-retry";
1213

1314
// Version is injected at build time via tsdown
1415
declare const __VERSION__: string;
@@ -94,6 +95,45 @@ function setupDsqlSigner(opts: any): { signerConfig: DsqlSignerConfig; } {
9495
return { signerConfig };
9596
}
9697

98+
function wrapBegin(sql: postgres.Sql<any>, opts: { retry?: Partial<OCCRetryConfig> | boolean; logger?: Logger }): void {
99+
if (!sql.begin) return;
100+
const { retry: constructorRetry, logger } = opts;
101+
if (constructorRetry && constructorRetry !== true) {
102+
resolveRetryConfig(constructorRetry, undefined);
103+
}
104+
const originalBegin = sql.begin.bind(sql);
105+
// postgres-js does not call this.begin internally, so direct mutation is safe.
106+
(sql as any).begin = (first: any, second?: any, third?: any) => {
107+
let beginOpts: string | undefined;
108+
let fn: (sql: any) => any;
109+
let callOpts: { retry?: Partial<OCCRetryConfig> | boolean } | undefined;
110+
111+
if (typeof first === "string") {
112+
beginOpts = first;
113+
fn = second;
114+
callOpts = third;
115+
} else {
116+
fn = first;
117+
callOpts = second;
118+
}
119+
120+
if (typeof fn !== "function") {
121+
throw new TypeError("sql.begin: callback function is required");
122+
}
123+
124+
const config = resolveRetryConfig(constructorRetry, callOpts?.retry);
125+
if (!config) {
126+
return beginOpts ? originalBegin(beginOpts, fn) : originalBegin(fn);
127+
}
128+
129+
return executeWithRetry(
130+
() => beginOpts ? originalBegin(beginOpts, fn) : originalBegin(fn),
131+
config,
132+
logger,
133+
);
134+
};
135+
}
136+
97137
export function auroraDSQLPostgres<
98138
T extends Record<string, postgres.PostgresType> = {}
99139
>(
@@ -156,9 +196,12 @@ export function auroraDSQLPostgres<
156196
...opts,
157197
pass: () => getToken(signer, opts.username),
158198
};
159-
return typeof urlOrOptions === "string"
199+
const sql = typeof urlOrOptions === "string"
160200
? postgres(urlOrOptions, postgresOpts)
161201
: postgres(postgresOpts);
202+
203+
wrapBegin(sql, { retry: opts.retry, logger: opts.logger });
204+
return sql;
162205
}
163206

164207
export function auroraDSQLWsPostgres<
@@ -238,9 +281,13 @@ export function auroraDSQLWsPostgres<
238281

239282
opts.port = 443;
240283

241-
return typeof urlOrOptions === "string"
242-
? postgres(urlOrOptions, opts)
243-
: postgres(opts);
284+
const postgresOpts: postgres.Options<T> = { ...opts };
285+
const sql = typeof urlOrOptions === "string"
286+
? postgres(urlOrOptions, postgresOpts)
287+
: postgres(postgresOpts);
288+
289+
wrapBegin(sql, { retry: opts.retry, logger: opts.logger });
290+
return sql;
244291
}
245292

246293

@@ -337,6 +384,10 @@ export interface AuroraDSQLConfig<T extends Record<string, PostgresType<T>>> ext
337384

338385
customCredentialsProvider?: AwsCredentialIdentity | AwsCredentialIdentityProvider;
339386

387+
retry?: Partial<OCCRetryConfig> | boolean;
388+
389+
logger?: Logger;
390+
340391
}
341392
export interface AuroraDSQLWsConfig<T extends Record<string, PostgresType<T>>>
342393
extends Omit<postgres.Options<T>, "socket" | "ssl" | "port"> {
@@ -348,4 +399,6 @@ export interface AuroraDSQLWsConfig<T extends Record<string, PostgresType<T>>>
348399
connectionCheck?: boolean;
349400
connectionId?: string;
350401
onReservedConnectionClose?: (connectionId?: string) => void;
402+
retry?: Partial<OCCRetryConfig> | boolean;
403+
logger?: Logger;
351404
}

node/postgres-js/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55
export { auroraDSQLPostgres, auroraDSQLWsPostgres } from './client.js';
6-
export type { AuroraDSQLConfig, AuroraDSQLWsConfig } from './client.js';
6+
export { isOCCError } from './occ-retry.js';
7+
export type { AuroraDSQLConfig, AuroraDSQLWsConfig } from './client.js';
8+
export type { OCCRetryConfig, Logger } from './occ-retry.js';

node/postgres-js/src/occ-retry.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
interface Logger {
7+
debug?(msg: string, ...args: unknown[]): void;
8+
error(msg: string, ...args: unknown[]): void;
9+
}
10+
11+
interface OCCRetryConfig {
12+
maxRetries: number;
13+
baseDelayMs: number;
14+
maxDelayMs: number;
15+
jitterFactor: number;
16+
}
17+
18+
const DEFAULT_CONFIG: OCCRetryConfig = {
19+
maxRetries: 3,
20+
baseDelayMs: 1,
21+
maxDelayMs: 100,
22+
jitterFactor: 0.25,
23+
};
24+
25+
function isOCCError(error: unknown): boolean {
26+
if (!error || typeof error !== "object") {
27+
return false;
28+
}
29+
30+
const dbError = error as { code?: string };
31+
if (!dbError.code) {
32+
return false;
33+
}
34+
35+
return dbError.code === "OC000" || dbError.code === "OC001" || dbError.code === "40001";
36+
}
37+
38+
function validateRetryConfig(config: OCCRetryConfig): void {
39+
for (const [k, v] of Object.entries(config)) {
40+
if (typeof v !== "number" || !Number.isFinite(v)) {
41+
throw new Error(`${k} must be a finite number`);
42+
}
43+
}
44+
if (!Number.isInteger(config.maxRetries)) {
45+
throw new Error("maxRetries must be an integer");
46+
}
47+
if (config.maxRetries < 0) {
48+
throw new Error("maxRetries must be >= 0");
49+
}
50+
if (config.maxRetries > 100) {
51+
throw new Error("maxRetries must not exceed 100");
52+
}
53+
if (config.baseDelayMs <= 0) {
54+
throw new Error("baseDelayMs must be greater than 0");
55+
}
56+
if (config.maxDelayMs < config.baseDelayMs) {
57+
throw new Error("maxDelayMs must be >= baseDelayMs");
58+
}
59+
if (config.jitterFactor < 0 || config.jitterFactor > 1) {
60+
throw new Error("jitterFactor must be between 0.0 and 1.0");
61+
}
62+
}
63+
64+
// Returns null when retry is not opted-in (no config from either source).
65+
function resolveRetryConfig(
66+
constructorRetry: Partial<OCCRetryConfig> | boolean | undefined,
67+
perCallRetry: Partial<OCCRetryConfig> | boolean | undefined,
68+
): OCCRetryConfig | null {
69+
if (perCallRetry === false) return null;
70+
if (perCallRetry === undefined && (constructorRetry === undefined || constructorRetry === false)) return null;
71+
72+
const base = (constructorRetry === true || constructorRetry === undefined || constructorRetry === false)
73+
? {}
74+
: constructorRetry;
75+
const override = (perCallRetry === true || perCallRetry === undefined)
76+
? {}
77+
: perCallRetry;
78+
79+
const resolved: OCCRetryConfig = { ...DEFAULT_CONFIG, ...base, ...override };
80+
validateRetryConfig(resolved);
81+
return resolved;
82+
}
83+
84+
function calculateBackoff(config: OCCRetryConfig, attempt: number): number {
85+
const exponent = Math.min(attempt - 1, 31);
86+
const delay = Math.min(config.baseDelayMs * Math.pow(2.0, exponent), config.maxDelayMs);
87+
const jitter = delay * Math.random() * config.jitterFactor;
88+
return delay + jitter;
89+
}
90+
91+
async function executeWithRetry<T>(
92+
executeCallback: () => Promise<T>,
93+
config: OCCRetryConfig,
94+
logger?: Logger,
95+
): Promise<T> {
96+
if (config.maxRetries <= 0) {
97+
return executeCallback();
98+
}
99+
100+
let lastError: Error | undefined;
101+
102+
for (let attempt = 1; attempt <= config.maxRetries + 1; attempt++) {
103+
try {
104+
return await executeCallback();
105+
} catch (error) {
106+
if (!isOCCError(error)) {
107+
throw error;
108+
}
109+
110+
lastError = error as Error;
111+
112+
if (attempt <= config.maxRetries) {
113+
const delay = calculateBackoff(config, attempt);
114+
logger?.debug?.(
115+
`OCC conflict on attempt ${attempt}/${config.maxRetries + 1}, retrying in ${delay.toFixed(1)}ms`,
116+
);
117+
118+
await new Promise<void>((resolve) => setTimeout(resolve, delay));
119+
}
120+
}
121+
}
122+
123+
logger?.error(`OCC retry exhausted after ${config.maxRetries + 1} attempts`);
124+
throw lastError!;
125+
}
126+
127+
export type { Logger, OCCRetryConfig };
128+
export { resolveRetryConfig, executeWithRetry, isOCCError };

0 commit comments

Comments
 (0)