codete Mixing Spring Transaction Propagation Modes 1 main 86ffc4c95e
Codete Blog

Spring: Mixing Transaction Propagation Modes [Spring & JPA Pitfalls Series]

Michal Marciniec 7ff5ed9975

22/01/2019 |

11 min read

Michał Marciniec

The article is a part of the JPA & Spring pitfalls series, which you can check out here.

Spring Transaction Propagation: a Quick Review

In this article, we will go through a few practical cases with code samples to show how tricky it may be to mix @Transactional propagation modes in Spring (especially REQUIRES_NEW) without being cautious enough. 

Before we go, let’s only shortly remind the difference between REQUIRED and REQUIRES_NEW propagation modes.

REQUIRED

Support a current transaction, create a new one if none exists.
This is the default setting for @Transactional.

 

REQUIRES_NEW

Create a new transaction, and suspend the current transaction if one exists.

In short, using REQUIRES NEW is only significant when the method is invoked from a transactional context; otherwise, it behaves just like REQUIRED and simply creates a new transaction. That does not imply that there will be a single transaction for all of your clients; each client will begin in a non-transactional environment, and as soon as the request processing encounters a @Transactional, a new transaction will be created.

With that in mind, if using REQUIRES NEW makes sense for the semantics of the operation, I would disregard performance concerns - this would be a textbook premature optimization - prioritizing correctness and data integrity, so we could worry about the performance metrics, after they’ve been collected, not before.

On rollback - because REQUIRES NEW forces the beginning of a new transaction, an exception will rollback that transaction. If another transaction was also processed, it will either be rolled back or not, depending on whether the exception bubbles up the stack or is caught - your choice, depending on the specifics of the operation. 

And if you absolutely must do it in a separate transaction, you must use REQUIRES NEW and accept the performance penalty. Also, keep an eye out for broken locks.

Scenario description

Let’s imagine that we have a task to implement. Something as simple as just creating a new Person object with a Wallet and persisting them in the database. 

Hence, we’ll have 2 entity classes: Person and Wallet. A Person owns a Wallet (one-to-one relation). For each, we’ll have Repository classes (we’re using Spring Data) and Service classes for operating on entities. We should also have just a few integration tests to check if it works. 

Please look at our initial code for this task. The important thing to notice is that in the PersonService we use @Transactional annotation (with default REQUIRED propagation mode), whereas WalletService has @Transactional(propagation = Propagation.REQUIRES_NEW).

Let’s see how it looks in the PersonService first:

@Transactional
public long createPerson(String name) {
   Person person = new Person(name);
   personRepository.save(person);


   walletService.createWalletAndAttachToPerson(person);


   return person.getId();
}

This method creates a new Person object, persists it, passes it to the walletService to do its job, and then just simply returns the ID of the newly created person.

Let’s see what the WalletService does:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public Wallet createWalletAndAttachToPerson(Person person) {
   Wallet emptyWallet = new Wallet();
   walletRepository.save(emptyWallet);


   person.setWallet(emptyWallet);


   return emptyWallet;
}

It creates and persists an empty Wallet entity, then sets the reference to this wallet to the person object (passed from the PersonService) and returns the wallet instance.

Since the method is annotated with @Transactional, all the changes to managed entities are tracked and automatically saved to the database, so we don’t call anything like personRepository.save(person) to update DB entity, because we expect it to be done automatically at the end of createWalletAndAttachToPerson method.

Finally, we write an integration test to see whether this works as we expected.

@SpringBootTest
@RunWith(SpringRunner.class)
public class PersonServiceTest {


   @Autowired
   private PersonService personService;
   @Autowired
   private PersonRepository personRepository;


   @Test
   public void shouldCreatePersonWithEmptyWallet() {
       // when
       long jeremyId = personService.createPerson("Jeremy");


       // then
       Optional<Person> jeremy = personRepository.findById(jeremyId);
       assertThat(jeremy).isPresent();
       Wallet jeremyWallet = jeremy.get().getWallet();
       assertThat(jeremyWallet.getId()).isNotNull();
       assertThat(jeremyWallet.getAmount()).isZero();
   }


}

We run this test and… it’s green - well done!

----

Code for this section is available on branch requires-new/initial.

 

It works fine, so let’s write another test

Since the PersonService is more complicated, writing a test for WalletService is just a formality.

@SpringBootTest
@RunWith(SpringRunner.class)
public class WalletServiceTest {


   @Autowired
   private PersonRepository personRepository;
   @Autowired
   private WalletRepository walletRepository;
   @Autowired
   private WalletService walletService;


   @Test
   public void shouldCreateAndAddEmptyWalletToPerson() {
       // given
       Person margaret = personRepository.save(new Person("Margaret"));


       // when
       long walletId = walletService.createWalletAndAttachToPerson(margaret).getId();


       // then
       Optional<Wallet> dbWallet = walletRepository.findById(walletId);
       Assertions.assertThat(dbWallet).isPresent();


       Optional<Person> dbMargaret = personRepository.findById(margaret.getId());
       Assertions.assertThat(dbMargaret).isPresent();


       Wallet margaretWallet = dbMargaret.get().getWallet();
       assertThat(margaretWallet).isNotNull();
       assertThat(margaretWallet.getId()).isNotNull();
       assertThat(margaretWallet.getAmount()).isZero();
   }


}

So we run the tests and… wait… it failed - margaretWallet was in fact null. 

Therefore, in this test person.setWallet() wasn’t propagated to the database at all, but with the same code, it worked fine in PersonServiceTest. Why?

The thing is, that indeed any changes to entities done in methods with @Transactional are automatically reflected in the database at the end of the transaction, but only of the entities saved, merged, or retrieved within this specific transaction. In our case, createWalletAndAttachToPerson method starts a completely new transaction, therefore won’t save changes to the person instance passed from a different transaction context.

PersonServiceTest succeeds because it uses PersonService.createPerson(), which is a transactional method where a person was persisted, therefore it’s in the managed state within that transaction, which means that at the end of it all the changes will be populated to the database. That’s why the test passed (even though createWalletAndAttachToPerson didn’t work as initially expected) and gave us a false impression that everything is alright.

----

Code for this section is available on branch requires-new/wallet-service-test.

 

Remember the letter “I” from ACID?

Now, since we know that passing an entity between different transaction contexts is usually a bad idea, let’s refactor createWalletAndAttachToPerson to accept not a Person object, but just personId, then we will retrieve the entity (to have it in the managed state) within the new transaction, set the wallet and then everything should be reflected in the database as expected. 

So, finally, we come up with something like this:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public Wallet createWalletAndAttachToPerson(long personId) {
   Wallet emptyWallet = new Wallet();
   walletRepository.save(emptyWallet);


   Person person = personRepository.findById(personId).orElseThrow();
   person.setWallet(emptyWallet);


   return emptyWallet;
}

It looks good, so we run tests and see now WalletServiceTest passes, but PersonServiceTest fails due to an exception thrown in line personRepository.findById(personId).orElseThrow(). How is that possible if we have just saved the Person entity in createPerson() method right before calling walletService?

In WalletServiceTest the entity is saved out of transactional context, therefore it’s persisted right away and it’s visible to the walletService. Whereas createPerson() is executed in a transactional scope and here transaction isolation comes into play. Normally, any changes (save/update/delete) are visible only within the same transaction. The world outside of this transaction won’t see them until the transaction ends successfully.

When you use the REQUIRES_NEW transaction attribute and an existing transaction context is present, the current transaction is suspended and a new transaction is started. Once that new transaction ends, its changes are committed and visible to the original transaction, which gets resumed. 

However, it doesn’t work the other way. The “new transaction” cannot see changes made by the original one, because it’s suspended and has not committed them yet. That’s why findById(personId) failed to find the Person entity created in a different transaction.

----

Code for this section is available on branch requires-new/isolation.

 

Transaction Propagation and Isolation in Spring @Transactional: Detached or managed?

Let’s make another change. If in WalletService we can’t see a person saved in the primary transaction, let’s try the other way - WalletService will only create a new Wallet in a separate transaction, then in createPerson() we’ll update its money amount and set the reference to it in person object.

@Transactional
public long createPerson(String name, BigDecimal money) {
   Person person = new Person(name);
   personRepository.save(person);


   Wallet wallet = walletService.createWallet();


   wallet.setAmount(money);
   person.setWallet(wallet);


   return person.getId();
}

As you probably suppose, again, this won’t work. In fact, now we can see changes done by createWallet, because this transaction has finished with success. 

However, we can’t expect that changes to the returned wallet entity (create in a different transaction) will be automatically recorded in a database, because the wallet isn’t in the managed state, but detached. So to make the code working, we would need to for instance add walletRepository.save(wallet) after altering the wallet, or instead of using the returned entity we would need to retrieve “a fresh one” from the database in the current transaction.

As a rule, remember that when you pass entities to a method with REQUIRES_NEW or when you return entities from it - they are in a detached state and this may mislead other developers. Therefore, on the boundaries of transaction contexts, it’s always better to operate on entity IDs instead of passing entities themselves.

----

Code for this section is available on branch requires-new/detached.

 

Rollback

Alright, now our manager told us a new requirement, namely that we shouldn’t allow creating a person with a negative initial amount of money. In that case, we should throw an exception and nothing should be inserted into a database.

Instead of doing it programmatically, we can leverage transactions and throw a runtime exception to trigger a rollback. So here’s our code:

@Transactional
public long createPerson(String name, BigDecimal money) {
   Person person = new Person(name);
   personRepository.save(person);


   Wallet wallet = walletService.createWallet(money);
   if (wallet.getAmount().compareTo(BigDecimal.ZERO) < 0) {
       throw new RuntimeException("Initial amount of money cannot be less than zero");
   }


   person.setWallet(wallet);
   return person.getId();
}

and a new test case:

@Test
public void shouldNotCreateAnythingWhenTryingToCreatePersonWithNegativeAmountOfMoney() {
   // when
   assertThatThrownBy(() -> personService.createPerson("Vince", BigDecimal.valueOf(-100.0D)))
           .isInstanceOf(RuntimeException.class);


   // then
   assertThat(personRepository.findAll()).isEmpty();
   assertThat(walletRepository.findAll()).isEmpty();
}

What we face another time is that our test fails. The rollback mechanism reverts changes in the entities that are tracked by the current transaction. Creating a wallet happens in a different transaction than throwing the exception, therefore it won’t be rolled back.

Finally, we’re getting to the point that @Transactional(propagation = Propagation.REQUIRES_NEW) does more harm than good in our case. Therefore, we remove that, run each test case separately one by one, and… it finally works.

----

Code for this section is available on branch requires-new/rollback.

Beware of transactional tests

It looks so great to see tests light up in green… So let’s rerun them all together, not one by one. Wait… one of them actually failed and for the first time today it’s not about REQUIRES_NEW propagation. PersonServiceTest contains two test cases, which are not independent because the first one adds a person to the database whereas the second expects this table to be empty. So we need to add a mechanism to revert the database to the initial state after each test case.

Reading Spring docs (source, source) you may find an interesting trick. With @Transactional annotation on test method or test class level, Spring by default rolls back such transactions after the test, preventing committing any changes to the database. It’s nice, clean and it works for our case. But? But it may end up in false-positive test results

Remember when we discovered a bug in WalletService (“It works fine, so let’s write another test” section) when we added a test case that didn’t go through a transactional context? Now, imagine that you want to make test cases independent and add @Transactional. What happens? The failing test case now passes, not because it fixed the problem, but because it hid it! Even adding entityManager.flush(), which is advised by Spring docs to avoid false positives, doesn’t help in that case.

If you want to test how @Transactional influences the behavior of WalletServiceTest by yourself, check out branch requires-new/transactional-test.

----

If you are interested in reading more about this pitfall, I recommend Tomasz Nurkiewicz’s article.

 

Conclusions

Spring’s declarative transaction management is really cool and easy-to-use feature, but at the same time it may be the cause of hard-to-detect bugs, so remember to be cautious when using @Transactional, especially when you are about to use REQUIRES_NEW propagation mode.

A few basic things to remember from this article:

  • Prefer returning and passing IDs (or DTOs) over entity objects on the boundaries of a transaction
  • Entity modifications are tracked only within a transaction in which they have been saved, merged, or retrieved
  • Rollbacks revert only changes to the entities tracked by the current transaction
  • Avoid transactional tests, until you specifically want to test how code behaves in a transactional context

Thanks for reading and stay tuned!

Rated: 4.6 / 9 opinions
Michal Marciniec 7ff5ed9975

Michał Marciniec

Tech Lead at Codete. Michał is an eager fan of the fresh approach to Java programming. Enthusiast of Spring tech stack and refactoring techniques. Enjoys solving Java quirks, algorithmic puzzles, and... Rubik's cube. Privately, amateur drummer.

Our mission is to accelerate your growth through technology

Contact us

Codete Global
Spółka z ograniczoną odpowiedzialnością

Na Zjeździe 11
30-527 Kraków

NIP (VAT-ID): PL6762460401
REGON: 122745429
KRS: 0000983688

Get in Touch
  • icon facebook
  • icon linkedin
  • icon instagram
  • icon youtube
Offices
  • Kraków

    Na Zjeździe 11
    30-527 Kraków
    Poland

  • Lublin

    Wojciechowska 7E
    20-704 Lublin
    Poland

  • Berlin

    Bouchéstraße 12
    12435 Berlin
    Germany