Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change query retry handling #308

Merged
merged 1 commit into from
Nov 7, 2021
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 8 additions & 2 deletions .README/QUERY_METHODS.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,12 @@ ROLLBACK;

Transactions that are failing with [Transaction Rollback](https://www.postgresql.org/docs/current/errcodes-appendix.html) class errors are automatically retried.

A failing transaction will be rolled back and all queries up to the failing query will be replayed.
A failing transaction will be rolled back and the callback function passed to the transaction method call will be executed again. Nested transactions are also retried until the retry limit is reached. If the nested transaction keeps failing with a [Transaction Rollback](https://www.postgresql.org/docs/current/errcodes-appendix.html) error, then the parent transaction will be retried until the retry limit is reached.

How many times a transaction is retried is controlled using `transactionRetryLimit` configuration (default: 5).
How many times a transaction is retried is controlled using `transactionRetryLimit` configuration (default: 5) and the `transactionRetryLimit` parameter of the `transaction` method (default: undefined). If a `transactionRetryLimit` is given to the method call then it is used otherwise the `transactionRetryLimit` configuration is used.

#### Query retrying

A single query (not part of a transaction) failing with a [Transaction Rollback](https://www.postgresql.org/docs/current/errcodes-appendix.html) class error is automatically retried.

How many times it is retried is controlled by using the `queryRetryLimit` configuration (default: 5).
1 change: 1 addition & 0 deletions .README/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Note: Using this project does not require TypeScript. It is a regular ES6 module
* [Safe value interpolation](#protecting-against-unsafe-value-interpolation).
* [Transaction nesting](#transaction-nesting).
* [Transaction retrying](#transaction-retrying)
* [Query retrying](#query-retrying)
* Detailed [logging](#slonik-debugging).
* [Asynchronous stack trace resolution](#capture-stack-trace).
* [Middlewares](#slonik-interceptors).
Expand Down
2 changes: 2 additions & 0 deletions .README/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ createPool(
* @property pgClient Override the underlying PostgreSQL client.
* @property statementTimeout Timeout (in milliseconds) after which database is instructed to abort the query. Use 'DISABLE_TIMEOUT' constant to disable the timeout. (Default: 60000)
* @property transactionRetryLimit Number of times a transaction failing with Transaction Rollback class error is retried. (Default: 5)
* @property queryRetryLimit Number of times a query failing with Transaction Rollback class error, that doesn't belong to a transaction, is retried. (Default: 5)
* @property typeParsers An array of [Slonik type parsers](https://github.com/gajus/slonik#slonik-type-parsers).
*/
type ClientConfigurationInputType = {
Expand All @@ -158,6 +159,7 @@ type ClientConfigurationInputType = {
pgClient?: PgClientType,
statementTimeout?: number | 'DISABLE_TIMEOUT',
transactionRetryLimit?: number,
queryRetryLimit?: number,
typeParsers?: TypeParserType[],
};

Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Note: Using this project does not require TypeScript. It is a regular ES6 module
* [Safe value interpolation](#protecting-against-unsafe-value-interpolation).
* [Transaction nesting](#transaction-nesting).
* [Transaction retrying](#transaction-retrying)
* [Query retrying](#query-retrying)
* Detailed [logging](#slonik-debugging).
* [Asynchronous stack trace resolution](#capture-stack-trace).
* [Middlewares](#slonik-interceptors).
Expand Down Expand Up @@ -546,6 +547,7 @@ createPool(
* @property pgClient Override the underlying PostgreSQL client.
* @property statementTimeout Timeout (in milliseconds) after which database is instructed to abort the query. Use 'DISABLE_TIMEOUT' constant to disable the timeout. (Default: 60000)
* @property transactionRetryLimit Number of times a transaction failing with Transaction Rollback class error is retried. (Default: 5)
* @property queryRetryLimit Number of times a query failing with Transaction Rollback class error, that doesn't belong to a transaction, is retried. (Default: 5)
* @property typeParsers An array of [Slonik type parsers](https://github.com/gajus/slonik#slonik-type-parsers).
*/
type ClientConfigurationInputType = {
Expand All @@ -559,6 +561,7 @@ type ClientConfigurationInputType = {
pgClient?: PgClientType,
statementTimeout?: number | 'DISABLE_TIMEOUT',
transactionRetryLimit?: number,
queryRetryLimit?: number,
typeParsers?: TypeParserType[],
};

Expand Down Expand Up @@ -1992,9 +1995,16 @@ ROLLBACK;

Transactions that are failing with [Transaction Rollback](https://www.postgresql.org/docs/current/errcodes-appendix.html) class errors are automatically retried.

A failing transaction will be rolled back and all queries up to the failing query will be replayed.
A failing transaction will be rolled back and the callback function passed to the transaction method call will be executed again. Nested transactions are also retried until the retry limit is reached. If the nested transaction keeps failing with a [Transaction Rollback](https://www.postgresql.org/docs/current/errcodes-appendix.html) error, then the parent transaction will be retried until the retry limit is reached.

How many times a transaction is retried is controlled using `transactionRetryLimit` configuration (default: 5).
How many times a transaction is retried is controlled using `transactionRetryLimit` configuration (default: 5) and the `transactionRetryLimit` parameter of the `transaction` method (default: undefined). If a `transactionRetryLimit` is given to the method call then it is used otherwise the `transactionRetryLimit` configuration is used.

<a name="slonik-query-methods-transaction-query-retrying"></a>
#### Query retrying

A single query (not part of a transaction) failing with a [Transaction Rollback](https://www.postgresql.org/docs/current/errcodes-appendix.html) class error is automatically retried.

How many times it is retried is controlled by using the `queryRetryLimit` configuration (default: 5).


<a name="slonik-error-handling"></a>
Expand Down
4 changes: 2 additions & 2 deletions src/binders/bindPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,14 @@ export const bindPool = (
streamQuery,
);
},
transaction: async (transactionHandler) => {
transaction: async (transactionHandler, transactionRetryLimit) => {
return createConnection(
parentLog,
pool,
clientConfiguration,
'IMPLICIT_TRANSACTION',
(connectionLog, connection) => {
return transaction(connectionLog, connection, clientConfiguration, transactionHandler);
return transaction(connectionLog, connection, clientConfiguration, transactionHandler, transactionRetryLimit);
},
(newPool) => {
return newPool.transaction(transactionHandler);
Expand Down
3 changes: 2 additions & 1 deletion src/binders/bindPoolConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,13 @@ export const bindPoolConnection = (
streamHandler,
);
},
transaction: (handler) => {
transaction: (handler, transactionRetryLimit) => {
return transaction(
parentLog,
connection,
clientConfiguration,
handler,
transactionRetryLimit,
);
},
};
Expand Down
3 changes: 2 additions & 1 deletion src/binders/bindTransactionConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export const bindTransactionConnection = (
streamHandler,
);
},
transaction: (handler) => {
transaction: (handler, transactionRetryLimit) => {
assertTransactionDepth();

return nestedTransaction(
Expand All @@ -177,6 +177,7 @@ export const bindTransactionConnection = (
clientConfiguration,
handler,
transactionDepth,
transactionRetryLimit,
);
},
};
Expand Down
93 changes: 82 additions & 11 deletions src/connectionMethods/nestedTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,111 @@ import {
import {
bindTransactionConnection,
} from '../binders';
import {
TRANSACTION_ROLLBACK_ERROR_PREFIX,
} from '../constants';
import type {
InternalNestedTransactionFunctionType,
} from '../types';
import {
createUid,
} from '../utilities';

export const nestedTransaction: InternalNestedTransactionFunctionType = async (parentLog, connection, clientConfiguration, handler, transactionDepth) => {
const newTransactionDepth = transactionDepth + 1;

const execNestedTransaction: InternalNestedTransactionFunctionType = async (parentLog, connection, clientConfiguration, handler, newTransactionDepth) => {
if (connection.connection.slonik.mock === false) {
await connection.query('SAVEPOINT slonik_savepoint_' + String(newTransactionDepth));
}

const log = parentLog.child({
transactionId: createUid(),
});

try {
connection.connection.slonik.transactionDepth = newTransactionDepth;

const result = await handler(bindTransactionConnection(log, connection, clientConfiguration, newTransactionDepth));
const result = await handler(bindTransactionConnection(
parentLog,
connection,
clientConfiguration,
newTransactionDepth,
));

return result;
} catch (error) {
if (connection.connection.slonik.mock === false) {
await connection.query('ROLLBACK TO SAVEPOINT slonik_savepoint_' + String(newTransactionDepth));
}

log.error({
parentLog.error({
error: serializeError(error),
}, 'rolling back transaction due to an error');

throw error;
}
};

type Awaited<T> = T extends PromiseLike<infer U> ? U : T;

const retryNestedTransaction: InternalNestedTransactionFunctionType = async (
parentLog,
connection,
clientConfiguration,
handler,
transactionDepth,
transactionRetryLimit,
) => {
let remainingRetries = transactionRetryLimit ?? clientConfiguration.transactionRetryLimit;
let attempt = 0;
let result: Awaited<ReturnType<typeof handler>>;

while (remainingRetries-- > 0) {
attempt++;

try {
parentLog.trace({
attempt,
parentTransactionId: connection.connection.slonik.transactionId,
}, 'retrying nested transaction');

result = await execNestedTransaction(parentLog, connection, clientConfiguration, handler, transactionDepth);

// If the attempt succeeded break out of the loop
break;
} catch (error) {
if (typeof error.code === 'string' && error.code.startsWith(TRANSACTION_ROLLBACK_ERROR_PREFIX) && remainingRetries > 0) {
continue;
}

throw error;
}
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return result!;
};

export const nestedTransaction: InternalNestedTransactionFunctionType = async (
parentLog,
connection,
clientConfiguration,
handler,
transactionDepth,
transactionRetryLimit,
) => {
const newTransactionDepth = transactionDepth + 1;

const log = parentLog.child({
transactionId: createUid(),
});

try {
connection.connection.slonik.transactionDepth = newTransactionDepth;

return await execNestedTransaction(log, connection, clientConfiguration, handler, newTransactionDepth);
} catch (error) {
const transactionRetryLimitToUse = transactionRetryLimit ?? clientConfiguration.transactionRetryLimit;

const shouldRetry = typeof error.code === 'string' && error.code.startsWith(TRANSACTION_ROLLBACK_ERROR_PREFIX) && transactionRetryLimitToUse > 0;

if (shouldRetry) {
return await retryNestedTransaction(parentLog, connection, clientConfiguration, handler, newTransactionDepth, transactionRetryLimit);
} else {
throw error;
}
} finally {
connection.connection.slonik.transactionDepth = newTransactionDepth - 1;
}
Expand Down
82 changes: 66 additions & 16 deletions src/connectionMethods/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import {
import {
bindTransactionConnection,
} from '../binders';
import {
TRANSACTION_ROLLBACK_ERROR_PREFIX,
} from '../constants';
import {
BackendTerminatedError,
UnexpectedStateError,
Expand All @@ -15,26 +18,14 @@ import {
createUid,
} from '../utilities';

export const transaction: InternalTransactionFunctionType = async (parentLog, connection, clientConfiguration, handler) => {
if (connection.connection.slonik.transactionDepth !== null) {
throw new UnexpectedStateError('Cannot use the same connection to start a new transaction before completing the last transaction.');
}

connection.connection.slonik.transactionDepth = 0;
connection.connection.slonik.transactionId = createUid();
connection.connection.slonik.transactionQueries = [];

const execTransaction: InternalTransactionFunctionType = async (parentLog, connection, clientConfiguration, handler) => {
if (connection.connection.slonik.mock === false) {
await connection.query('START TRANSACTION');
}

const log = parentLog.child({
transactionId: connection.connection.slonik.transactionId,
});

try {
const result = await handler(bindTransactionConnection(
log,
parentLog,
connection,
clientConfiguration,
connection.connection.slonik.transactionDepth,
Expand All @@ -55,15 +46,74 @@ export const transaction: InternalTransactionFunctionType = async (parentLog, co
await connection.query('ROLLBACK');
}

log.error({
parentLog.error({
error: serializeError(error),
}, 'rolling back transaction due to an error');
}

throw error;
}
};

type Awaited<T> = T extends PromiseLike<infer U> ? U : T;

const retryTransaction: InternalTransactionFunctionType = async (parentLog, connection, clientConfiguration, handler, transactionRetryLimit) => {
let remainingRetries = transactionRetryLimit ?? clientConfiguration.transactionRetryLimit;
let attempt = 0;
let result: Awaited<ReturnType<typeof handler>>;

while (remainingRetries-- > 0) {
attempt++;

try {
parentLog.trace({
attempt,
transactionId: connection.connection.slonik.transactionId,
}, 'retrying transaction');

result = await execTransaction(parentLog, connection, clientConfiguration, handler);

// If the attempt succeeded break out of the loop
break;
} catch (error) {
if (typeof error.code === 'string' && error.code.startsWith(TRANSACTION_ROLLBACK_ERROR_PREFIX) && remainingRetries > 0) {
continue;
}

throw error;
}
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return result!;
};

export const transaction: InternalTransactionFunctionType = async (parentLog, connection, clientConfiguration, handler, transactionRetryLimit) => {
if (connection.connection.slonik.transactionDepth !== null) {
throw new UnexpectedStateError('Cannot use the same connection to start a new transaction before completing the last transaction.');
}

connection.connection.slonik.transactionDepth = 0;
connection.connection.slonik.transactionId = createUid();

const log = parentLog.child({
transactionId: connection.connection.slonik.transactionId,
});

try {
return await execTransaction(log, connection, clientConfiguration, handler);
} catch (error) {
const transactionRetryLimitToUse = transactionRetryLimit ?? clientConfiguration.transactionRetryLimit;

const shouldRetry = typeof error.code === 'string' && error.code.startsWith(TRANSACTION_ROLLBACK_ERROR_PREFIX) && transactionRetryLimitToUse > 0;

if (shouldRetry) {
return await retryTransaction(log, connection, clientConfiguration, handler, transactionRetryLimit);
} else {
throw error;
}
} finally {
connection.connection.slonik.transactionDepth = null;
connection.connection.slonik.transactionId = null;
connection.connection.slonik.transactionQueries = null;
}
};
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// @see https://www.postgresql.org/docs/current/errcodes-appendix.html
export const TRANSACTION_ROLLBACK_ERROR_PREFIX = '40';
1 change: 1 addition & 0 deletions src/factories/createClientConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const createClientConfiguration = (clientUserConfigurationInput?: ClientC
idleTimeout: 5000,
interceptors: [],
maximumPoolSize: 10,
queryRetryLimit: 5,
statementTimeout: 60000,
transactionRetryLimit: 5,
typeParsers,
Expand Down