Skip to content

Commit

Permalink
feat(core): Allow specifying transaction isolation level (#2116)
Browse files Browse the repository at this point in the history
  • Loading branch information
domsj authored Apr 19, 2023
1 parent 636929f commit bf2b1f5
Show file tree
Hide file tree
Showing 4 changed files with 38 additions and 17 deletions.
16 changes: 15 additions & 1 deletion packages/core/src/api/decorators/transaction.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ export const TRANSACTION_MODE_METADATA_KEY = '__transaction_mode__';
*/
export type TransactionMode = 'auto' | 'manual';

export const TRANSACTION_ISOLATION_LEVEL_METADATA_KEY = '__transaction_isolation_level__';
/**
* @description
* Transactions can be run at different isolation levels. The default is undefined, which
* falls back to the default of your database. See the documentation of your database for more
* information on available isolation levels.
*
* @default undefined
* @docsCategory request
* @docsPage Transaction Decorator
*/
export type TransactionIsolationLevel = 'READ UNCOMMITTED' | 'READ COMMITTED' | 'REPEATABLE READ' | 'SERIALIZABLE';

/**
* @description
* Runs the decorated method in a TypeORM transaction. It works by creating a transactional
Expand Down Expand Up @@ -61,9 +74,10 @@ export type TransactionMode = 'auto' | 'manual';
* @docsPage Transaction Decorator
* @docsWeight 0
*/
export const Transaction = (transactionMode: TransactionMode = 'auto') => {
export const Transaction = (transactionMode: TransactionMode = 'auto', transactionIsolationLevel?: TransactionIsolationLevel) => {
return applyDecorators(
SetMetadata(TRANSACTION_MODE_METADATA_KEY, transactionMode),
SetMetadata(TRANSACTION_ISOLATION_LEVEL_METADATA_KEY, transactionIsolationLevel),
UseInterceptors(TransactionInterceptor),
);
};
11 changes: 8 additions & 3 deletions packages/core/src/api/middleware/transaction-interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { REQUEST_CONTEXT_KEY, REQUEST_CONTEXT_MAP_KEY } from '../../common/const
import { TransactionWrapper } from '../../connection/transaction-wrapper';
import { TransactionalConnection } from '../../connection/transactional-connection';
import { parseContext } from '../common/parse-context';
import { TransactionMode, TRANSACTION_MODE_METADATA_KEY } from '../decorators/transaction.decorator';
import { TransactionMode, TRANSACTION_MODE_METADATA_KEY, TransactionIsolationLevel, TRANSACTION_ISOLATION_LEVEL_METADATA_KEY } from '../decorators/transaction.decorator';

/**
* @description
Expand All @@ -20,7 +20,7 @@ export class TransactionInterceptor implements NestInterceptor {
private connection: TransactionalConnection,
private transactionWrapper: TransactionWrapper,
private reflector: Reflector,
) {}
) { }

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const { isGraphQL, req } = parseContext(context);
Expand All @@ -31,7 +31,11 @@ export class TransactionInterceptor implements NestInterceptor {
TRANSACTION_MODE_METADATA_KEY,
context.getHandler(),
);

const transactionIsolationLevel = this.reflector.get<TransactionIsolationLevel | undefined>(
TRANSACTION_ISOLATION_LEVEL_METADATA_KEY,
context.getHandler(),
);

return of(
this.transactionWrapper.executeInTransaction(
ctx,
Expand All @@ -41,6 +45,7 @@ export class TransactionInterceptor implements NestInterceptor {
return next.handle()
},
transactionMode,
transactionIsolationLevel,
this.connection.rawConnection,
)
);
Expand Down
13 changes: 7 additions & 6 deletions packages/core/src/connection/transaction-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Connection, EntityManager, QueryRunner } from 'typeorm';
import { TransactionAlreadyStartedError } from 'typeorm/error/TransactionAlreadyStartedError';

import { RequestContext } from '../api/common/request-context';
import { TransactionMode } from '../api/decorators/transaction.decorator';
import { TransactionIsolationLevel, TransactionMode } from '../api/decorators/transaction.decorator';
import { TRANSACTION_MANAGER_KEY } from '../common/constants';

/**
Expand All @@ -27,16 +27,17 @@ export class TransactionWrapper {
originalCtx: RequestContext,
work: (ctx: RequestContext) => Observable<T> | Promise<T>,
mode: TransactionMode,
isolationLevel: TransactionIsolationLevel | undefined,
connection: Connection,
): Promise<T> {
// Copy to make sure original context will remain valid after transaction completes
const ctx = originalCtx.copy();

const entityManager: EntityManager | undefined = (ctx as any)[TRANSACTION_MANAGER_KEY];
const queryRunner = entityManager?.queryRunner || connection.createQueryRunner();
const queryRunner = entityManager ?.queryRunner || connection.createQueryRunner();

if (mode === 'auto') {
await this.startTransaction(queryRunner);
await this.startTransaction(queryRunner, isolationLevel);
}
(ctx as any)[TRANSACTION_MANAGER_KEY] = queryRunner.manager;

Expand Down Expand Up @@ -66,7 +67,7 @@ export class TransactionWrapper {
}
throw error;
} finally {
if (!queryRunner.isTransactionActive
if (!queryRunner.isTransactionActive
&& queryRunner.isReleased === false) {
// There is a check for an active transaction
// because this could be a nested transaction (savepoint).
Expand All @@ -80,15 +81,15 @@ export class TransactionWrapper {
* Attempts to start a DB transaction, with retry logic in the case that a transaction
* is already started for the connection (which is mainly a problem with SQLite/Sql.js)
*/
private async startTransaction(queryRunner: QueryRunner) {
private async startTransaction(queryRunner: QueryRunner, isolationLevel: TransactionIsolationLevel | undefined) {
const maxRetries = 25;
let attempts = 0;
let lastError: any;

// Returns false if a transaction is already in progress
async function attemptStartTransaction(): Promise<boolean> {
try {
await queryRunner.startTransaction();
await queryRunner.startTransaction(isolationLevel);
return true;
} catch (err) {
lastError = err;
Expand Down
15 changes: 8 additions & 7 deletions packages/core/src/connection/transactional-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { VendureEntity } from '../entity/base/base.entity';
import { removeCustomFieldsWithEagerRelations } from './remove-custom-fields-with-eager-relations';
import { TransactionWrapper } from './transaction-wrapper';
import { GetEntityOrThrowOptions } from './types';
import { TransactionIsolationLevel } from '../api/decorators/transaction.decorator';

/**
* @description
Expand All @@ -40,7 +41,7 @@ export class TransactionalConnection {
constructor(
@InjectConnection() private connection: Connection,
private transactionWrapper: TransactionWrapper,
) {}
) { }

/**
* @description
Expand Down Expand Up @@ -147,18 +148,18 @@ export class TransactionalConnection {
ctx = RequestContext.empty();
work = ctxOrWork;
}
return this.transactionWrapper.executeInTransaction(ctx, work, 'auto', this.rawConnection);
return this.transactionWrapper.executeInTransaction(ctx, work, 'auto', undefined, this.rawConnection);
}

/**
* @description
* Manually start a transaction if one is not already in progress. This method should be used in
* conjunction with the `'manual'` mode of the {@link Transaction} decorator.
*/
async startTransaction(ctx: RequestContext) {
async startTransaction(ctx: RequestContext, isolationLevel?: TransactionIsolationLevel) {
const transactionManager = this.getTransactionManager(ctx);
if (transactionManager?.queryRunner?.isTransactionActive === false) {
await transactionManager.queryRunner.startTransaction();
if (transactionManager ?.queryRunner ?.isTransactionActive === false) {
await transactionManager.queryRunner.startTransaction(isolationLevel);
}
}

Expand All @@ -171,7 +172,7 @@ export class TransactionalConnection {
*/
async commitOpenTransaction(ctx: RequestContext) {
const transactionManager = this.getTransactionManager(ctx);
if (transactionManager?.queryRunner?.isTransactionActive) {
if (transactionManager ?.queryRunner ?.isTransactionActive) {
await transactionManager.queryRunner.commitTransaction();
}
}
Expand All @@ -184,7 +185,7 @@ export class TransactionalConnection {
*/
async rollBackTransaction(ctx: RequestContext) {
const transactionManager = this.getTransactionManager(ctx);
if (transactionManager?.queryRunner?.isTransactionActive) {
if (transactionManager ?.queryRunner ?.isTransactionActive) {
await transactionManager.queryRunner.rollbackTransaction();
}
}
Expand Down

0 comments on commit bf2b1f5

Please sign in to comment.