Spring transactions can be declared read-only when your code reads but does not modify data. Read-only transactions can be a useful optimization in some cases, such as when you use Hibernate or Neo4j OGM. With Spring Data Neo4j and Neo4j OGM read-only transactions are an integral part of causal cluster. They don’t prevent write queries from happening. However, it’s often nice to check if appropriate settings are used throughout your architecture.
1. Problem
I want to make sure that services with transactions marked as @Transactional(readOnly = true)
don’t execute nested write transactions.
2. Solution
To quote the Spring Framework documentation:
By default, a participating transaction joins the characteristics of the outer scope, silently ignoring the local isolation level, timeout value, or read-only flag (if any). Consider switching the validateExistingTransactions flag to true on your transaction manager if you want isolation level declarations to be rejected when participating in an existing transaction with a different isolation level. This non-lenient mode also rejects read-only mismatches (that is, an inner read-write transaction that tries to participate in a read-only outer scope).
The Neo4j specific Neo4jTransactionManager
extends from the AbstractPlatformTransactionManager
and thus supports enabling validation.
Listing 1 shows the most easy solution, applicable to a Spring Boot based application.
It uses org.springframework.boot.autoconfigure.transaction.PlatformTransactionManagerCustomizer
to hook into the automatic creation of a transaction manager and customizes it.
import org.springframework.boot.autoconfigure.transaction.PlatformTransactionManagerCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
@Configuration
class TransactionManagerConfiguration {
@Bean
public PlatformTransactionManagerCustomizer<AbstractPlatformTransactionManager> transactionManagementConfigurer() {
return (AbstractPlatformTransactionManager transactionManager) -> transactionManager
.setValidateExistingTransaction(true);
}
}
In case you’re on a plain Spring and Spring Data Neo4j based application, you have to instantiate the transaction manager yourself.
You can use the automatically configured SessionFactory
for creating the transaction manager.
Listing 2 shows one way of doing that
import org.neo4j.ogm.session.SessionFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.data.neo4j.transaction.Neo4jTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
class TransactionManagerConfigurationPlainSDN {
private final SessionFactory sessionFactory;
public TransactionManagerConfigurationPlainSDN(SessionFactory sessionFactory) {
this.sessionFactory = sessionFactory;
}
@Bean
public PlatformTransactionManager transactionManager() {
Neo4jTransactionManager transactionManager = new Neo4jTransactionManager(sessionFactory);
transactionManager.setValidateExistingTransaction(true);
return transactionManager;
}
}
Those two configurations are not meant to be used together in one project! |
Given a "broken" service like the one in Listing 3, the transaction manager now refuses to execute the method.
@Service
public class BrokenService {
private final ThingRepository thingRepository;
public BrokenService(ThingRepository thingRepository) {
this.thingRepository = thingRepository;
}
@Transactional(readOnly = true)
public ThingEntity tryToWriteInReadOnlyTx() {
return this.thingRepository.save(new ThingEntity("A thing")); (1)
}
}
1 | This is broken. #save is pretty obvious a write transaction. |
The following Spring Boot test demonstrate the desired behaviour (it’s a JUnit 5 test, so we can autowire our dependency with constructor injection):
@ExtendWith(SpringExtension.class)
@SpringBootTest
public class BrokenServiceTest {
private static final Predicate<String> MESSAGE_INDICATES_READ_ONLY_MISMATCH = Pattern
.compile(".*is not marked as read-only but existing transaction is.*").asPredicate();
private final BrokenService brokenService;
@Autowired
public BrokenServiceTest(BrokenService brokenService) {
this.brokenService = brokenService;
}
@Test
@DisplayName("Wrong transaction usage should fail")
void wrongTransactionUsageShouldFail() {
var caughtException = assertThrows(IllegalTransactionStateException.class,
() -> brokenService.tryToWriteInReadOnlyTx());
assertTrue(() -> MESSAGE_INDICATES_READ_ONLY_MISMATCH.test(caughtException.getMessage()));
}
}
The full project with all sources and build script is available here: validate-transaction-settings
.