DEV Community

Semyon Kirekov
Semyon Kirekov

Posted on • Updated on

Spring Data — Never Rollback Readonly Transactions

When you develop a backend application, you have to work with data. In most cases, a relational database is a store. Therefore, transactions' usage is almost inevitable.

All requests can be split into two categories. Those that read data (read-only transactions). And those that can also update data (read-write transactions). Spring Data provides a convenient way to manage database transactions. But its simplicity may hide the potential issues. For example, applying @Transactional(readOnly = true) annotation unwisely can make your code less reusable and introduce some unexpected bugs. What do I mean by that? Let's deep dive to find out.

All the examples are taken from this repository. You can clone it and run tests locally.

Our stack is Spring Boot + Hibernate + PostgreSQL.

Domain

Suppose that we are developing a system that controls remote Jakarta EE servers. Here is the Server entity.

@Entity
@Table(name = "server")
public class Server {
  @Id
  @GeneratedValue(strategy = IDENTITY)
  private Long id;

  private String name;

  private boolean switched;

  @Enumerated(STRING)
  private Type type;

  public enum Type {
    JBOSS,
    TOMCAT,
    WEB_LOGIC
  }
}
Enter fullscreen mode Exit fullscreen mode

Each server has a name, running status (whether it is switched on) and type.

Operation Restriction

Servers cannot be switched on randomly. We have a policy that should be followed.

If a server is switched on already, it cannot be triggered to switch on. The maximum amount of switched on servers of each type is 3.

Let's declare a service that checks the stated requirements.

@Service
public class ServerRestrictions {
  @Autowired
  private ServerRepository serverRepository;

  @Transactional(readOnly = true)
  public void checkSwitchOn(Long serverId) {
    final var server =
        serverRepository.findById(serverId)
            .orElseThrow();
    if (server.isSwitched()) {
      throw new OperationRestrictedException(
          format("Server %s is already switched on", server.getName())
      );
    }
    final var count = serverRepository.countAllByTypeAndIdNot(server.getType(), serverId);
    if (count >= 3) {
      throw new OperationRestrictedException(
          format("There is already 3 switched on servers of type %s", server.getType())
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If the Server is absent, NoSuchElementException occurs. If the switch-on operation is not permitted, OperationRestrictedException goes then.

The serverRepository.countAllByTypeAndIdNot method returns the number of servers for the given type excluding the one with the provided ID.

If a user tries to switch on a server (or a bunch of servers), then checkSwitchOn might be called directly. Any RuntimeException thrown out of the transactional proxy sets the current transaction as rollback-only. It's quite helpful in most cases and it helps to compose separated units of work into a solid business case.

Anyway, sometimes this approach causes unexpected problems. You see, checkSwitchOn method does not make any changes. That's why we added the readOnly = true attribute. But if the validation does not pass, the transaction will be rolled back. So, what's the difference between rollback and commit in read-only transactions? There is no difference until you try to suppress the exception.

Problem

Suppose that users see a table of available servers. Each row has a button that sends a request to switch on the particular server. And here is a thing. If the server cannot be switched on, users do not want to get an error message. Instead, the button should be disabled. So, we need to retrieve the servers' statuses in advance.

Here is the possible solution.

@Service
public class ServerAllowedOperations {
  @Autowired
  private ServerRestrictions serverRestrictions;

  @Transactional(readOnly = true)
  public Map<Long, OperationStatus> getServersSwitchOnStatus(Collection<Long> serverIds) {
    final var result = new HashMap<Long, OperationStatus>();
    for (Long serverId : serverIds) {
      result.put(serverId, getOperationStatus(serverId));
    }
    return result;
  }

  private OperationStatus getOperationStatus(Long serverId) {
    try {
      serverRestrictions.checkSwitchOn(serverId);
      return ALLOWED;
    } catch (NoSuchElementException e) {
      LOG.debug(format("Server with id %s is absent", serverId), e);
      return SERVER_IS_ABSENT;
    } catch (OperationRestrictedException e) {
      LOG.debug(format("Server with id %s cannot be switched on", serverId), e);
      return RESTRICTED;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The getServersSwitchOnStatus method accepts the collection of server IDs that should be checked. There are 3 possible statuses.

  1. ALLOWED — the server can be switched on
  2. SERVER_IS_ABSENT — the server with the given ID is absent
  3. RESTRICTED — the server cannot be switched on

Time to write some tests. Let's start with a simple happy path.

There are 3 servers and each can be switched on successfully.

@Test
void shouldAllowAllServersToSwitchOn() {
  final var server =
      aServer().withSwitched(false).withType(WEB_LOGIC);
  final var s1 = db.save(server.withName("s1"));
  final var s2 = db.save(server.withName("s2"));
  final var s3 = db.save(server.withName("s3"));
  final var serversIds = List.of(s1.getId(), s2.getId(), s3.getId());

  final var operations = allowedOperations.getServersSwitchOnStatus(
      serversIds
  );

  for (Long serversId : serversIds) {
    assertEquals(ALLOWED, operations.get(serversId));
  }
}
Enter fullscreen mode Exit fullscreen mode

The test passes

That was easy. Let's write something more complicated.

There are 3 servers. The first one is JBOSS and it's already switched on. The second one is TOMCAT and it's switched off. The third one is WEB_LOGIC and it's switched off. But there are 3 additional servers of WEB_LOGIC type that are switched on.

So, it means that only TOMCAT server can be switched on. The two others should be bound with RESTRICTED operation status.

@Test
void shouldNotAllowSomeServersToSwitchOn() {
  final var s1 = db.save(
      aServer().withSwitched(true).withType(JBOSS)
  );
  final var s2 = db.save(
      aServer().withSwitched(false).withType(TOMCAT)
  );
  final var webLogic = aServer().withSwitched(false).withType(WEB_LOGIC);
  final var s3 = db.save(webLogic);
  db.saveAll(
      webLogic.withSwitched(true),
      webLogic.withSwitched(true),
      webLogic.withSwitched(true)
  );
  final var serversIds = List.of(s1.getId(), s2.getId(), s3.getId());

  final var operations = allowedOperations.getServersSwitchOnStatus(
        serversIds
  );

  assertEquals(RESTRICTED, operations.get(s1.getId()));
  assertEquals(ALLOWED, operations.get(s2.getId()));
  assertEquals(RESTRICTED, operations.get(s3.getId()));
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, the test does not pass.

The test does not pass

Transaction silently rolled back 
because it has been marked as rollback-only
Enter fullscreen mode Exit fullscreen mode

I've described similar obstacles in my article "Spring Data — Transactional Caveats". You can find more information there.

To reveal the cause of the issue, we need to figure out how transactions are managed in Spring.

Unexpected rollback

The default transaction propagation is REQUIRED. So, all the subsequent calls to ServerRestrictions.checkSwitchOn are being executed in the same transaction. The problem rises if any RuntimeException leaves the scope of the transactional proxy. When it happens, Spring marks the current transaction as rollback-only. If latter executions try to commit the changes, the error that we described earlier occurs.

By the end of the ServerAllowedOperations.getServersSwitchOnStatus method commit attempt happens.

There are several ways to deal with the situation. The easiest one is to force the ServerRestrictions.checkSwitchOn method to run in a separate transaction.

@Transactional(readOnly = true, propagation = REQUIRES_NEW)
public void checkSwitchOn(Long serverId) {
  ...
}
Enter fullscreen mode Exit fullscreen mode

The test passes

Why does it solve the problem? Let's take a look at the schema again.

Transaction schema

When a RuntimeException throws out of ServiceRestrictions.checkSwitchOn method, it only affects the separate transaction. The one that started on ServerAllowedOperations.getServersSwitchOnStatus calling keeps going.

Nevertheless, there is a caveat that should be mentioned. For every server to check a new transaction starts. In our case, there are 3 servers. So, we have 4 transactions instead of one. This can be a performance penalty. And it gets worse with a greater servers count.

Thankfully, a better approach exists. As I stated at the beginning of the article, there is no sense in rolling back read-only transactions. So, let's put some extra configurations.

@Transactional(readOnly = true, noRollbackFor = Exception.class)
public void checkSwitchOn(Long serverId) {
  ...
}
Enter fullscreen mode Exit fullscreen mode

The noRollbackFor attribute tells Spring not to set the rollback-only flag on exception raising.

The test passes

Now we have reached the stated goals.

  1. The request is being processed within a single transaction.
  2. Exceptions in read-only blocks do not cause rollbacks.

Anyway, we have to remember to add the noRollBackFor attribute every time we declare a read-only service method. Is there any better solution? There is one. Embrace Spring Meta Annotaions!

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(readOnly = true, noRollbackFor = Exception.class)
@Documented
public @interface ReadTransactional {
}
Enter fullscreen mode Exit fullscreen mode

The ReadTransactional annotation acts as an alias for @Transactional(readOnly = true, noRollbackFor = Exception.class) usage. However, the original annotation has many other attributes (isolation, propagation, etc.). What if wanted to override them? Spring has a solution as well.

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(readOnly = true, noRollbackFor = Exception.class)
@Documented
public @interface ReadTransactional {
  @AliasFor(annotation = Transactional.class, attribute = "value")
  String value() default "";

  @AliasFor(annotation = Transactional.class, attribute = "transactionManager")
  String transactionManager() default "";

  @AliasFor(annotation = Transactional.class, attribute = "label")
  String[] label() default {};

  @AliasFor(annotation = Transactional.class, attribute = "propagation")
  Propagation propagation() default Propagation.REQUIRED;

  @AliasFor(annotation = Transactional.class, attribute = "isolation")
  Isolation isolation() default Isolation.DEFAULT;

  @AliasFor(annotation = Transactional.class, attribute = "timeout")
  int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;

  @AliasFor(annotation = Transactional.class, attribute = "timeoutString")
  String timeoutString() default "";
}
Enter fullscreen mode Exit fullscreen mode

The AliasFor annotation usage references the attributes from the different annotations.

This pattern is used in Spring widely. Check out GetMapping or PostMapping declaration. They are nothing but aliases for the generic RequestMapping annotation.

So, here is the final version.

@ReadTransactional
public void checkSwitchOn(Long serverId) {
  ...
}
Enter fullscreen mode Exit fullscreen mode

The test passes

Combining with Read-Write Operations

How does this ReadTransactional annotation usage act with read-write queries? If any exception is being suppressed, can it affect the commit phase in update requests?

Well, if you apply it correctly, it can't. The methods that perform any updates should be marked with regular Transactional annotation. Whilst those that just read data with ReadTransactional.

Let's make a trivial example to prove this idea. If a user wants to switch on the server, the validation should proceed. If it fails, the transaction rollbacks.

@Service
public class ServerUpdateService {
  @Autowired
  private ServerRepository serverRepository;
  @Autowired
  private ServerRestrictions serverRestrictions;

  @Transactional
  public void switchOnServer(Long serverId) {
    final var server =
        serverRepository.findById(serverId)
            .orElseThrow();
    server.setSwitched(true);
    serverRepository.saveAndFlush(server);
    serverRestrictions.checkSwitchOn(serverId);
  }
}
Enter fullscreen mode Exit fullscreen mode

I put checkSwitchOn after flushing changes to the database manually. If everything works correctly, rollback should cancel the request entirely.

Time to create a test. Let's cover this scenario.

The server of type JBOSS is switched off. But there are already 3 JBOSS servers that are switched on. So, the operation has to fail.

@Test
void shouldRollbackIfServerCannotBeSwitchedOn() {
  final var jboss = aServer().withType(WEB_LOGIC).withSwitched(true);
  db.saveAll(jboss, jboss, jboss);
  final var serverId = db.save(jboss.withSwitched(false)).getId();

  assertThrows(
    OperationRestrictedException.class,
    () -> server.switchOnServer(serverId)
  );

  final var server = db.find(serverId, Server.class);
  assertFalse(server.isSwitched());
}
Enter fullscreen mode Exit fullscreen mode

The test passes

The server remains switched off because the validation does not pass. So, the business case acts correctly.

How does it work exactly? Take a look at the schema below.

Read-write request schema

When ServerRestrictions.checkSwitchOn throws an exception, Spring skips setting the rollback-only flag. But when the exception leaves the scope of ServerUpdateService.switchOnServer, Spring performs rollback instead of commit. Because the noRollBackFor attribute is not set for this method.

Conclusion

In my opinion, this is the best way to deal with transactions in the Spring ecosystem. It helps us to distinguish read-only and read-write requests that can be composed in a complex business unit eventually. If you have any questions or suggestions, leave your comments down below. Thanks for reading!

Resources

  1. The Repository with Examples
  2. Jakarta EE
  3. Spring Data — Transactional Caveats
  4. Rollback-only Flag Explanation
  5. Spring Transaction Propagation
  6. Understanding Proxy Usage in Spring
  7. Spring Meta Annotaions

Discussion (0)