From b215ac24ff2a2c33eb0a34b7091c816fec0ed391 Mon Sep 17 00:00:00 2001 From: Artem Derevnjuk Date: Thu, 21 Jul 2022 23:27:22 +0400 Subject: [PATCH] feat(mikro-orm): provide the ability to use the implicit transaction (#1997) closes #1996 --- docs/tutorials/mikroorm.md | 54 +++++++++++++------ packages/orm/mikro-orm/readme.md | 54 +++++++++++++------ .../TransactionalInterceptor.spec.ts | 32 +++++++++++ .../interceptors/TransactionalInterceptor.ts | 43 +++++++++------ 4 files changed, 135 insertions(+), 48 deletions(-) diff --git a/docs/tutorials/mikroorm.md b/docs/tutorials/mikroorm.md index a32b91e21da..0b31770d663 100644 --- a/docs/tutorials/mikroorm.md +++ b/docs/tutorials/mikroorm.md @@ -112,7 +112,7 @@ export class UsersService { It's also possible to inject an ORM by its context name: -```ts +```typescript import {Injectable} from "@tsed/di"; @Injectable() @@ -253,11 +253,47 @@ export class UsersCtrl { } ``` +## Retry policy + +By default, `IsolationLevel.READ_COMMITTED` is used. You can override it, specifying the isolation level for the transaction by supplying it as the `isolationLevel` parameter in the `@Transactional` decorator: + +```typescript +@Post("/") +@Transactional({isolationLevel: IsolationLevel.SERIALIZABLE}) +create(@BodyParams() user: User): Promise { + return this.usersService.create(user); +} +``` + +The MikroORM supports the standard isolation levels such as `SERIALIZABLE` or `REPEATABLE READ`, the full list of available options see [here](https://mikro-orm.io/docs/transactions#isolation-levels). + +You can also set the [flushing strategy](https://mikro-orm.io/docs/unit-of-work#flush-modes) for the transaction by setting the `flushMode`: + +```typescript +@Post("/") +@Transactional({flushMode: FlushMode.AUTO}) +create(@BodyParams() user: User): Promise { + return this.usersService.create(user); +} +``` + +In some cases, you might need to avoid an explicit transaction, but preserve an async context to prevent the usage of the global identity map. For example, starting with v3.4, the MongoDB driver supports transactions. Yet, you have to use a replica set, otherwise, the driver will raise an exception. + +To prevent `@Transactional()` use of an explicit transaction, you just need to set the `disabled` field to `true`: + +```typescript +@Post("/") +@Transactional({disabled: true}) +create(@BodyParams() user: User): Promise { + return this.usersService.create(user); +} +``` + By default, the automatic retry policy is disabled. You can implement your own to match the business requirements and the nature of the failure. For some noncritical operations, it is better to fail as soon as possible rather than retry a coupe of times. For example, in an interactive web application, it is better to fail right after a smaller number of retries with only a short delay between retry attempts, and display a message to the user (for example, "please try again later"). The `@Transactional()` decorator allows you to enable a retry policy for the particular resources. You just need to implement the `RetryStrategy` interface and use `registerProvider()` or `@OverrideProvider()` to register it in the IoC container. Below you can find an example to handle occurred optimistic locks based on [an exponential backoff retry strategy](https://en.wikipedia.org/wiki/Exponential_backoff). -```ts +```typescript import {OptimisticLockError} from "@mikro-orm/core"; import {RetryStrategy} from "@tsed/mikro-orm"; @@ -333,20 +369,6 @@ export class UsersCtrl { } ``` -## Transaction isolation levels - -By default, `IsolationLevel.READ_COMMITTED` is used. You can override it, specifying the isolation level for the transaction by supplying it as the `isolationLevel` parameter in the `@Transactional` decorator: - -```typescript -@Post("/") -@Transactional({isolationLevel: IsolationLevel.SERIALIZABLE}) -create(@BodyParams() user: User): Promise { - return this.usersService.create(user); -} -``` - -The MikroORM supports the standard isolation levels such as `SERIALIZABLE` or `REPEATABLE READ`, the full list of available options see [here](https://mikro-orm.io/docs/transactions#isolation-levels). - ## Author diff --git a/packages/orm/mikro-orm/readme.md b/packages/orm/mikro-orm/readme.md index b9c99a2cc8e..839dea72fcb 100644 --- a/packages/orm/mikro-orm/readme.md +++ b/packages/orm/mikro-orm/readme.md @@ -132,7 +132,7 @@ export class UsersService { It's also possible to inject an ORM by its context name: -```ts +```typescript import {Injectable} from "@tsed/di"; @Injectable() @@ -273,11 +273,47 @@ export class UsersCtrl { } ``` +By default, `IsolationLevel.READ_COMMITTED` is used. You can override it, specifying the isolation level for the transaction by supplying it as the `isolationLevel` parameter in the `@Transactional` decorator: + +```typescript +@Post("/") +@Transactional({isolationLevel: IsolationLevel.SERIALIZABLE}) +create(@BodyParams() user: User): Promise { + return this.usersService.create(user); +} +``` + +The MikroORM supports the standard isolation levels such as `SERIALIZABLE` or `REPEATABLE READ`, the full list of available options see [here](https://mikro-orm.io/docs/transactions#isolation-levels). + +You can also set the [flushing strategy](https://mikro-orm.io/docs/unit-of-work#flush-modes) for the transaction by setting the `flushMode`: + +```typescript +@Post("/") +@Transactional({flushMode: FlushMode.AUTO}) +create(@BodyParams() user: User): Promise { + return this.usersService.create(user); +} +``` + +In some cases, you might need to avoid an explicit transaction, but preserve an async context to prevent the usage of the global identity map. For example, starting with v3.4, the MongoDB driver supports transactions. Yet, you have to use a replica set, otherwise, the driver will raise an exception. + +To prevent `@Transactional()` use of an explicit transaction, you just need to set the `disabled` field to `true`: + +```typescript +@Post("/") +@Transactional({disabled: true}) +create(@BodyParams() user: User): Promise { + return this.usersService.create(user); +} +``` + +## Retry policy + By default, the automatic retry policy is disabled. You can implement your own to match the business requirements and the nature of the failure. For some noncritical operations, it is better to fail as soon as possible rather than retry a coupe of times. For example, in an interactive web application, it is better to fail right after a smaller number of retries with only a short delay between retry attempts, and display a message to the user (for example, "please try again later"). The `@Transactional()` decorator allows you to enable a retry policy for the particular resources. You just need to implement the `RetryStrategy` interface and use `registerProvider()` or `@OverrideProvider()` to register it in the IoC container. Below you can find an example to handle occurred optimistic locks based on [an exponential backoff retry strategy](https://en.wikipedia.org/wiki/Exponential_backoff). -```ts +```typescript import {OptimisticLockError} from "@mikro-orm/core"; import {RetryStrategy} from "@tsed/mikro-orm"; @@ -353,20 +389,6 @@ export class UsersCtrl { } ``` -## Transaction isolation levels - -By default, `IsolationLevel.READ_COMMITTED` is used. You can override it, specifying the isolation level for the transaction by supplying it as the `isolationLevel` parameter in the `@Transactional` decorator: - -```typescript -@Post("/") -@Transactional({isolationLevel: IsolationLevel.SERIALIZABLE}) -create(@BodyParams() user: User): Promise { - return this.usersService.create(user); -} -``` - -The MikroORM supports the standard isolation levels such as `SERIALIZABLE` or `REPEATABLE READ`, the full list of available options see [here](https://mikro-orm.io/docs/transactions#isolation-levels). - ## Contributors Please read [contributing guidelines here](https://tsed.io/CONTRIBUTING.html) diff --git a/packages/orm/mikro-orm/src/interceptors/TransactionalInterceptor.spec.ts b/packages/orm/mikro-orm/src/interceptors/TransactionalInterceptor.spec.ts index 4c6c25b6697..d19b0f97d87 100644 --- a/packages/orm/mikro-orm/src/interceptors/TransactionalInterceptor.spec.ts +++ b/packages/orm/mikro-orm/src/interceptors/TransactionalInterceptor.spec.ts @@ -125,6 +125,38 @@ describe("TransactionalInterceptor", () => { expect(next).toHaveBeenCalled(); }); + it("should disable an explicit transaction", async () => { + // arrange + const context = {options: {disabled: true}} as InterceptorContext; + const entityManger = instance(mockedEntityManager); + + when(mockedMikroOrmRegistry.get(anything())).thenReturn(instance(mockedMikroOrm)); + when(mockedMikroOrmContext.has(anything())).thenReturn(true); + when(mockedMikroOrmContext.get(anything())).thenReturn(entityManger); + + // act + await transactionalInterceptor.intercept(context, next); + + // assert + expect(next).toHaveBeenCalled(); + verify(mockedEntityManager.transactional(anything(), anything())).never(); + }); + + it("should throw an error if context is lost", async () => { + // arrange + const context = {} as InterceptorContext; + + when(mockedMikroOrmRegistry.get(anything())).thenReturn(instance(mockedMikroOrm)); + when(mockedMikroOrmContext.has(anything())).thenReturn(true); + when(mockedEntityManager.transactional(anything(), anything())).thenCall((func: (...args: unknown[]) => unknown) => func()); + + // act + const result = transactionalInterceptor.intercept(context, next); + + // assert + await expect(result).rejects.toThrow("No such context"); + }); + it("should throw an error if no such context", async () => { // arrange const context = {} as InterceptorContext; diff --git a/packages/orm/mikro-orm/src/interceptors/TransactionalInterceptor.ts b/packages/orm/mikro-orm/src/interceptors/TransactionalInterceptor.ts index 454d6a6de8a..7f34d91d593 100644 --- a/packages/orm/mikro-orm/src/interceptors/TransactionalInterceptor.ts +++ b/packages/orm/mikro-orm/src/interceptors/TransactionalInterceptor.ts @@ -3,11 +3,13 @@ import {Logger} from "@tsed/logger"; import {RetryStrategy} from "../services/RetryStrategy"; import {MikroOrmContext} from "../services/MikroOrmContext"; import {MikroOrmRegistry} from "../services/MikroOrmRegistry"; -import {IsolationLevel} from "@mikro-orm/core"; +import {FlushMode, IsolationLevel} from "@mikro-orm/core"; export interface TransactionOptions { retry?: boolean; + disabled?: boolean; isolationLevel?: IsolationLevel; + flushMode?: FlushMode; contextName?: string; /** * @deprecated Since 2022-02-01. Use {@link contextName} instead @@ -15,7 +17,7 @@ export interface TransactionOptions { connectionName?: string; } -type TransactionSettings = Required>; +type TransactionSettings = Required> & {flushMode?: FlushMode}; @Interceptor() export class TransactionalInterceptor implements InterceptorMethods { @@ -60,10 +62,12 @@ export class TransactionalInterceptor implements InterceptorMethods { } private extractContextName(context: InterceptorContext): TransactionSettings { - const options = context.options || ({} as TransactionOptions | string); + const options = (context.options || {}) as TransactionOptions | string; let isolationLevel: IsolationLevel | undefined; + let disabled: boolean | undefined; let contextName: string | undefined; + let flushMode: FlushMode | undefined; let retry: boolean | undefined; if (typeof options === "string") { @@ -72,27 +76,34 @@ export class TransactionalInterceptor implements InterceptorMethods { contextName = options.contextName ?? options.connectionName; isolationLevel = options.isolationLevel; retry = options.retry; + disabled = options.disabled; + flushMode = options.flushMode; } - if (!contextName) { - contextName = "default"; - } + return { + flushMode, + retry: retry ?? false, + disabled: disabled ?? false, + contextName: contextName ?? "default", + isolationLevel: isolationLevel ?? IsolationLevel.READ_COMMITTED + }; + } - if (!retry) { - retry = false; - } + private async executeInTransaction(next: InterceptorNext, options: TransactionSettings): Promise { + const manager = this.context.get(options.contextName); - if (!isolationLevel) { - isolationLevel = IsolationLevel.READ_COMMITTED; + if (!manager) { + throw new Error( + `No such context: ${options.contextName}. Please check if the async context is lost in one of the asynchronous operations.` + ); } - return {contextName, isolationLevel, retry}; - } - - private async executeInTransaction(next: InterceptorNext, options: TransactionSettings): Promise { - const manager = this.context.get(options.contextName)!; + if (options.disabled) { + return next(); + } return manager.transactional(() => next(), { + flushMode: options.flushMode, isolationLevel: options.isolationLevel }); }