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
}
}
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())
);
}
}
}
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;
}
}
}
The getServersSwitchOnStatus
method accepts the collection of server IDs that should be checked. There are 3 possible statuses.
-
ALLOWED
— the server can be switched on -
SERVER_IS_ABSENT
— the server with the given ID is absent -
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));
}
}
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 isTOMCAT
and it's switched off. The third one isWEB_LOGIC
and it's switched off. But there are 3 additional servers ofWEB_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()));
}
Unfortunately, the test does not pass.
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.
To reveal the cause of the issue, we need to figure out how transactions are managed in Spring.
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) {
...
}
Why does it solve the problem? Let's take a look at the schema again.
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) {
...
}
The noRollbackFor
attribute tells Spring not to set the rollback-only flag on exception raising.
Now we have reached the stated goals.
- The request is being processed within a single transaction.
- 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 {
}
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 "";
}
The AliasFor
annotation usage references the attributes from the different annotations.
This pattern is used in Spring widely. Check out
GetMapping
orPostMapping
declaration. They are nothing but aliases for the genericRequestMapping
annotation.
So, here is the final version.
@ReadTransactional
public void checkSwitchOn(Long serverId) {
...
}
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);
}
}
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 3JBOSS
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());
}
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.
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!
Top comments (0)