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

feat(mikro-orm): provide the ability to avoid an explicit transaction #1997

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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 38 additions & 16 deletions docs/tutorials/mikroorm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<User> {
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<User> {
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<User> {
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";

Expand Down Expand Up @@ -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<User> {
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

<GithubContributors :users="['derevnjuk']"/>
Expand Down
54 changes: 38 additions & 16 deletions packages/orm/mikro-orm/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<User> {
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<User> {
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<User> {
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";

Expand Down Expand Up @@ -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<User> {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
43 changes: 27 additions & 16 deletions packages/orm/mikro-orm/src/interceptors/TransactionalInterceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,21 @@ 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
*/
connectionName?: string;
}

type TransactionSettings = Required<Omit<TransactionOptions, "connectionName">>;
type TransactionSettings = Required<Omit<TransactionOptions, "connectionName" | "flushMode">> & {flushMode?: FlushMode};

@Interceptor()
export class TransactionalInterceptor implements InterceptorMethods {
Expand Down Expand Up @@ -60,10 +62,12 @@ export class TransactionalInterceptor implements InterceptorMethods {
}

private extractContextName(context: InterceptorContext<unknown>): 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") {
Expand All @@ -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<unknown> {
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<unknown> {
const manager = this.context.get(options.contextName)!;
if (options.disabled) {
return next();
}

return manager.transactional(() => next(), {
flushMode: options.flushMode,
isolationLevel: options.isolationLevel
});
}
Expand Down