From 4b75c375dfd9b041d56d1a9d6bcabb0671e90bbf Mon Sep 17 00:00:00 2001 From: Pascal Jufer Date: Thu, 25 Apr 2024 14:19:17 +0200 Subject: [PATCH] Client-side restart of failed transactions on CockroachDB (#22240) --- .changeset/bright-mirrors-rush.md | 5 ++++ api/src/utils/transaction.ts | 47 +++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 .changeset/bright-mirrors-rush.md diff --git a/.changeset/bright-mirrors-rush.md b/.changeset/bright-mirrors-rush.md new file mode 100644 index 0000000000..3b48da331c --- /dev/null +++ b/.changeset/bright-mirrors-rush.md @@ -0,0 +1,5 @@ +--- +'@directus/api': patch +--- + +Implemented client-side restart of failed transactions for CockroachDB diff --git a/api/src/utils/transaction.ts b/api/src/utils/transaction.ts index 61e0783996..0b3c9930f9 100644 --- a/api/src/utils/transaction.ts +++ b/api/src/utils/transaction.ts @@ -1,4 +1,6 @@ -import type { Knex } from 'knex'; +import { type Knex } from 'knex'; +import { getDatabaseClient } from '../database/index.js'; +import { useLogger } from '../logger.js'; /** * Execute the given handler within the current transaction or a newly created one @@ -7,10 +9,49 @@ import type { Knex } from 'knex'; * Can be used to ensure the handler is run within a transaction, * while preventing nested transactions. */ -export const transaction = (knex: Knex, handler: (knex: Knex) => Promise): Promise => { +export const transaction = async (knex: Knex, handler: (knex: Knex) => Promise): Promise => { if (knex.isTransaction) { return handler(knex); } else { - return knex.transaction((trx) => handler(trx)); + try { + return await knex.transaction((trx) => handler(trx)); + } catch (error: any) { + const client = getDatabaseClient(knex); + + /** + * This error code indicates that the transaction failed due to another + * concurrent or recent transaction attempting to write to the same data. + * This can usually be solved by restarting the transaction on client-side + * after a short delay, so that it is executed against the latest state. + * + * @link https://www.cockroachlabs.com/docs/stable/transaction-retry-error-reference + */ + const COCKROACH_RETRY_ERROR_CODE = '40001'; + + if (client !== 'cockroachdb' || error?.code !== COCKROACH_RETRY_ERROR_CODE) throw error; + + const MAX_ATTEMPTS = 3; + const BASE_DELAY = 100; + + const logger = useLogger(); + + for (let attempt = 0; attempt < MAX_ATTEMPTS; ++attempt) { + const delay = 2 ** attempt * BASE_DELAY; + + await new Promise((resolve) => setTimeout(resolve, delay)); + + logger.trace(`Restarting failed transaction (attempt ${attempt + 1}/${MAX_ATTEMPTS})`); + + try { + return await knex.transaction((trx) => handler(trx)); + } catch (error: any) { + if (error?.code !== COCKROACH_RETRY_ERROR_CODE) throw error; + } + } + + /** Initial execution + additional attempts */ + const attempts = 1 + MAX_ATTEMPTS; + throw new Error(`Transaction failed after ${attempts} attempts`, { cause: error }); + } } };