6

For this experimental project based on the spring-boot-starter-data-jpa dependency and H2 in-memory database, I defined a User entity with two fields (id and firstName) and declared a UsersRepository by extending the CrudRepository interface.

Now, consider a simple controller which provides two endpoints: /print-user reads the same user twice with some interval printing out its first name, and /update-user is used to change the user's first name in between those two reads. Notice that I deliberately set Isolation.READ_COMMITTED level and expected that during the course of the first transaction, a user which is retrieved twice by the same id will have different names. But instead, the first transaction prints out the same value twice. To make it more clear, this is the complete sequence of actions:

  1. Initially, jeremy's first name is set to Jeremy.
  2. Then I call /print-user which prints out Jeremy and goes to sleep.
  3. Next, I call /update-user from another session and it changes jeremy's first name to Bob.
  4. Finally, when the first transaction gets awakened after sleep and re-reads the jeremy user, it prints out Jeremy again as his first name even though the first name has already been changed to Bob (and if we open the database console, it's now indeed stored as Bob, not Jeremy).

It seems like setting isolation level has no effect here and I'm curious why this is so.

@RestController
@RequestMapping
public class UsersController {

    private final UsersRepository usersRepository;

    @Autowired
    public UsersController(UsersRepository usersRepository) {
        this.usersRepository = usersRepository;
    }

    @GetMapping("/print-user")
    @ResponseStatus(HttpStatus.OK)
    @Transactional (isolation = Isolation.READ_COMMITTED)
    public void printName() throws InterruptedException {
        User user1 = usersRepository.findById("jeremy"); 
        System.out.println(user1.getFirstName());
        
        // allow changing user's name from another 
        // session by calling /update-user endpoint
        Thread.sleep(5000);
        
        User user2 = usersRepository.findById("jeremy");
        System.out.println(user2.getFirstName());
    }


    @GetMapping("/update-user")
    @ResponseStatus(HttpStatus.OK)
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public User changeName() {
        User user = usersRepository.findById("jeremy"); 
        user.setFirstName("Bob");
        return user;
    }
    
}
Boann
  • 48,794
  • 16
  • 117
  • 146
escudero380
  • 536
  • 2
  • 7
  • 25
  • Shouldn't `findById(....)` return an `Optional`? – Andrew S Sep 15 '20 at 12:40
  • It's just omitted for simplicity here. – escudero380 Sep 15 '20 at 12:51
  • You may not have committed the change in update. You probably need to add this line `usersRepository.save(user)` after setting. – Shababb Karim Sep 15 '20 at 12:54
  • 1
    When a method is transactional, then entities retrieved within this transaction are in managed state, which means that all changes made to them will be populated to the database automatically at the end of the transaction. Therefore the `save()` call is redundant. – escudero380 Sep 15 '20 at 12:56
  • findById doesn't look right, should it be findByFirstName? – Nathan Hughes Sep 15 '20 at 13:04
  • No, the point is that I retrieve the same row defined by `jeremy` id, and change its first name from `Jeremy` to `Bob` (not its id). But the first transaction doesn't see those changes and acts like the isolation level is set to `REPEATABLE_READ`, even though it's set to `READ_COMMITTED`. – escudero380 Sep 15 '20 at 13:11
  • are you sure the second query in the printUser method even hits the database? i would assume the second query would use the first level cache. Check the logs and see what is going on. – Nathan Hughes Sep 15 '20 at 13:17
  • @NathanHughes, how can I do that? I mean, how can I enable detailed JPA logging? I set this property: `logging.level.org.hibernate.SQL=debug` and now it shows me one SQL select-statement created in thread `exec-1` and two statements (select and update) created in thread `exec-2`, but is this information enough to conclude that the second call to `findById()` in `exec-1` just reuses cached results from the previous query? – escudero380 Sep 15 '20 at 13:56
  • @escudero380 Calling setter on the managed entity doesn't necessarily mean you are committing to the DB. – Shababb Karim Sep 15 '20 at 15:10

2 Answers2

6

There are two issues with your code.

You are performing usersRepository.findById("jeremy"); twice in the same transaction, chances are your second read is retrieving the record from the Cache. You need to refresh the cache when you read the record for the second time. I have updated code which uses entityManager, please check how it can be done using the JpaRepository

User user1 = usersRepository.findById("jeremy"); 
Thread.sleep(5000);
entityManager.refresh(user1);    
User user2 = usersRepository.findById("jeremy");

Here are the logs from my test case, please check SQL queries:

  • The first read operation is completed. Thread is waiting for the timeout.

Hibernate: select person0_.id as id1_0_0_, person0_.city as city2_0_0_, person0_.name as name3_0_0_ from person person0_ where person0_.id=?

  • Triggered update to Bob, it selects and then updates the record.

Hibernate: select person0_.id as id1_0_0_, person0_.city as city2_0_0_, person0_.name as name3_0_0_ from person person0_ where person0_.id=?

Hibernate: update person set city=?, name=? where id=?

  • Now thread wakes up from Sleep and triggers the second read. I could not see any DB query triggered i.e the second read is coming from the cache.

The second possible issue is with /update-user endpoint handler logic. You are changing the name of the user but not persisting it back, merely calling the setter method won't update the database. Hence when other endpoint's Thread wakes up it prints Jeremy.

Thus you need to call userRepository.saveAndFlush(user) after changing the name.

@GetMapping("/update-user")
@ResponseStatus(HttpStatus.OK)
@Transactional(isolation = Isolation.READ_COMMITTED)
public User changeName() {
    User user = usersRepository.findById("jeremy"); 
    user.setFirstName("Bob");
    userRepository.saveAndFlush(user); // call saveAndFlush
    return user;
}

Also, you need to check whether the database supports the required isolation level. You can refer H2 Transaction Isolation Levels

Govinda Sakhare
  • 5,009
  • 6
  • 33
  • 74
  • I tried. Even with explicit call to `usersRepository.save(user)` in the end of `/update-user` transaction the result is the same. – escudero380 Sep 15 '20 at 13:39
  • 1
    `save` does not commit your data. You should use `saveAndFlush()` or explicitly call `commit()` From https://www.baeldung.com/spring-data-jpa-save-saveandflush : When we use the save() method, the data associated with the save operation will not be flushed to the DB unless and until an explicit call to flush() or commit() method is made. – IQbrod Sep 15 '20 at 13:52
  • OK. I replaced `CrudRepository` with `JpaRepository` and added explicit `usersRepository.saveAndFlush(user);` - nothing changed – escudero380 Sep 15 '20 at 14:14
  • @escudero380 I have updated the answer. in your case, it seems the second read is retrieving the value from the cache. you need to refresh the cache. – Govinda Sakhare Sep 15 '20 at 14:16
  • @GoviS, yes. I just injected `EntityManager` via constructor and did as you said invoking `entityManager.refresh(user1, LockModeType.PESSIMISTIC_WRITE);`. Now `/print-user` transaction prints `Jeremy` and `Bob` as expected. – escudero380 Sep 15 '20 at 14:25
  • @GoviS, I have very same logs. The second select-statement in the first thread after wake-up is not triggered unless explicit call to `refresh()` with `PESSIMISTIC_WRITE` is made. – escudero380 Sep 15 '20 at 14:44
  • have a look at the Lock modes and what it does. In your case, query would be `SELECT.. FOR UPDATE` https://stackoverflow.com/a/33081311/3734640 – Govinda Sakhare Sep 15 '20 at 15:01
2

Your method to update @GetMapping("/update-user") is set with an isolation level @Transactional(isolation = Isolation.READ_COMMITTED) so commit() step is never reached in this method.

You must change isolation level or read your value in your transaction to commit the changes :) user.setFirstName("Bob"); does not ensure your data will be committed

Thread Summary will look like this :

A: Read => "Jeremy"
B: Write "Bob" (not committed)
A: Read => "Jeremy"
Commit B : "Bob"

// Now returning "Bob"
IQbrod
  • 2,060
  • 1
  • 6
  • 28
  • Sorry, I don't thinks I understand what you mean. Which isolation level should I use then? I tried all of them with explicit calls to repository's `save()` method followed by re-reading the value from repository and it doesn't solve the problem. – escudero380 Sep 15 '20 at 15:01
  • @escudero380 you should either allow `printName()` to read Uncommitted data with this isolation level `READ_UNCOMMITTED` either explicitly call `flush()` or `commit()`. I already explained this under @GoviS's post. `save()` does not commit data... And uncommitted data cannot be retreived under READ_COMMITTED transaction (as it allow read on committed data only). – IQbrod Sep 15 '20 at 15:12
  • Also please consider using a service between your controller and your repository as your implementation does not respect Separation Of Concern. Your repository should only handle data and transfer it to the service, the service will call repositories to provide an answer, and your repository should only transform data to db's entities – IQbrod Sep 15 '20 at 15:16
  • Following your suggestion, I tried both techniques: 1) replaced isolation level with `READ_UNCOMMITTED` for `printName()` method, but it didn't change anything; 2) then, I added `usersRepository.saveAndFlush(user)` to `changeName()` method and again it didn't help. – escudero380 Sep 15 '20 at 17:54
  • So far, the only thing that works is adding `entityManager.refresh(user1)` to `printName()` method as described in updated _Govi S_'s answer. – escudero380 Sep 15 '20 at 17:54
  • @IQbrod I understand both your points (about a service layer, and about save not commiting). I feel, though, that this is not the heart of the issue. When returning from the `@Transactionnel` method, Spring's transaction manager will retreive the active JPA session and flush (and commit) it, that's what it's here for too. – GPI Sep 16 '20 at 13:16
  • @GPI This comment starts with 'Also' which means it's not the heart of the issue. I agree with you and GoviS has the correct answer here – IQbrod Sep 16 '20 at 13:18
  • 1
    My comment is not only about service layer. The answer here states "commit is never reached". In my opinion, it **is** reached (well, that or a rollback in case of a DB exception, sure). – GPI Sep 16 '20 at 13:22