diff --git a/deno/booking-api/postgres-js/README.md b/deno/booking-api/postgres-js/README.md index 1df55e019..cbbad2c9c 100644 --- a/deno/booking-api/postgres-js/README.md +++ b/deno/booking-api/postgres-js/README.md @@ -178,15 +178,16 @@ This sample uses a layered defense against double-booking: Blog post [Concurrency control in Amazon Aurora DSQL](https://aws.amazon.com/blogs/database/concurrency-control-in-amazon-aurora-dsql/) for details. -**Write-skew caveat.** Aurora DSQL provides strong snapshot isolation and -OCC only conflicts writes to the same physical rows. Two concurrent -transactions inserting *overlapping but distinct* windows (e.g., -`[9:00–10:00]` and `[9:30–10:30]`) may both pass the SELECT above and -both commit — the unique index catches only identical windows. This is -the classic *write skew* anomaly. For strict serialization of overlapping -writes, maintain a parent `resources` table and acquire -`SELECT ... FOR UPDATE` on the resource row (keyed by its primary key) -before the overlap check. See the AWS Database Blog post +**Write-skew caveat.** Aurora DSQL provides strong snapshot isolation. In this +overlap-check pattern, two concurrent transactions can insert *overlapping but +distinct* windows without conflicting when `SELECT ... FOR UPDATE` is not used. +For example, transactions for `[9:00–10:00]` and `[9:30–10:30]` may both pass +the SELECT above and both commit — the unique index catches only identical +windows. This is the classic *write skew* anomaly. For strict serialization of +overlapping writes, maintain a parent `resources` table and acquire +`SELECT ... FOR UPDATE` on the resource row (keyed by its primary key) before +the overlap check. Each targeted row's primary key counts toward the 10 MiB +transaction-size limit. See the AWS Database Blog post [Concurrency control in Amazon Aurora DSQL](https://aws.amazon.com/blogs/database/concurrency-control-in-amazon-aurora-dsql/) (Example 2: `SELECT FOR UPDATE` to manage write skew) and the user-guide page [Concurrency control in Aurora DSQL](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-concurrency-control.html) diff --git a/deno/booking-api/postgres-js/handlers.ts b/deno/booking-api/postgres-js/handlers.ts index c499f6cb2..1789b6b8f 100644 --- a/deno/booking-api/postgres-js/handlers.ts +++ b/deno/booking-api/postgres-js/handlers.ts @@ -291,7 +291,8 @@ async function createBooking( // Write-skew caveat: two concurrent transactions inserting // overlapping-but-distinct windows (e.g., 9:00–10:00 and // 9:30–10:30) may both pass this SELECT and both commit — - // DSQL's OCC only conflicts writes to the same physical rows, + // In this overlap-check pattern, writes to distinct booking rows + // do not conflict merely because their time ranges overlap, // and the unique index on (resource_name, start_time, end_time) // catches only identical windows. For strict serialization, // maintain a `resources` table and `SELECT ... FOR UPDATE` on diff --git a/deno/booking-api/postgres-js/occ-overlap-race.integration.test.ts b/deno/booking-api/postgres-js/occ-overlap-race.integration.test.ts index 5abdd7120..c80827d07 100644 --- a/deno/booking-api/postgres-js/occ-overlap-race.integration.test.ts +++ b/deno/booking-api/postgres-js/occ-overlap-race.integration.test.ts @@ -181,7 +181,7 @@ Deno.test({ // // This test codifies the behavior. For strict serialization of overlapping // writes, use either (a) a coarser grouping key enforced via unique index, -// or (b) an application-level row lock via `SELECT ... FOR UPDATE` on a +// or (b) a commit-time OCC conflict point via `SELECT ... FOR UPDATE` on a // parent row. See README "Concurrency model — what's serialized and what // isn't" for the recommended production patterns. // --------------------------------------------------------------------------- diff --git a/go/pgx/src/transaction/example.go b/go/pgx/src/transaction/example.go index e700e8144..d4fe92435 100644 --- a/go/pgx/src/transaction/example.go +++ b/go/pgx/src/transaction/example.go @@ -13,7 +13,7 @@ // - Using occretry for OCC conflict handling // // DSQL transaction limits: -// - Maximum 3,000 rows modified per transaction +// - Maximum 3,000 row modifications per transaction // - Maximum 10 MiB data size per transaction // - Maximum 5 minute transaction duration // diff --git a/sample-amazon-aurora-dsql-auth-session-mgmt/README.md b/sample-amazon-aurora-dsql-auth-session-mgmt/README.md index 3289a6444..35c0d8286 100644 --- a/sample-amazon-aurora-dsql-auth-session-mgmt/README.md +++ b/sample-amazon-aurora-dsql-auth-session-mgmt/README.md @@ -141,7 +141,7 @@ SELECT id, user_id, created_at, expires_at, revoked_at FROM sessions; - IAM-based database authentication (no static passwords) - UUIDs generated app-side - 1 DDL per transaction -- 3,000 row limit per DML transaction +- 3,000 row-modification limit per DML transaction ## Operational Notes diff --git a/typescript/sequelize/README.md b/typescript/sequelize/README.md index d1d7e9601..bc6c62f85 100644 --- a/typescript/sequelize/README.md +++ b/typescript/sequelize/README.md @@ -215,18 +215,29 @@ For the full list of Aurora DSQL SQL compatibility details, see the [PostgreSQL ### Locking -Aurora DSQL uses optimistic concurrency control (OCC), meaning transactions proceed without locks and conflicts are detected at commit time. The `SELECT FOR UPDATE` clause modifies this behavior by flagging read rows for concurrency checks, which is useful for managing write skew scenarios. +Aurora DSQL uses optimistic concurrency control (OCC). `SELECT ... FOR UPDATE` does not take a blocking row lock; rows targeted by the locking clause participate in commit-time conflict checks. A conflicting transaction fails at commit and the whole transaction must be retried. Each targeted row's primary key counts toward the 10 MiB transaction-size limit. -In Sequelize, only `Transaction.LOCK.UPDATE` is supported. The query must include an equality predicate on the primary key. Queries that lock by non-key columns will fail. +Sequelize `Transaction.LOCK.UPDATE` and `Transaction.LOCK.KEY_SHARE` are supported. `Transaction.LOCK.NO_KEY_UPDATE` and `Transaction.LOCK.SHARE` are not supported. Locking queries may use non-key predicates and may join multiple tables; there is no requirement to use equality predicates on every primary-key column or to query only one table. ```ts -// Works: lock by primary key -await Model.findByPk(id, { lock: Transaction.LOCK.UPDATE, transaction }); +// Non-key predicate +await Order.findOne({ + where: { tenantId, status: 'pending' }, + lock: Transaction.LOCK.UPDATE, + transaction, +}); -// Does not work: lock by non-key column -await Model.findOne({ where: { status: 'pending' }, lock: Transaction.LOCK.UPDATE, transaction }); +// Joined query +await Order.findOne({ + include: [{ model: Customer, required: true }], + where: { tenantId, status: 'pending' }, + lock: Transaction.LOCK.UPDATE, + transaction, +}); ``` +Retry the complete transaction with backoff when a commit conflict returns SQLSTATE `40001`. Keep external side effects outside the retried callback or make them idempotent. + For more details on concurrency control in Aurora DSQL, see [Concurrency control in Amazon Aurora DSQL](https://aws.amazon.com/blogs/database/concurrency-control-in-amazon-aurora-dsql/). ## Additional resources diff --git a/typescript/sequelize/src/index.ts b/typescript/sequelize/src/index.ts index 8f4928690..b6d976bcc 100644 --- a/typescript/sequelize/src/index.ts +++ b/typescript/sequelize/src/index.ts @@ -1,6 +1,6 @@ import { AuroraDSQLClient } from "@aws/aurora-dsql-node-postgres-connector"; import * as pg from 'pg'; -import { Sequelize, DataTypes, Model } from 'sequelize'; +import { Sequelize, DataTypes, Model, Transaction } from 'sequelize'; const ADMIN = "admin"; const NON_ADMIN_SCHEMA = "myschema"; @@ -239,31 +239,53 @@ async function sequelizeExample() { await sequelize.close(); } -async function executeSqlStatementWithRetry(instance: Sequelize, sqlStatement: string, maxRetries: number = 0): Promise { - let retries = 0; - while (retries <= maxRetries) { +type SequelizeError = Error & { + code?: string; + original?: { code?: string }; + parent?: { code?: string }; +}; + +function getSqlState(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null) { + return undefined; + } + + const sequelizeError = error as SequelizeError; + return sequelizeError.original?.code ?? sequelizeError.parent?.code ?? sequelizeError.code; +} + +export async function executeTransactionWithRetry( + instance: Sequelize, + operation: (transaction: Transaction) => Promise, + maxRetries: number = 3 +): Promise { + for (let attempt = 0; ; attempt += 1) { try { - const result = await instance.transaction(async (transaction) => { - return await instance.query(sqlStatement, { - transaction - }); - }); - return result; + return await instance.transaction(operation); } catch (error) { - const err = error as Error; - if (retries === maxRetries) { - throw new Error(`Maximum retries (${maxRetries}) reached. Last error: ${err.message}`); - } - if (err.message.includes('OC001') || err.message.includes('OC000')) { - console.log(`Error occurred when executing statement ${sqlStatement}, executing retry`); - retries += 1; - } else { - throw err; + if (getSqlState(error) !== '40001' || attempt >= maxRetries) { + throw error; } + + const backoffMs = Math.min(100 * 2 ** attempt, 2000) + Math.random() * 100; + console.log('Retrying transaction after SQLSTATE 40001'); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); } } } +async function executeSqlStatementWithRetry( + instance: Sequelize, + sqlStatement: string, + maxRetries: number = 3 +): Promise { + return executeTransactionWithRetry( + instance, + (transaction) => instance.query(sqlStatement, { transaction }), + maxRetries + ); +} + async function retryExample() { var sequelize: Sequelize = await getSequelizeConnection(); await sequelize.authenticate(); @@ -272,7 +294,7 @@ async function retryExample() { await executeSqlStatementWithRetry(sequelize, "CREATE TABLE IF NOT EXISTS abc (id UUID NOT NULL);") await executeSqlStatementWithRetry(sequelize, "DROP TABLE IF EXISTS abc;") - // Run statement that will fail, it will not be retried as the error is not OC001 or OC000 + // Run statement that will fail, it will not be retried as the error is not SQLSTATE 40001 try { await executeSqlStatementWithRetry(sequelize, "DROP TABLE abc;") } catch (err: any) {