Spring Data — Never Rollback Readonly Transactions
Tags: spring, java, tutorial, programming
Published: 2022-01-05 09:02:22

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](https://github.com/SimonHarmonicMinor/spring-boot-readonly-transactions). 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](https://jakarta.ee) servers. Here is the `Server` entity.

```java
@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
  }
}
```

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.

```java
@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())
      );
    }
  }
}
```

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](https://dev.to/kirekov/spring-data-transactional-caveats-19di) sets the current transaction as [rollback-only](https://docs.spring.io/spring-framework/docs/4.2.x/spring-framework-reference/html/transaction.html). 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.

```java
@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;
    }
  }
}
```

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
1. `SERVER_IS_ABSENT` — the server with the given ID is absent
1. `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.

```java
@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));
  }
}
```

![The test passes](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kcdgzovmhbjugy308pg1.png)

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.

```java
@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()));
}
```

Unfortunately, the test does not pass.

![The test does not pass](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bldicme1n1cltqko2qvm.png)

```
Transaction silently rolled back 
because it has been marked as rollback-only
```

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

{% link https://dev.to/kirekov/spring-data-transactional-caveats-19di %}

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

![Unexpected rollback](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o8eaktisrsrdv7vcx7ad.png)

The default transaction [propagation](https://www.baeldung.com/spring-transactional-propagation-isolation) 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](https://spring.io/blog/2012/05/23/transactions-caching-and-aop-understanding-proxy-usage-in-spring). 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.

```java
@Transactional(readOnly = true, propagation = REQUIRES_NEW)
public void checkSwitchOn(Long serverId) {
  ...
}
```

![The test passes](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cullgmg2p93gnm3q5xfp.png)

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

![Transaction schema](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xthhobqi1nedztra5aq3.png)

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.

```java
@Transactional(readOnly = true, noRollbackFor = Exception.class)
public void checkSwitchOn(Long serverId) {
  ...
}
```

The `noRollbackFor` attribute tells Spring not to set the  _rollback-only_ flag on exception raising.

![The test passes](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/eexl7am102poq6ikkv4s.png)

Now we have reached the stated goals.

1. The request is being processed within a single transaction.
1. 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](https://www.logicbig.com/tutorials/spring-framework/spring-web-mvc/meta-annotation.html)!

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

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.

```java
@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 "";
}
```

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.

```java
@ReadTransactional
public void checkSwitchOn(Long serverId) {
  ...
}
```

![The test passes](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vyj8g1kk3e8qxgosl1u4.png)

## 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.

```java
@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);
  }
}
```

> 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.

```java
@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());
}
```

![The test passes](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ykf6tp6ieif6l1i9vyie.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nf4a7hm9ifwo0y2z4892.png)

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](https://github.com/SimonHarmonicMinor/spring-boot-readonly-transactions)
1. [Jakarta EE](https://jakarta.ee)
1. [Spring Data — Transactional Caveats](https://dev.to/kirekov/spring-data-transactional-caveats-19di)
1. [Rollback-only Flag Explanation](https://docs.spring.io/spring-framework/docs/4.2.x/spring-framework-reference/html/transaction.html)
1. [Spring Transaction Propagation](https://www.baeldung.com/spring-transactional-propagation-isolation)
1. [Understanding Proxy Usage in Spring](https://spring.io/blog/2012/05/23/transactions-caching-and-aop-understanding-proxy-usage-in-spring)
1. [Spring Meta Annotaions](https://www.logicbig.com/tutorials/spring-framework/spring-web-mvc/meta-annotation.html)