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

Transactional not applying on whole method ? #58

Closed
jchapelle opened this issue Oct 27, 2020 · 17 comments
Closed

Transactional not applying on whole method ? #58

jchapelle opened this issue Oct 27, 2020 · 17 comments

Comments

@jchapelle
Copy link

jchapelle commented Oct 27, 2020

When trying this library, I realize that the transaction is committed after the first "await this.service1.create" while I would expect the transaction to be committed at the end of the method.

@Transactional()
    async myMethod(id, message): Promise<Post> {
        const service1Result = await this.service1.create({ id, message })  // which makes a repository1.save()
        const service1Result = await this.service2.create({ id, message })  //  which makes a repository2.save()
        runOnTransactionCommit(() => this.events.emit('post created'))
        return result
}
@matiasraisedreal
Copy link

matiasraisedreal commented Nov 12, 2020

I have a similar error and it is that although the rollback and commit are executed well. The 'save' is committed before ending the function, so the subscription in this case is saving in the DB.

 @Transactional()
  async createSubscription(subscription: SubscriptionDto): Promise<Subscription> {

    runOnTransactionCommit(() => console.log('subscription created'));
    runOnTransactionRollback(() => console.log('subscription roll back'));

    const sub = await this.repository.save(subscription);

    if (subscription.userId === 1) throw 'fail';

    return sub;
}

@odavid
Copy link
Owner

odavid commented Nov 12, 2020 via email

@odavid
Copy link
Owner

odavid commented Nov 12, 2020

I think the save method should be awaited...

Sorry, ignore that...

@odavid
Copy link
Owner

odavid commented Nov 12, 2020

@matiasraisedreal - can you try using Constructor instead of create()

@odavid
Copy link
Owner

odavid commented Nov 12, 2020

@matiasraisedreal - can you try using Constructor instead of create()

Also, are the service create methods also decorated with transactional?

@matiasraisedreal
Copy link

Thank you very much @odavid for the quick reply.
I didn't quite understand what your suggestion is. Maybe I'll share some code to give you a better context for the comment ...

Just to understand the scope of the library. Does it rollBack the transactions in the database that occur within the function defined with the transactional () decorator? Or does it only provide you with a function ("runOnTransactionRollback"), which can be called if at some point the function / transaction fails?

// subscription.controller.ts

@ApiTags('Subscriptions')
@Controller('subscriptions')
export class SubscriptionController {
  constructor(private readonly subscriptionService: SubscriptionService) {}

  @Post()
  @Public()
  createSubscription(
    @Body() subscription: SubscriptionDto
  ): Promise<Subscription | SubscriptionDto> {
    return this.subscriptionService.createSubscription(subscription);
  }
}

subscription.service.ts

@Injectable()
export class SubscriptionService {
  constructor(readonly repository: SubscriptionRepository) { }

  @Transactional()
  async createSubscription(subscription: SubscriptionDto): Promise<Subscription | SubscriptionDto> {

    runOnTransactionCommit(() => console.log('subscription created'));
    runOnTransactionRollback(() => console.log('subscription roll back'));

    const sub = this.repository.save(subscription);

    if (subscription.userId === 1) throw 'fallo';

    return sub;
  }
}

subscription.repository.ts

@EntityRepository(Subscription)
export class SubscriptionRepository extends BaseRepository<Subscription> {}

@matiasraisedreal
Copy link

@odavid It would be of great help and I would be very grateful if you could give me any suggestions based on what I shared with you 😉

@odavid
Copy link
Owner

odavid commented Nov 19, 2020

Hey @mathiswiehl - I think the issue you have is because you don't await the save method... so it is running later on
Can you try to change the code and see it happens?

@Injectable()
export class SubscriptionService {
  constructor(readonly repository: SubscriptionRepository) { }

  @Transactional()
  async createSubscription(subscription: SubscriptionDto): Promise<Subscription | SubscriptionDto> {

    runOnTransactionCommit(() => console.log('subscription created'));
    runOnTransactionRollback(() => console.log('subscription roll back'));

    const sub = await this.repository.save(subscription);

    if (subscription.userId === 1) throw 'fallo';

    return sub;
  }
}

@matiasraisedreal
Copy link

Hi @odavid !
I just added the await, but it's the same. The subscription is created without problems and however the console still throws me the error and the function "runOnTransactionRollback ()" is executed. Attached image.

Screenshot from 2020-11-20 08-10-44

I feel that the Repository 'save' function is not "listening" to what is happening, as if it were disconnected from what the library is.
I tried to downgrade typeOrm to version 0.2.22, but the same thing keeps happening...

@odavid
Copy link
Owner

odavid commented Nov 20, 2020

@matiasraisedreal - Will it be possible to create a simple test that I can clone and reproduce?

@odavid
Copy link
Owner

odavid commented Nov 21, 2020

Hi @matiasraisedreal , I've created a small test using postgresql and I could not reproduce the issue you have.
Can u please clone this repository and then run:

yarn install
yarn test

What is the database engine you're using?

@sunjoong85
Copy link
Contributor

sunjoong85 commented Nov 23, 2020

It's not safe to use Transactional() in a loop and complex async/await calls.

Unhandled async logic can be executed even though your IDE or lint doesn't recognize it as an unhandled Promises.

public async mainApi() {
        const promiseMemo = [];  // Keep promises 
        const revenues = await this.revenueMgmtRepository.find();

        revenues.forEach((revenue) => {
              promiseMemo.push(this.revenueMgmtRepository.update(revenue));
              
              ... revenue assertion logic. And it throws somewhere in this loop.

             // Not safe. because it has two async logic inside. @Transactional doesn't wait until all the async calls are done.
              promiseMemo.push(this.secondApi(revenue));   

        })

       await Promise.all(promiseMemo);   // Not called, when an exception occured in a loop.
  }

  private async secondApi(revenue) {
    const foundAccount = await this.hostAccountMgmtService.findAccountByHost(entity.hostId);

    revenue.setAccount(foundAccount);

    await this.revenueTxMgmtRepository.insert(rtx);  // This could be called out side of the transactional scope.
  }

Conclusion,

It's not safe to use @transactional in a complex situation.

I am not sure that I can manage the transactional scope per method or per request like Spring Transaction does in any Node frameworks.

I have to accept that asynchronous calls are too complicated to be handled.

@jchapelle
Copy link
Author

jchapelle commented Nov 23, 2020 via email

@sunjoong85
Copy link
Contributor

It's not safe to use Transactional() in a loop and complex async/await calls.

Unhandled async logic can be executed even though your IDE or lint doesn't recognize it as an unhandled Promises.

public async mainApi() {
        const promiseMemo = [];  // Keep promises 
        const revenues = await this.revenueMgmtRepository.find();

        revenues.forEach((revenue) => {
              promiseMemo.push(this.revenueMgmtRepository.update(revenue));
              
              ... revenue assertion logic. And it throws somewhere in this loop.

             // Not safe. because it has two async logic inside. @Transactional doesn't wait until all the async calls are done.
              promiseMemo.push(this.secondApi(revenue));   

        })

       await Promise.all(promiseMemo);   // Not called, when an exception occured in a loop.
  }

  private async secondApi(revenue) {
    const foundAccount = await this.hostAccountMgmtService.findAccountByHost(entity.hostId);

    revenue.setAccount(foundAccount);

    await this.revenueTxMgmtRepository.insert(rtx);  // This could be called out side of the transactional scope.
  }

Conclusion,

It's not safe to use @transactional in a complex situation.

I am not sure that I can manage the transactional scope per method or per request like Spring Transaction does in any Node frameworks.

I have to accept that asynchronous calls are too complicated to be handled.

Additionally,

Promise.all is not safe to use in a transaction. The same issue happens in running transactional logic manually without cls-hook.

For loop or sequential Promise Runner could be a solution.

@odavid
Copy link
Owner

odavid commented Nov 28, 2020

Hi @matiasraisedreal and @jchapelle - in 0.1.17, I've added Logging to the Transactional decorator.
Will be happy if you could apply it, so we can see what's going on.

During the weekend, I manage to reproduce the same behavior as you got using a test NestJS application.

After debugging it, I noticed the following reasons for this to happen:

First reason (When extending the BaseRepository)

This is the typeorm module I had...

@EntityRepository(Post)
export class PostRepository extends Repository<Post>{}
...

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      username: 'postgres',
      password: 'postgres',
      entities: [Post],
      synchronize: true,
      logging: "all",
      }),
      TypeOrmModule.forFeature([Post])
  ],
  exports: [],
  controllers: [AppController],
  providers: [AppService, ],
})

...
@Injectable()
export class AppService {
  constructor(
    private readonly postRepository: PostRepository,
  ) {}

  @Transactional()
  async createPost(message: string): Promise<Post>{

    console.log(`this.postRepository: ${this.postRepository.constructor.name}`)

    const p = new Post()
    p.message = message
    const ret = await this.postRepository.save(p)
    throw Error("Not save")
...
    return ret
  }

I've called console.log() inside the createPost method and it printed: Repository instead of PostRepository...

After debugging it a bit, I've noticed that I needed to expose also the PostRepository within the application module, otherwise it will not fail, but will inject a Repository instead of PostRepository and therefore the manager was not the right one within the repository causing typeorm to create a Transaction during save() with a different EntityManager

Options to solve it

  • In the application module, expose also the PostRepository
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
     ...
   }),
// Instead of 
   TypeOrmModule.forFeature([Post])
// Change to 
   TypeOrmModule.forFeature([Post, PostRepository])
  • By patching the Repository prototype
// Instead of ...
async function bootstrap() {
  initializeTransactionalContext()
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

// Change to 
async function bootstrap() {
  initializeTransactionalContext()
  // Patch typeorm Repository, so every Repository will be able to return the right `manager`
  patchTypeORMRepositoryWithBaseRepository()
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

Looking forward to get your answers on that matter...

Cheers

@preterer
Copy link

preterer commented Dec 15, 2020

The issue here is this code is not resolving promises whenever it fails.

  public async mainApi() {
        const promiseMemo = [];  // Keep promises 
        const revenues = await this.revenueMgmtRepository.find();

        revenues.forEach((revenue) => {
              promiseMemo.push(this.revenueMgmtRepository.update(revenue)); // promise is started
              
              // error is thrown, all started promises are not resolved/awaited/canceled, 
             throw new Error();

              promiseMemo.push(this.secondApi(revenue));
        });
       
      // error is never caught, so function never gets here
      // promises started before the error are still running
      await Promise.all(promiseMemo);   // Not called, when an exception occured in a loop.
  }

The "easy" solution is to wrap the forEach call in a try-catch block, cancel/resolve all pending promises and then throw the caught error to handle it wherever else you are handling it right now.

You can also refactor your code so it won't start promises in a forEach loop, but rather use a map like:

await Promise.all(revenues.map(revenue => doSomething(revenue).then((result) => {
  // do your checks here
  return doSomethingElse(revenue);
}));

Then whenever the checks fail, your Promise.all call will already be made, so it will handle canceling all other pending promises. Downside is the code will wait for the first call to finish, before processing, so it will most likely be slightly slower.

Anyway I'm using Promise.all a lot, and it works perfectly fine with the library.

@odavid
Copy link
Owner

odavid commented Dec 15, 2020

Thanks @preterer for clarification.
I think this issue became confusing and too many disconnected threads.

I will close it. If any of you guys feel it needs to be opened, please feel free to create a new one with a reference to it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants