Transaction support has been recently added to the Couchbase SDKs. Starting in Spring Data Couchbase 5.0.0-M5, support for transactions has been added to Spring Data Couchbase. In this blog, we will describe using the Spring Data @Transactional annotation to leverage Couchbase Transactions.
The spring-data-couchbase repository contains a spring-data-testapp which exercises transactions. It relies on the travel-sample bucket on a local Couchbase server. (any bucket with a primary index will suffice).
1 2 |
git clone git@github.com:spring-projects/spring-data-examples.git cd spring-data-examples/couchbase/transactions |
Beans supporting transaction management have been added to AbstractCouchbaseConfiguration. To allow the built-in transaction interceptor class to be overridden with the CouchbaseTransactionInterceptor, ensure the following line is in the application.properties file:
application.properties
1 |
spring.main.allow-bean-definition-overriding=true |
The transaction configuration can be modified by overriding the configureEnvironment() method in your class which extends AbstractCouchbaseConfiguration. Here we set the durabilityLevel to NONE so we can work in our single-node cluster.
Config.java
1 2 3 4 5 6 7 8 9 |
@Configuration @EnableCouchbaseRepositories({"com.example.demo"}) @EnableTransactionManagement public class Config extends AbstractCouchbaseConfiguration { … @Override public void configureEnvironment(ClusterEnvironment.Builder builder){ builder.transactionsConfig(TransactionsConfig.durabilityLevel(DurabilityLevel.NONE)); } |
In the class which will use them, we need to define the template as usual and a reference to the service object. It’s important to note that the @Autowired annotation populates a variable with a proxy object. This is an important detail as the proxy objects perform processing before and after calling the actual method. Directly creating the service object from its constructor will result in the @Transactional annotation having no effect.
CmdRunner.java
1 2 |
@Autowired CouchbaseTemplate template; @Autowired AirportGatesService airportGatesService; |
The service is defined with an @Service annotation. The constructor takes arguments which are @Bean objects, such that the infrastructure can create a proxy object for @Autowired.
AirlineGateService.java
1 2 3 4 5 6 |
@Service public class AirlineGatesService { CouchbaseTemplate template; public AirlineGatesService(CouchbaseTemplate template) { this.template = template; } |
AirlineGateService.java (cont’d)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// The @Transactional annotation results in the method of the proxy for the service executing this in a transaction @Transactional public void transferGates(String fromId, String toId, int gatesToTransfer, RuntimeException exceptionToThrow) { // Not necessary, but may wish to include this check to confirm this is actually in a transaction. TransactionalSupport.checkForTransactionInThreadLocalStorage().map((h) -> { if (!h.isPresent()) throw new RuntimeException("not in transaction!"); return h; }); AirlineGates fromAirlineGates = template.findById(AirlineGates.class).one(fromId); AirlineGates toAirlineGates = template.findById(AirlineGates.class).one(toId); toAirlineGates.gates += gatesToTransfer; fromAirlineGates.gates -= gatesToTransfer; template.save(fromAirlineGates); // maybe simulate an error occurring after the fromAirlineGates doc has been saved if(exceptionToThrow != null){ throw exceptionToThrow; } template.save(toAirlineGates); } |
Now use everything we have constructed:
- save two AirlineGates documents, each of which indicates 200 gates
- verify they have been saved.
- execute the transferGates() method of the service to transfer 50 gates from one airline to the other in a transaction.
- verify that the transfer has taken place.
- attempt to execute transferGates() again, this time with an exception occurring after the first document has been saved.
CmdRunner.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
AirlineGates airlineGates1 = new AirlineGates("1", "JFK", "American Airlines", Long.valueOf(200)); //1 AirlineGates airlineGates2 = new AirlineGates("2", "JFK", "Lufthansa", Long.valueOf(200)); AirlineGates saved1 = airlineGatesService.save(airlineGates1); AirlineGates saved2 = airlineGatesService.save(airlineGates2); AirlineGates found1 = airlineGatesService.findById(saved1.getId()); //2 AirlineGates found2 = airlineGatesService.findById(saved2.getId()); System.err.println("found before transferGates: " + found1); System.err.println("found before transferGates: " + found2); // move 50 gates from airline1 to airline2 int gatesToTransfer=50; airlineGatesService.transferGates(airlineGates1.getId(), airlineGates2.getId(), gatesToTransfer, null); //3 found1 = airlineGatesService.findById(saved1.getId()); found2 = airlineGatesService.findById(saved2.getId()); System.err.println("found after transferGates: " + found1); //4 System.err.println("found after transferGates: " + found2); Assert.isTrue(found1.getGates().equals(airlineGates1.getGates()-gatesToTransfer), "should have transferred"); Assert.isTrue(found2.getGates().equals(airlineGates1.getGates()+gatesToTransfer), "should have transferred"); // attempt to move 44 gates from airline1 to airline2, but it fails. try { // 5 airlineGatesService.transferGates(airlineGates1.getId(), airlineGates2.getId(), 44, new SimulateErrorException()); } catch (RuntimeException rte) { if (!(rte instanceof TransactionSystemUnambiguousException) && rte != null && rte.getCause() instanceof SimulateErrorException) { throw rte; } } System.err.println("found after transferGates: " + airlineGatesService.findById(airlineGates1.getId())); System.err.println("found after transferGates: " + airlineGatesService.findById(airlineGates2.getId())); Assert.isTrue(found1.getGates().equals(airlineGates1.getGates()-gatesToTransfer), "should be same as previous"); Assert.isTrue(found2.getGates().equals(airlineGates1.getGates()+gatesToTransfer), "should be same as previous"); |
How it works:
- The TransactionInterceptor @Bean in AbstractCouchbaseConfiguration overrides the built-in TransactionInterceptor for @Transactional annotated methods.
- The @Transactional annotation causes the proxy for AirportGatesService to use the TransactionInterceptor to call the annotated method using the CouchbaseCallbackTransactionManager.
- The CouchbaseCallbackTransactionManager initializes a transaction and then calls the actual service method in the context of the transaction.
- If the method call is successful, the CouchbaseCallbackTransactionManager commits the transaction.
- If the commit is successful, the call returns.
- If the commit fails with a retryable exception, the CouchbaseCallbackTransactionManger will retry the complete process of initializing a new transaction, executing the actual service method and then committing the transaction.
The CouchbaseCallbackTransactionManager will repeat this until either the commit succeeds, or the maximum number of retries has been reached.
Options for next steps
- Couchbase transactions can be leveraged in Spring Data Couchbase by using the TransactionalOperator – see the Spring Data docs here.
- Transactions can also be directly used by the Couchbase Java SDK – see the Java ACID transaction docs here.
Hi Folks – the couchbase/transactions project has not yet been merged into spring-data-examples. It will be available shortly.
– Mike
The couchbase/transactions project is now available in the boot-3 branch
https://github.com/spring-projects/spring-data-examples/tree/boot-3/couchbase/transactions
The transaction sample needs spring-boot-starter-data-couchbase 3.0.0-RC2 to run due to an incompatibility with reactor in other 3.0.0* versions.