diff --git a/pom.xml b/pom.xml index 13d61713..2768a90e 100644 --- a/pom.xml +++ b/pom.xml @@ -29,9 +29,10 @@ 3.3.0 3.3.0 4.1.2 + 6.1.11 3.3.0 1.6 - 8.1.2 + 9.0.0 8.1.2 8.1.2 3.6.8 @@ -193,6 +194,11 @@ ${springdata.spring-boot} compile + + org.springframework + spring-tx + ${spring-tx} + com.aerospike aerospike-client-jdk8 diff --git a/src/main/asciidoc/reference/transactions.adoc b/src/main/asciidoc/reference/transactions.adoc new file mode 100644 index 00000000..e7932589 --- /dev/null +++ b/src/main/asciidoc/reference/transactions.adoc @@ -0,0 +1,149 @@ +[[transactions]] += Transactions + +In the context of database operations, a transaction is a sequence of statements that are executed as a single unit of work. Transactions typically follow the A.C.I.D. principle: +[arabic] +. **Atomicity** ensures that a transaction is treated as a single, indivisible unit; either all operations within the +transaction are completed successfully, or none of them are applied. +. **Consistency** ensures that a transaction brings the database from one valid state to another, maintaining all +predefined rules and constraints. +. **Isolation** ensures that transactions operate independently of one another, so that intermediate states of a +transaction are not visible to others. +. **Durability** guarantees that once a transaction has been committed, its changes are permanent. + +== Choosing Transaction Management Model + +Spring offers two models of transaction management: **declarative** and **programmatic**. When choosing between them, +consider the complexity and requirements of your application. + +**Declarative transaction management** is typically preferred for its simplicity and ease of maintenance, as it allows +to define transaction boundaries using annotations without altering the business logic code. +This model suits for most applications where transaction boundaries are straightforward and the business logic +does not require intricate transaction control. + +**Programmatic transaction management** is chosen when you need more fine-grained control over transactions, +such as handling complex transaction scenarios. +This approach is useful in situations where specific transaction behavior needs to be dynamically adjusted +or when integrating with legacy code that requires explicit transaction management. When using this approach, +it is possible to explicitly start, commit, and rollback transactions within the code if needed. + +In general, declarative management is more straightforward and reduces boilerplate code, +while programmatic management offers more control but at the cost of increased complexity. + +== Declarative Transaction Management + +Declarative transaction management uses annotations to define transaction boundaries and behavior without changing +the business logic code. It’s usually more common in Spring applications due to its simplicity and ease of use. + +You can annotate methods and/or classes with `@Transactional` to automatically handle transactions, including +committing or rolling back based on execution. + +Couple other things needed to start working with transactions using declarative approach: +[arabic] +. A transaction manager must be specified in your Spring Configuration. +. Spring Configuration must be annotated with the `@EnableTransactionManagement` annotation. + +=== Example + +Here is an example that shows applying `@Transactional` to a method. +It ensures that the entire method runs within a transaction context, and Spring manages the transaction lifecycle +(automatically committing the transaction if the method succeeds or rolling back if it encounters an exception). + +[source,java] +---- +@Configuration +@EnableTransactionManagement +public class Config { + + @Bean + public AerospikeTransactionManager aerospikeTransactionManager(IAerospikeClient client) { + return new AerospikeTransactionManager(client); + } + + // Other configuration +} + +@Service +public class MyService { + + @Transactional + public void performDatabaseOperations() { + // Perform database operations + } +} +---- + +== Programmatic Transaction Management + +Programmatic transaction management gives developers fine-grained control over transactions through code. +This approach involves manually managing transactions using Spring’s API. + +The Spring Framework offers two ways for programmatic transaction management: + +[arabic] +. Using `TransactionTemplate` or `TransactionalOperator` which use callback approach +(for programmatic transaction management in imperative code it is typically recommended to use `TransactionTemplate`; +for reactive code, `TransactionalOperator` is preferred). +. Directly using a `TransactionManager` implementation. + +=== Example + +Here is an example that shows using a programmatic transaction in a method. +You would use `TransactionTemplate` to wrap your database operations in a transaction block, +ensuring the transaction is automatically committed if successful or rolled back if an exception occurs. + +[source,java] +---- +@Configuration +public class Config { + + @Bean + public AerospikeTransactionManager aerospikeTransactionManager(IAerospikeClient client) { + return new AerospikeTransactionManager(client); + } + + @Bean + public TransactionTemplate transactionTemplate(AerospikeTransactionManager transactionManager) { + return new TransactionTemplate(transactionManager); + } + + // Other configuration +} + +@Service +public class MyService { + + @Autowired + TransactionTemplate transactionTemplate; + + public void performDatabaseOperations() { + transactionTemplate.executeWithoutResult(status -> { + // Perform database operations + }); + } +} +---- + +== Aerospike Operations Support + +Behind the curtains Aerospike transaction manager uses MRTs (multi-record transactions) +which is an Aerospike feature allowing to group together multiple Aerospike operation requests +into a single transaction. + +NOTE: Not all of the Aerospike operations can participate in transactions. + +Here is a list of Aerospike operations that participate in transactions: + +[arabic] +. all single record operations (`insert`, `save`, `update`, `add`, `append`, `persist`, `findById`, `exists`, `delete`) +. all batch operations without query (`insertAll`, `saveAll`, `findByIds`, `deleteAll`) +. queries that include `id` (e.g., repository queries like `findByIdAndName`) + +The following operations do not participate in transactions +(will not become part of a transaction if included into it): + +[arabic] +. `truncate` +. queries that do not include `id` (e.g., repository queries like `findByName`) +. operations that perform info commands (e.g., `indexExists`) +. operations that perform scans (using ScanPolicy) diff --git a/src/main/java/org/springframework/data/aerospike/config/AbstractAerospikeDataConfiguration.java b/src/main/java/org/springframework/data/aerospike/config/AbstractAerospikeDataConfiguration.java index bdff2148..db198c87 100644 --- a/src/main/java/org/springframework/data/aerospike/config/AbstractAerospikeDataConfiguration.java +++ b/src/main/java/org/springframework/data/aerospike/config/AbstractAerospikeDataConfiguration.java @@ -72,7 +72,7 @@ public QueryEngine queryEngine(IAerospikeClient aerospikeClient, return queryEngine; } - @Bean(name = "aerospikePersistenceEntityIndexCreator") + @Bean public AerospikePersistenceEntityIndexCreator aerospikePersistenceEntityIndexCreator( ObjectProvider aerospikeMappingContext, AerospikeIndexResolver aerospikeIndexResolver, diff --git a/src/main/java/org/springframework/data/aerospike/config/AbstractReactiveAerospikeDataConfiguration.java b/src/main/java/org/springframework/data/aerospike/config/AbstractReactiveAerospikeDataConfiguration.java index 4da5b1eb..501512d3 100644 --- a/src/main/java/org/springframework/data/aerospike/config/AbstractReactiveAerospikeDataConfiguration.java +++ b/src/main/java/org/springframework/data/aerospike/config/AbstractReactiveAerospikeDataConfiguration.java @@ -105,8 +105,8 @@ protected ClientPolicy getClientPolicy() { return clientPolicy; } - @Bean(name = "reactiveAerospikePersistenceEntityIndexCreator") - public ReactiveAerospikePersistenceEntityIndexCreator reactiveAerospikePersistenceEntityIndexCreator( + @Bean + public ReactiveAerospikePersistenceEntityIndexCreator aerospikePersistenceEntityIndexCreator( ObjectProvider aerospikeMappingContext, AerospikeIndexResolver aerospikeIndexResolver, ObjectProvider template, AerospikeSettings settings) { diff --git a/src/main/java/org/springframework/data/aerospike/core/AerospikeTemplate.java b/src/main/java/org/springframework/data/aerospike/core/AerospikeTemplate.java index 7b1aea89..4961674c 100644 --- a/src/main/java/org/springframework/data/aerospike/core/AerospikeTemplate.java +++ b/src/main/java/org/springframework/data/aerospike/core/AerospikeTemplate.java @@ -76,9 +76,11 @@ import static org.springframework.data.aerospike.core.CoreUtils.getDistinctPredicate; import static org.springframework.data.aerospike.core.CoreUtils.operations; import static org.springframework.data.aerospike.core.CoreUtils.verifyUnsortedWithOffset; +import static org.springframework.data.aerospike.core.TemplateUtils.checkForTransaction; import static org.springframework.data.aerospike.core.TemplateUtils.excludeIdQualifier; import static org.springframework.data.aerospike.core.TemplateUtils.getBinNamesFromTargetClass; import static org.springframework.data.aerospike.core.TemplateUtils.getIdValue; +import static org.springframework.data.aerospike.core.TemplateUtils.getPolicyFilterExpOrDefault; import static org.springframework.data.aerospike.query.QualifierUtils.getIdQualifier; import static org.springframework.data.aerospike.query.QualifierUtils.queryCriteriaIsNotNull; @@ -145,17 +147,17 @@ public void save(T document, String setName) { AerospikeWriteData data = writeData(document, setName); AerospikePersistentEntity entity = mappingContext.getRequiredPersistentEntity(document.getClass()); if (entity.hasVersionProperty()) { - WritePolicy policy = expectGenerationCasAwarePolicy(data); + WritePolicy writePolicy = expectGenerationCasAwarePolicy(data); // mimicking REPLACE behavior by firstly deleting bins due to bin convergence feature restrictions - doPersistWithVersionAndHandleCasError(document, data, policy, true, SAVE_OPERATION); + doPersistWithVersionAndHandleCasError(document, data, writePolicy, true, SAVE_OPERATION); } else { - WritePolicy policy = ignoreGenerationPolicy(data, RecordExistsAction.UPDATE); + WritePolicy writePolicy = ignoreGenerationPolicy(data, RecordExistsAction.UPDATE); // mimicking REPLACE behavior by firstly deleting bins due to bin convergence feature restrictions Operation[] operations = operations(data.getBinsAsArray(), Operation::put, Operation.array(Operation.delete())); - doPersistAndHandleError(data, policy, operations); + doPersistAndHandleError(data, writePolicy, operations); } } @@ -206,8 +208,8 @@ private void batchWriteAllDocuments(List documents, String setName, Opera List batchWriteRecords = batchWriteDataList.stream().map(BatchWriteData::batchRecord).toList(); try { - // requires server ver. >= 6.0.0 - client.operate(null, batchWriteRecords); + BatchPolicy bPolicy = (BatchPolicy) checkForTransaction(client, client.getBatchPolicyDefault()); + client.operate(bPolicy, batchWriteRecords); } catch (AerospikeException e) { throw translateError(e); // no exception is thrown for versions mismatch, only record's result code shows it } @@ -259,7 +261,7 @@ public void insert(T document, String setName) { Assert.notNull(setName, "Set name must not be null!"); AerospikeWriteData data = writeData(document, setName); - WritePolicy policy = ignoreGenerationPolicy(data, RecordExistsAction.CREATE_ONLY); + WritePolicy writePolicy = ignoreGenerationPolicy(data, RecordExistsAction.CREATE_ONLY); AerospikePersistentEntity entity = mappingContext.getRequiredPersistentEntity(document.getClass()); if (entity.hasVersionProperty()) { // we are ignoring generation here as insert operation should fail with DuplicateKeyException if key @@ -268,10 +270,10 @@ public void insert(T document, String setName) { // in the original document // also we do not want to handle aerospike error codes as cas aware error codes as we are ignoring // generation - doPersistWithVersionAndHandleError(document, data, policy); + doPersistWithVersionAndHandleError(document, data, writePolicy); } else { Operation[] operations = operations(data.getBinsAsArray(), Operation::put); - doPersistAndHandleError(data, policy, operations); + doPersistAndHandleError(data, writePolicy, operations); } } @@ -291,22 +293,22 @@ public void insertAll(Iterable documents, String setName) { } @Override - public void persist(T document, WritePolicy policy) { + public void persist(T document, WritePolicy writePolicy) { Assert.notNull(document, "Document must not be null!"); - Assert.notNull(policy, "Policy must not be null!"); - persist(document, policy, getSetName(document)); + Assert.notNull(writePolicy, "Policy must not be null!"); + persist(document, writePolicy, getSetName(document)); } @Override - public void persist(T document, WritePolicy policy, String setName) { + public void persist(T document, WritePolicy writePolicy, String setName) { Assert.notNull(document, "Document must not be null!"); - Assert.notNull(policy, "Policy must not be null!"); + Assert.notNull(writePolicy, "Policy must not be null!"); Assert.notNull(setName, "Set name must not be null!"); AerospikeWriteData data = writeData(document, setName); Operation[] operations = operations(data.getBinsAsArray(), Operation::put); - doPersistAndHandleError(data, policy, operations); + doPersistAndHandleError(data, writePolicy, operations); } @Override @@ -323,17 +325,17 @@ public void update(T document, String setName) { AerospikeWriteData data = writeData(document, setName); AerospikePersistentEntity entity = mappingContext.getRequiredPersistentEntity(document.getClass()); if (entity.hasVersionProperty()) { - WritePolicy policy = expectGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); + WritePolicy writePolicy = expectGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); // mimicking REPLACE_ONLY behavior by firstly deleting bins due to bin convergence feature restrictions - doPersistWithVersionAndHandleCasError(document, data, policy, true, UPDATE_OPERATION); + doPersistWithVersionAndHandleCasError(document, data, writePolicy, true, UPDATE_OPERATION); } else { - WritePolicy policy = ignoreGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); + WritePolicy writePolicy = ignoreGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); // mimicking REPLACE_ONLY behavior by firstly deleting bins due to bin convergence feature restrictions Operation[] operations = Stream.concat(Stream.of(Operation.delete()), data.getBins().stream() .map(Operation::put)).toArray(Operation[]::new); - doPersistAndHandleError(data, policy, operations); + doPersistAndHandleError(data, writePolicy, operations); } } @@ -351,14 +353,14 @@ public void update(T document, String setName, Collection fields) { AerospikeWriteData data = writeDataWithSpecificFields(document, setName, fields); AerospikePersistentEntity entity = mappingContext.getRequiredPersistentEntity(document.getClass()); if (entity.hasVersionProperty()) { - WritePolicy policy = expectGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); + WritePolicy writePolicy = expectGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); - doPersistWithVersionAndHandleCasError(document, data, policy, false, UPDATE_OPERATION); + doPersistWithVersionAndHandleCasError(document, data, writePolicy, false, UPDATE_OPERATION); } else { - WritePolicy policy = ignoreGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); + WritePolicy writePolicy = ignoreGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); Operation[] operations = operations(data.getBinsAsArray(), Operation::put); - doPersistAndHandleError(data, policy, operations); + doPersistAndHandleError(data, writePolicy, operations); } } @@ -398,7 +400,9 @@ public boolean delete(T document, String setName) { private boolean doDeleteWithVersionAndHandleCasError(AerospikeWriteData data) { try { - return client.delete(expectGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY), data.getKey()); + WritePolicy writePolicy = expectGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); + writePolicy = (WritePolicy) checkForTransaction(client, writePolicy); + return client.delete(writePolicy, data.getKey()); } catch (AerospikeException e) { throw translateCasError(e, "Failed to delete record due to versions mismatch"); } @@ -406,7 +410,9 @@ private boolean doDeleteWithVersionAndHandleCasError(AerospikeWriteData data) { private boolean doDeleteIgnoreVersionAndTranslateError(AerospikeWriteData data) { try { - return client.delete(ignoreGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY), data.getKey()); + WritePolicy writePolicy = ignoreGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); + writePolicy = (WritePolicy) checkForTransaction(client, writePolicy); + return client.delete(writePolicy, data.getKey()); } catch (AerospikeException e) { throw translateError(e); } @@ -425,8 +431,8 @@ public boolean deleteById(Object id, String setName) { try { Key key = getKey(id, setName); - - return client.delete(ignoreGenerationPolicy(), key); + WritePolicy writePolicy = (WritePolicy) checkForTransaction(client, ignoreGenerationPolicy()); + return client.delete(writePolicy, key); } catch (AerospikeException e) { throw translateError(e); } @@ -587,8 +593,8 @@ public void deleteAll(String setName, Instant beforeLastUpdate) { private void deleteAndHandleErrors(IAerospikeClient client, Key[] keys) { BatchResults results; try { - // requires server ver. >= 6.0.0 - results = client.delete(null, null, keys); + BatchPolicy batchPolicy = (BatchPolicy) checkForTransaction(client, client.getBatchPolicyDefault()); + results = client.delete(batchPolicy, null, keys); } catch (AerospikeException e) { throw translateError(e); } @@ -624,6 +630,7 @@ public T add(T document, String setName, Map values) { WritePolicy writePolicy = WritePolicyBuilder.builder(client.getWritePolicyDefault()) .expiration(data.getExpiration()) .build(); + writePolicy = (WritePolicy) checkForTransaction(client, writePolicy); Record aeroRecord = client.operate(writePolicy, data.getKey(), ops); @@ -650,6 +657,7 @@ public T add(T document, String setName, String binName, long value) { WritePolicy writePolicy = WritePolicyBuilder.builder(client.getWritePolicyDefault()) .expiration(data.getExpiration()) .build(); + writePolicy = (WritePolicy) checkForTransaction(client, writePolicy); Record aeroRecord = client.operate(writePolicy, data.getKey(), Operation.add(new Bin(binName, value)), Operation.get()); @@ -674,7 +682,8 @@ public T append(T document, String setName, Map values) { try { AerospikeWriteData data = writeData(document, setName); Operation[] ops = operations(values, Operation.Type.APPEND, Operation.get()); - Record aeroRecord = client.operate(null, data.getKey(), ops); + WritePolicy writePolicy = (WritePolicy) checkForTransaction(client, client.getWritePolicyDefault()); + Record aeroRecord = client.operate(writePolicy, data.getKey(), ops); return mapToEntity(data.getKey(), getEntityClass(document), aeroRecord); } catch (AerospikeException e) { @@ -695,7 +704,8 @@ public T append(T document, String setName, String binName, String value) { try { AerospikeWriteData data = writeData(document, setName); - Record aeroRecord = client.operate(null, data.getKey(), + WritePolicy writePolicy = (WritePolicy) checkForTransaction(client, client.getWritePolicyDefault()); + Record aeroRecord = client.operate(writePolicy, data.getKey(), Operation.append(new Bin(binName, value)), Operation.get(binName)); @@ -718,7 +728,8 @@ public T prepend(T document, String setName, String fieldName, String value) try { AerospikeWriteData data = writeData(document, setName); - Record aeroRecord = client.operate(null, data.getKey(), + WritePolicy writePolicy = (WritePolicy) checkForTransaction(client, client.getWritePolicyDefault()); + Record aeroRecord = client.operate(writePolicy, data.getKey(), Operation.prepend(new Bin(fieldName, value)), Operation.get(fieldName)); @@ -742,7 +753,8 @@ public T prepend(T document, String setName, Map values) { try { AerospikeWriteData data = writeData(document, setName); Operation[] ops = operations(values, Operation.Type.PREPEND, Operation.get()); - Record aeroRecord = client.operate(null, data.getKey(), ops); + WritePolicy writePolicy = (WritePolicy) checkForTransaction(client, client.getWritePolicyDefault()); + Record aeroRecord = client.operate(writePolicy, data.getKey(), ops); return mapToEntity(data.getKey(), getEntityClass(document), aeroRecord); } catch (AerospikeException e) { @@ -790,24 +802,24 @@ public S findById(Object id, Class entityClass, Class targetClass, return (S) findByIdUsingQuery(id, entityClass, targetClass, setName, null); } - private Record getRecord(AerospikePersistentEntity entity, Key key, Query query) { + private Record getRecord(AerospikePersistentEntity entity, Key key, @Nullable Query query) { Record aeroRecord; if (entity.isTouchOnRead()) { Assert.state(!entity.hasExpirationProperty(), "Touch on read is not supported for expiration property"); aeroRecord = getAndTouch(key, entity.getExpiration(), null, null); } else { - Policy policy = getPolicyFilterExp(query); - aeroRecord = getAerospikeClient().get(policy, key); + Policy policy = checkForTransaction(client, getPolicyFilterExpOrDefault(client, queryEngine, query)); + aeroRecord = client.get(policy, key); } return aeroRecord; } private BatchPolicy getBatchPolicyFilterExp(Query query) { if (queryCriteriaIsNotNull(query)) { - BatchPolicy policy = new BatchPolicy(getAerospikeClient().getBatchPolicyDefault()); + BatchPolicy batchPolicy = new BatchPolicy(getAerospikeClient().getBatchPolicyDefault()); Qualifier qualifier = query.getCriteriaObject(); - policy.filterExp = queryEngine.getFilterExpressionsBuilder().build(qualifier); - return policy; + batchPolicy.filterExp = queryEngine.getFilterExpressionsBuilder().build(qualifier); + return batchPolicy; } return null; } @@ -819,15 +831,15 @@ private Key[] getKeys(Collection ids, String setName) { } private Object getRecordMapToTargetClass(AerospikePersistentEntity entity, Key key, Class targetClass, - Query query) { + @Nullable Query query) { Record aeroRecord; String[] binNames = getBinNamesFromTargetClass(targetClass, mappingContext); if (entity.isTouchOnRead()) { Assert.state(!entity.hasExpirationProperty(), "Touch on read is not supported for expiration property"); aeroRecord = getAndTouch(key, entity.getExpiration(), binNames, query); } else { - Policy policy = getPolicyFilterExp(query); - aeroRecord = getAerospikeClient().get(policy, key, binNames); + Policy policy = checkForTransaction(client, getPolicyFilterExpOrDefault(client, queryEngine, query)); + aeroRecord = client.get(policy, key, binNames); } return mapToEntity(key, targetClass, aeroRecord); } @@ -842,7 +854,7 @@ private Policy getPolicyFilterExp(Query query) { return null; } - private Record getAndTouch(Key key, int expiration, String[] binNames, Query query) { + private Record getAndTouch(Key key, int expiration, String[] binNames, @Nullable Query query) { WritePolicyBuilder writePolicyBuilder = WritePolicyBuilder.builder(client.getWritePolicyDefault()) .expiration(expiration); @@ -851,6 +863,7 @@ private Record getAndTouch(Key key, int expiration, String[] binNames, Query que writePolicyBuilder.filterExp(queryEngine.getFilterExpressionsBuilder().build(qualifier)); } WritePolicy writePolicy = writePolicyBuilder.build(); + writePolicy = (WritePolicy) checkForTransaction(client, writePolicy); try { if (binNames == null || binNames.length == 0) { @@ -931,7 +944,8 @@ public GroupedEntities findByIds(GroupedKeys groupedKeys) { private GroupedEntities findGroupedEntitiesByGroupedKeys(GroupedKeys groupedKeys) { EntitiesKeys entitiesKeys = EntitiesKeys.of(toEntitiesKeyMap(groupedKeys)); - Record[] aeroRecords = client.get(null, entitiesKeys.getKeys()); + BatchPolicy bPolicy = (BatchPolicy) checkForTransaction(client, client.getBatchPolicyDefault()); + Record[] aeroRecords = client.get(bPolicy, entitiesKeys.getKeys()); return toGroupedEntities(entitiesKeys, aeroRecords); } @@ -946,7 +960,7 @@ public Object findByIdUsingQuery(Object id, Class entityClass, Class Object findByIdUsingQuery(Object id, Class entityClass, Class targetClass, String setName, - Query query) { + @Nullable Query query) { Assert.notNull(id, "Id must not be null!"); Assert.notNull(entityClass, "Entity class must not be null!"); Assert.notNull(setName, "Set name must not be null!"); @@ -986,24 +1000,22 @@ public List findByIdsUsingQuery(Collection ids, Class entityClas .map(id -> getKey(id, setName)) .toArray(Key[]::new); - BatchPolicy policy = getBatchPolicyFilterExp(query); - + BatchPolicy batchPolicy = (BatchPolicy) checkForTransaction(client, getBatchPolicyFilterExp(query)); Class target; Record[] aeroRecords; if (targetClass != null && targetClass != entityClass) { String[] binNames = getBinNamesFromTargetClass(targetClass, mappingContext); - aeroRecords = getAerospikeClient().get(policy, keys, binNames); + aeroRecords = client.get(batchPolicy, keys, binNames); target = targetClass; } else { - aeroRecords = getAerospikeClient().get(policy, keys); + aeroRecords = client.get(batchPolicy, keys); target = entityClass; } - Stream results = IntStream.range(0, keys.length) + return IntStream.range(0, keys.length) .filter(index -> aeroRecords[index] != null) - .mapToObj(index -> mapToEntity(keys[index], target, aeroRecords[index])); - - return applyPostProcessingOnResults(results, query).collect(Collectors.toList()); + .mapToObj(index -> mapToEntity(keys[index], target, aeroRecords[index])) + .collect(Collectors.toList()); } catch (AerospikeException e) { throw translateError(e); } @@ -1022,10 +1034,10 @@ public IntStream findByIdsUsingQueryWithoutMapping(Collection ids, String set .toArray(Key[]::new); } - BatchPolicy policy = getBatchPolicyFilterExp(query); + BatchPolicy batchPolicy = getBatchPolicyFilterExp(query); Record[] aeroRecords; - aeroRecords = getAerospikeClient().get(policy, keys); + aeroRecords = getAerospikeClient().get(batchPolicy, keys); return IntStream.range(0, keys.length) .filter(index -> aeroRecords[index] != null); @@ -1158,7 +1170,8 @@ public boolean exists(Object id, String setName) { try { Key key = getKey(id, setName); - Record aeroRecord = client.operate(null, key, Operation.getHeader()); + WritePolicy writePolicy = (WritePolicy) checkForTransaction(client, client.getWritePolicyDefault()); + Record aeroRecord = client.operate(writePolicy, key, Operation.getHeader()); return aeroRecord != null; } catch (AerospikeException e) { throw translateError(e); @@ -1411,38 +1424,40 @@ public boolean indexExists(String indexName) { return false; } - private Record doPersistAndHandleError(AerospikeWriteData data, WritePolicy policy, Operation[] operations) { + private Record doPersistAndHandleError(AerospikeWriteData data, WritePolicy writePolicy, Operation[] operations) { try { - return client.operate(policy, data.getKey(), operations); + writePolicy = (WritePolicy) checkForTransaction(client, writePolicy); + return client.operate(writePolicy, data.getKey(), operations); } catch (AerospikeException e) { throw translateError(e); } } - private void doPersistWithVersionAndHandleCasError(T document, AerospikeWriteData data, WritePolicy policy, + private void doPersistWithVersionAndHandleCasError(T document, AerospikeWriteData data, WritePolicy writePolicy, boolean firstlyDeleteBins, OperationType operationType) { try { - Record newAeroRecord = putAndGetHeader(data, policy, firstlyDeleteBins); + Record newAeroRecord = putAndGetHeader(data, writePolicy, firstlyDeleteBins); updateVersion(document, newAeroRecord); } catch (AerospikeException e) { throw translateCasError(e, "Failed to " + operationType.toString() + " record due to versions mismatch"); } } - private void doPersistWithVersionAndHandleError(T document, AerospikeWriteData data, WritePolicy policy) { + private void doPersistWithVersionAndHandleError(T document, AerospikeWriteData data, WritePolicy writePolicy) { try { - Record newAeroRecord = putAndGetHeader(data, policy, false); + Record newAeroRecord = putAndGetHeader(data, writePolicy, false); updateVersion(document, newAeroRecord); } catch (AerospikeException e) { throw translateError(e); } } - private Record putAndGetHeader(AerospikeWriteData data, WritePolicy policy, boolean firstlyDeleteBins) { + private Record putAndGetHeader(AerospikeWriteData data, WritePolicy writePolicy, boolean firstlyDeleteBins) { Key key = data.getKey(); Operation[] operations = getPutAndGetHeaderOperations(data, firstlyDeleteBins); + writePolicy = (WritePolicy) checkForTransaction(client, writePolicy); - return client.operate(policy, key, operations); + return client.operate(writePolicy, key, operations); } @SuppressWarnings("SameParameterValue") @@ -1525,14 +1540,13 @@ private List findByIdsWithoutMapping(Collection ids, String setNam try { Key[] keys = getKeys(ids, setName); - BatchPolicy policy = getBatchPolicyFilterExp(query); - + BatchPolicy bPolicy = (BatchPolicy) checkForTransaction(client, getBatchPolicyFilterExp(query)); Record[] aeroRecords; if (targetClass != null) { String[] binNames = getBinNamesFromTargetClass(targetClass, mappingContext); - aeroRecords = getAerospikeClient().get(policy, keys, binNames); + aeroRecords = client.get(bPolicy, keys, binNames); } else { - aeroRecords = getAerospikeClient().get(policy, keys); + aeroRecords = client.get(bPolicy, keys); } return IntStream.range(0, keys.length) diff --git a/src/main/java/org/springframework/data/aerospike/core/ReactiveAerospikeTemplate.java b/src/main/java/org/springframework/data/aerospike/core/ReactiveAerospikeTemplate.java index bf29f13f..8d68ecc0 100644 --- a/src/main/java/org/springframework/data/aerospike/core/ReactiveAerospikeTemplate.java +++ b/src/main/java/org/springframework/data/aerospike/core/ReactiveAerospikeTemplate.java @@ -82,6 +82,7 @@ import static org.springframework.data.aerospike.core.BaseAerospikeTemplate.OperationType.UPDATE_OPERATION; import static org.springframework.data.aerospike.core.CoreUtils.getDistinctPredicate; import static org.springframework.data.aerospike.core.CoreUtils.operations; +import static org.springframework.data.aerospike.core.TemplateUtils.enrichPolicyWithTransaction; import static org.springframework.data.aerospike.core.TemplateUtils.excludeIdQualifier; import static org.springframework.data.aerospike.core.TemplateUtils.getIdValue; import static org.springframework.data.aerospike.query.QualifierUtils.getIdQualifier; @@ -136,19 +137,19 @@ public Mono save(T document, String setName) { AerospikeWriteData data = writeData(document, setName); AerospikePersistentEntity entity = mappingContext.getRequiredPersistentEntity(document.getClass()); if (entity.hasVersionProperty()) { - WritePolicy policy = expectGenerationCasAwarePolicy(data); + WritePolicy writePolicy = expectGenerationCasAwarePolicy(data); // mimicking REPLACE behavior by firstly deleting bins due to bin convergence feature restrictions Operation[] operations = operations(data.getBinsAsArray(), Operation::put, Operation.array(Operation.delete())); - return doPersistWithVersionAndHandleCasError(document, data, policy, operations, SAVE_OPERATION); + return doPersistWithVersionAndHandleCasError(document, data, writePolicy, operations, SAVE_OPERATION); } else { - WritePolicy policy = ignoreGenerationPolicy(data, RecordExistsAction.UPDATE); + WritePolicy writePolicy = ignoreGenerationPolicy(data, RecordExistsAction.UPDATE); // mimicking REPLACE behavior by firstly deleting bins due to bin convergence feature restrictions Operation[] operations = operations(data.getBinsAsArray(), Operation::put, Operation.array(Operation.delete())); - return doPersistAndHandleError(document, data, policy, operations); + return doPersistAndHandleError(document, data, writePolicy, operations); } } @@ -203,14 +204,17 @@ private Flux batchWriteAllDocuments(List documents, String setName, Op List batchWriteRecords = batchWriteDataList.stream().map(BatchWriteData::batchRecord).toList(); - return batchWriteAndCheckForErrors(batchWriteRecords, batchWriteDataList, operationType); + return enrichPolicyWithTransaction(reactorClient, reactorClient.getBatchPolicyDefault()) + .flatMapMany(batchPolicyEnriched -> + batchWriteAndCheckForErrors((BatchPolicy) batchPolicyEnriched, batchWriteRecords, batchWriteDataList, + operationType)); } - private Flux batchWriteAndCheckForErrors(List batchWriteRecords, + private Flux batchWriteAndCheckForErrors(BatchPolicy batchPolicy, List batchWriteRecords, List> batchWriteDataList, OperationType operationType) { - // requires server ver. >= 6.0.0 - return reactorClient.operate(null, batchWriteRecords) + return reactorClient + .operate(batchPolicy, batchWriteRecords) .onErrorMap(this::translateError) .flatMap(ignore -> checkForErrorsAndUpdateVersion(batchWriteDataList, batchWriteRecords, operationType)) .flux() @@ -263,7 +267,7 @@ public Mono insert(T document, String setName) { Assert.notNull(setName, "Set name must not be null!"); AerospikeWriteData data = writeData(document, setName); - WritePolicy policy = ignoreGenerationPolicy(data, RecordExistsAction.CREATE_ONLY); + WritePolicy writePolicy = ignoreGenerationPolicy(data, RecordExistsAction.CREATE_ONLY); AerospikePersistentEntity entity = mappingContext.getRequiredPersistentEntity(document.getClass()); if (entity.hasVersionProperty()) { @@ -275,10 +279,10 @@ public Mono insert(T document, String setName) { // generation Operation[] operations = operations(data.getBinsAsArray(), Operation::put, null, Operation.array(Operation.getHeader())); - return doPersistWithVersionAndHandleError(document, data, policy, operations); + return doPersistWithVersionAndHandleError(document, data, writePolicy, operations); } else { Operation[] operations = operations(data.getBinsAsArray(), Operation::put); - return doPersistAndHandleError(document, data, policy, operations); + return doPersistAndHandleError(document, data, writePolicy, operations); } } @@ -298,22 +302,24 @@ public Flux insertAll(Iterable documents, String setName) { } @Override - public Mono persist(T document, WritePolicy policy) { + public Mono persist(T document, WritePolicy writePolicy) { Assert.notNull(document, "Document must not be null!"); - Assert.notNull(policy, "Policy must not be null!"); - return persist(document, policy, getSetName(document)); + Assert.notNull(writePolicy, "Policy must not be null!"); + return persist(document, writePolicy, getSetName(document)); } @Override - public Mono persist(T document, WritePolicy policy, String setName) { + public Mono persist(T document, WritePolicy writePolicy, String setName) { Assert.notNull(document, "Document must not be null!"); - Assert.notNull(policy, "Policy must not be null!"); + Assert.notNull(writePolicy, "Policy must not be null!"); Assert.notNull(setName, "Set name must not be null!"); AerospikeWriteData data = writeData(document, setName); Operation[] operations = operations(data.getBinsAsArray(), Operation::put); - return doPersistAndHandleError(document, data, policy, operations); + return enrichPolicyWithTransaction(reactorClient, writePolicy) + .flatMap(writePolicyEnriched -> + doPersistAndHandleError(document, data, (WritePolicy) writePolicyEnriched, operations)); } @Override @@ -329,19 +335,19 @@ public Mono update(T document, String setName) { AerospikeWriteData data = writeData(document, setName); AerospikePersistentEntity entity = mappingContext.getRequiredPersistentEntity(document.getClass()); if (entity.hasVersionProperty()) { - WritePolicy policy = expectGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); + WritePolicy writePolicy = expectGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); // mimicking REPLACE_ONLY behavior by firstly deleting bins due to bin convergence feature restrictions Operation[] operations = operations(data.getBinsAsArray(), Operation::put, Operation.array(Operation.delete()), Operation.array(Operation.getHeader())); - return doPersistWithVersionAndHandleCasError(document, data, policy, operations, UPDATE_OPERATION); + return doPersistWithVersionAndHandleCasError(document, data, writePolicy, operations, UPDATE_OPERATION); } else { - WritePolicy policy = ignoreGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); + WritePolicy writePolicy = ignoreGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); // mimicking REPLACE_ONLY behavior by firstly deleting bins due to bin convergence feature restrictions Operation[] operations = operations(data.getBinsAsArray(), Operation::put, Operation.array(Operation.delete())); - return doPersistAndHandleError(document, data, policy, operations); + return doPersistAndHandleError(document, data, writePolicy, operations); } } @@ -359,16 +365,16 @@ public Mono update(T document, String setName, Collection fields) AerospikeWriteData data = writeDataWithSpecificFields(document, setName, fields); AerospikePersistentEntity entity = mappingContext.getRequiredPersistentEntity(document.getClass()); if (entity.hasVersionProperty()) { - WritePolicy policy = expectGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); + WritePolicy writePolicy = expectGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); Operation[] operations = operations(data.getBinsAsArray(), Operation::put, null, Operation.array(Operation.getHeader())); - return doPersistWithVersionAndHandleCasError(document, data, policy, operations, UPDATE_OPERATION); + return doPersistWithVersionAndHandleCasError(document, data, writePolicy, operations, UPDATE_OPERATION); } else { - WritePolicy policy = ignoreGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); + WritePolicy writePolicy = ignoreGenerationPolicy(data, RecordExistsAction.UPDATE_ONLY); Operation[] operations = operations(data.getBinsAsArray(), Operation::put); - return doPersistAndHandleError(document, data, policy, operations); + return doPersistAndHandleError(document, data, writePolicy, operations); } } @@ -400,13 +406,13 @@ public Mono delete(T document, String setName) { AerospikeWriteData data = writeData(document, setName); AerospikePersistentEntity entity = mappingContext.getRequiredPersistentEntity(document.getClass()); if (entity.hasVersionProperty()) { - return reactorClient - .delete(expectGenerationPolicy(data), data.getKey()) + return enrichPolicyWithTransaction(reactorClient, expectGenerationPolicy(data)) + .flatMap(writePolicyEnriched -> reactorClient.delete((WritePolicy) writePolicyEnriched, data.getKey())) .hasElement() .onErrorMap(e -> translateCasThrowable(e, DELETE_OPERATION.toString())); } - return reactorClient - .delete(ignoreGenerationPolicy(), data.getKey()) + return enrichPolicyWithTransaction(reactorClient, ignoreGenerationPolicy()) + .flatMap(writePolicyEnriched -> reactorClient.delete((WritePolicy) writePolicyEnriched, data.getKey())) .hasElement() .onErrorMap(this::translateError); } @@ -422,7 +428,7 @@ public Mono delete(Query query, Class entityClass, String setName) return findQueryResults.flatMap(list -> { if (!list.isEmpty()) { - return deleteAll(list); + return deleteAll(list); } return Mono.empty(); } @@ -471,8 +477,9 @@ public Mono deleteById(Object id, String setName) { Assert.notNull(id, "Id must not be null!"); Assert.notNull(setName, "Set name must not be null!"); - return reactorClient - .delete(ignoreGenerationPolicy(), getKey(id, setName)) + return enrichPolicyWithTransaction(reactorClient, ignoreGenerationPolicy()) + .flatMap(writePolicyEnriched -> + reactorClient.delete((WritePolicy) writePolicyEnriched, getKey(id, setName))) .map(k -> true) .onErrorMap(this::translateError); } @@ -548,8 +555,8 @@ private Mono batchDeleteAndCheckForErrors(IAerospikeReactorClient reactorC return Mono.empty(); }; - // requires server ver. >= 6.0.0 - return reactorClient.delete(null, null, keys) + return enrichPolicyWithTransaction(reactorClient, reactorClient.getBatchPolicyDefault()) + .flatMap(batchPolicy -> reactorClient.delete((BatchPolicy) batchPolicy, null, keys)) .onErrorMap(this::translateError) .flatMap(checkForErrors); } @@ -568,7 +575,8 @@ public Mono deleteByIds(GroupedKeys groupedKeys) { private Mono deleteEntitiesByGroupedKeys(GroupedKeys groupedKeys) { EntitiesKeys entitiesKeys = EntitiesKeys.of(toEntitiesKeyMap(groupedKeys)); - reactorClient.delete(null, null, entitiesKeys.getKeys()) + enrichPolicyWithTransaction(reactorClient, reactorClient.getBatchPolicyDefault()) + .flatMap(batchPolicy -> reactorClient.delete((BatchPolicy) batchPolicy, null, entitiesKeys.getKeys())) .doOnError(this::translateError); return batchDeleteAndCheckForErrors(reactorClient, entitiesKeys.getKeys()); @@ -633,7 +641,9 @@ public Mono add(T document, String setName, Map values) { .expiration(data.getExpiration()) .build(); - return executeOperationsOnValue(document, data, operations, writePolicy); + return enrichPolicyWithTransaction(reactorClient, writePolicy) + .flatMap(writePolicyEnriched -> + executeOperationsOnValue(document, data, (WritePolicy) writePolicyEnriched, operations)); } @Override @@ -654,7 +664,9 @@ public Mono add(T document, String setName, String binName, long value) { .build(); Operation[] operations = {Operation.add(new Bin(binName, value)), Operation.get(binName)}; - return executeOperationsOnValue(document, data, operations, writePolicy); + return enrichPolicyWithTransaction(reactorClient, writePolicy) + .flatMap(writePolicyEnriched -> + executeOperationsOnValue(document, data, (WritePolicy) writePolicyEnriched, operations)); } @Override @@ -670,7 +682,9 @@ public Mono append(T document, String setName, Map values AerospikeWriteData data = writeData(document, setName); Operation[] operations = operations(values, Operation.Type.APPEND, Operation.get()); - return executeOperationsOnValue(document, data, operations, null); + return enrichPolicyWithTransaction(reactorClient, reactorClient.getWritePolicyDefault()) + .flatMap(writePolicyEnriched -> + executeOperationsOnValue(document, data, (WritePolicy) writePolicyEnriched, operations)); } @Override @@ -685,7 +699,9 @@ public Mono append(T document, String setName, String binName, String val AerospikeWriteData data = writeData(document, setName); Operation[] operations = {Operation.append(new Bin(binName, value)), Operation.get(binName)}; - return executeOperationsOnValue(document, data, operations, null); + return enrichPolicyWithTransaction(reactorClient, reactorClient.getWritePolicyDefault()) + .flatMap(writePolicyEnriched -> + executeOperationsOnValue(document, data, (WritePolicy) writePolicyEnriched, operations)); } @Override @@ -701,7 +717,9 @@ public Mono prepend(T document, String setName, Map value AerospikeWriteData data = writeData(document, setName); Operation[] operations = operations(values, Operation.Type.PREPEND, Operation.get()); - return executeOperationsOnValue(document, data, operations, null); + return enrichPolicyWithTransaction(reactorClient, reactorClient.getWritePolicyDefault()) + .flatMap(writePolicyEnriched -> executeOperationsOnValue(document, data, + (WritePolicy) writePolicyEnriched, operations)); } @Override @@ -716,11 +734,13 @@ public Mono prepend(T document, String setName, String binName, String va AerospikeWriteData data = writeData(document, setName); Operation[] operations = {Operation.prepend(new Bin(binName, value)), Operation.get(binName)}; - return executeOperationsOnValue(document, data, operations, null); + return enrichPolicyWithTransaction(reactorClient, reactorClient.getWritePolicyDefault()) + .flatMap(writePolicyEnriched -> + executeOperationsOnValue(document, data, (WritePolicy) writePolicyEnriched, operations)); } - private Mono executeOperationsOnValue(T document, AerospikeWriteData data, Operation[] operations, - WritePolicy writePolicy) { + private Mono executeOperationsOnValue(T document, AerospikeWriteData data, WritePolicy writePolicy, + Operation[] operations) { return reactorClient.operate(writePolicy, data.getKey(), operations) .filter(keyRecord -> Objects.nonNull(keyRecord.record)) .map(keyRecord -> mapToEntity(keyRecord.key, getEntityClass(document), keyRecord.record)) @@ -758,7 +778,8 @@ public Mono findById(Object id, Class entityClass, String setName) { ) .onErrorMap(this::translateError); } else { - return reactorClient.get(key) + return enrichPolicyWithTransaction(reactorClient, reactorClient.getReadPolicyDefault()) + .flatMap(policy -> reactorClient.get(policy, key)) .filter(keyRecord -> Objects.nonNull(keyRecord.record)) .map(keyRecord -> mapToEntity(keyRecord.key, entityClass, keyRecord.record)) .onErrorMap(this::translateError); @@ -789,7 +810,8 @@ public Mono findById(Object id, Class entityClass, Class targetC ) .onErrorMap(this::translateError); } else { - return reactorClient.get(null, key, binNames) + return enrichPolicyWithTransaction(reactorClient, reactorClient.getReadPolicyDefault()) + .flatMap(policy -> reactorClient.get(policy, key, binNames)) .filter(keyRecord -> Objects.nonNull(keyRecord.record)) .map(keyRecord -> mapToEntity(keyRecord.key, targetClass, keyRecord.record)) .onErrorMap(this::translateError); @@ -837,7 +859,8 @@ private Flux findByIds(Collection ids, Class targetClass, String se .map(id -> getKey(id, setName)) .toArray(Key[]::new); - return reactorClient.get(null, keys) + return enrichPolicyWithTransaction(reactorClient, reactorClient.getBatchPolicyDefault()) + .flatMap(batchPolicy -> reactorClient.get((BatchPolicy) batchPolicy, keys)) .flatMap(kr -> Mono.just(kr.asMap())) .flatMapMany(keyRecordMap -> { List entities = keyRecordMap.entrySet().stream() @@ -856,13 +879,14 @@ public Mono findByIds(GroupedKeys groupedKeys) { return Mono.just(GroupedEntities.builder().build()); } - return findGroupedEntitiesByGroupedKeys(groupedKeys); + return findGroupedEntitiesByGroupedKeys(reactorClient.getBatchPolicyDefault(), groupedKeys); } - private Mono findGroupedEntitiesByGroupedKeys(GroupedKeys groupedKeys) { + private Mono findGroupedEntitiesByGroupedKeys(BatchPolicy batchPolicy, GroupedKeys groupedKeys) { EntitiesKeys entitiesKeys = EntitiesKeys.of(toEntitiesKeyMap(groupedKeys)); - return reactorClient.get(null, entitiesKeys.getKeys()) + return enrichPolicyWithTransaction(reactorClient, batchPolicy) + .flatMap(bPolicy -> reactorClient.get((BatchPolicy) bPolicy, entitiesKeys.getKeys())) .map(item -> toGroupedEntities(entitiesKeys, item.records)) .onErrorMap(this::translateError); } @@ -906,7 +930,8 @@ public Mono findByIdUsingQuery(Object id, Class entityClass, Class< Qualifier qualifier = query.getCriteriaObject(); policy.filterExp = reactorQueryEngine.getFilterExpressionsBuilder().build(qualifier); } - return reactorClient.get(policy, key, binNames) + return enrichPolicyWithTransaction(reactorClient, policy) + .flatMap(rPolicy -> reactorClient.get(rPolicy, key, binNames)) .filter(keyRecord -> Objects.nonNull(keyRecord.record)) .map(keyRecord -> mapToEntity(keyRecord.key, target, keyRecord.record)) .onErrorMap(this::translateError); @@ -930,7 +955,7 @@ public Flux findByIdsUsingQuery(Collection ids, Class entityClas return Flux.empty(); } - BatchPolicy policy = getBatchPolicyFilterExp(query); + BatchPolicy batchPolicy = getBatchPolicyFilterExp(query); Class target; if (targetClass != null && targetClass != entityClass) { @@ -941,7 +966,7 @@ public Flux findByIdsUsingQuery(Collection ids, Class entityClas Flux results = Flux.fromIterable(ids) .map(id -> getKey(id, setName)) - .flatMap(key -> getFromClient(policy, key, targetClass)) + .flatMap(key -> getFromClient(batchPolicy, key, targetClass)) .filter(keyRecord -> nonNull(keyRecord.record)) .map(keyRecord -> mapToEntity(keyRecord.key, target, keyRecord.record)); @@ -956,11 +981,11 @@ private Flux findByIdsUsingQueryWithoutMapping(Collection ids, String setN return Flux.empty(); } - BatchPolicy policy = getBatchPolicyFilterExp(query); + BatchPolicy batchPolicy = getBatchPolicyFilterExp(query); return Flux.fromIterable(ids) .map(id -> getKey(id, setName)) - .flatMap(key -> getFromClient(policy, key, null)) + .flatMap(key -> getFromClient(batchPolicy, key, null)) .filter(keyRecord -> nonNull(keyRecord.record)); } @@ -1056,20 +1081,22 @@ public Flux findInRange(long offset, long limit, Sort sort, Class targ private BatchPolicy getBatchPolicyFilterExp(Query query) { if (queryCriteriaIsNotNull(query)) { - BatchPolicy policy = new BatchPolicy(reactorClient.getAerospikeClient().getBatchPolicyDefault()); + BatchPolicy batchPolicy = new BatchPolicy(reactorClient.getAerospikeClient().getBatchPolicyDefault()); Qualifier qualifier = query.getCriteriaObject(); - policy.filterExp = reactorQueryEngine.getFilterExpressionsBuilder().build(qualifier); - return policy; + batchPolicy.filterExp = reactorQueryEngine.getFilterExpressionsBuilder().build(qualifier); + return batchPolicy; } return null; } - private Mono getFromClient(BatchPolicy finalPolicy, Key key, Class targetClass) { + private Mono getFromClient(BatchPolicy batchPolicy, Key key, Class targetClass) { if (targetClass != null) { String[] binNames = getBinNamesFromTargetClass(targetClass); - return reactorClient.get(finalPolicy, key, binNames); + return enrichPolicyWithTransaction(reactorClient, batchPolicy) + .flatMap(rPolicy -> reactorClient.get(rPolicy, key, binNames)); } else { - return reactorClient.get(finalPolicy, key); + return enrichPolicyWithTransaction(reactorClient, batchPolicy) + .flatMap(rPolicy -> reactorClient.get(rPolicy, key)); } } @@ -1086,7 +1113,8 @@ public Mono exists(Object id, String setName) { Assert.notNull(setName, "Set name must not be null!"); Key key = getKey(id, setName); - return reactorClient.exists(key) + return enrichPolicyWithTransaction(reactorClient, reactorClient.getAerospikeClient().getReadPolicyDefault()) + .flatMap(policy -> reactorClient.exists(policy, key)) .map(Objects::nonNull) .defaultIfEmpty(false) .onErrorMap(this::translateError); @@ -1306,31 +1334,35 @@ public long getQueryMaxRecords() { return reactorQueryEngine.getQueryMaxRecords(); } - private Mono doPersistAndHandleError(T document, AerospikeWriteData data, WritePolicy policy, + private Mono doPersistAndHandleError(T document, AerospikeWriteData data, WritePolicy writePolicy, Operation[] operations) { - return reactorClient - .operate(policy, data.getKey(), operations) + return enrichPolicyWithTransaction(reactorClient, writePolicy) + .flatMap(writePolicyEnriched -> + reactorClient.operate((WritePolicy) writePolicyEnriched, data.getKey(), operations)) .map(docKey -> document) .onErrorMap(this::translateError); } - private Mono doPersistWithVersionAndHandleCasError(T document, AerospikeWriteData data, WritePolicy policy, - Operation[] operations, OperationType operationType) { - return putAndGetHeader(data, policy, operations) + private Mono doPersistWithVersionAndHandleCasError(T document, AerospikeWriteData data, + WritePolicy writePolicy, Operation[] operations, + OperationType operationType) { + return enrichPolicyWithTransaction(reactorClient, writePolicy) + .flatMap(writePolicyEnriched -> putAndGetHeader(data, (WritePolicy) writePolicyEnriched, operations)) .map(newRecord -> updateVersion(document, newRecord)) .onErrorMap(AerospikeException.class, i -> translateCasError(i, "Failed to " + operationType.toString() + " record due to versions mismatch")); } - private Mono doPersistWithVersionAndHandleError(T document, AerospikeWriteData data, WritePolicy policy, + private Mono doPersistWithVersionAndHandleError(T document, AerospikeWriteData data, WritePolicy writePolicy, Operation[] operations) { - return putAndGetHeader(data, policy, operations) + return enrichPolicyWithTransaction(reactorClient, writePolicy) + .flatMap(writePolicyEnriched -> putAndGetHeader(data, (WritePolicy) writePolicyEnriched, operations)) .map(newRecord -> updateVersion(document, newRecord)) .onErrorMap(AerospikeException.class, this::translateError); } - private Mono putAndGetHeader(AerospikeWriteData data, WritePolicy policy, Operation[] operations) { - return reactorClient.operate(policy, data.getKey(), operations) + private Mono putAndGetHeader(AerospikeWriteData data, WritePolicy writePolicy, Operation[] operations) { + return reactorClient.operate(writePolicy, data.getKey(), operations) .map(keyRecord -> keyRecord.record); } @@ -1345,7 +1377,9 @@ private Mono getAndTouch(Key key, int expiration, String[] binNames, WritePolicy writePolicy = writePolicyBuilder.build(); if (binNames == null || binNames.length == 0) { - return reactorClient.operate(writePolicy, key, Operation.touch(), Operation.get()); + return enrichPolicyWithTransaction(reactorClient, writePolicy) + .flatMap(writePolicyEnriched -> + reactorClient.operate((WritePolicy) writePolicyEnriched, key, Operation.touch(), Operation.get())); } Operation[] operations = new Operation[binNames.length + 1]; operations[0] = Operation.touch(); @@ -1353,7 +1387,8 @@ private Mono getAndTouch(Key key, int expiration, String[] binNames, for (int i = 1; i < operations.length; i++) { operations[i] = Operation.get(binNames[i - 1]); } - return reactorClient.operate(writePolicy, key, operations); + return enrichPolicyWithTransaction(reactorClient, writePolicy) + .flatMap(writePolicyEnriched -> reactorClient.operate((WritePolicy) writePolicyEnriched, key, operations)); } private String[] getBinNamesFromTargetClass(Class targetClass) { @@ -1419,18 +1454,16 @@ private void verifyUnsortedWithOffset(Sort sort, long offset) { } private Flux applyPostProcessingOnResults(Flux results, Query query) { - if (query != null) { - if (query.getSort() != null && query.getSort().isSorted()) { - Comparator comparator = getComparator(query); - results = results.sort(comparator); - } + if (query.getSort() != null && query.getSort().isSorted()) { + Comparator comparator = getComparator(query); + results = results.sort(comparator); + } - if (query.hasOffset()) { - results = results.skip(query.getOffset()); - } - if (query.hasRows()) { - results = results.take(query.getRows()); - } + if (query.hasOffset()) { + results = results.skip(query.getOffset()); + } + if (query.hasRows()) { + results = results.take(query.getRows()); } return results; } @@ -1490,11 +1523,11 @@ private Flux findByIdsWithoutMapping(Collection ids, String se return Flux.empty(); } - BatchPolicy policy = getBatchPolicyFilterExp(query); + BatchPolicy batchPolicy = getBatchPolicyFilterExp(query); return Flux.fromIterable(ids) .map(id -> getKey(id, setName)) - .flatMap(key -> getFromClient(policy, key, targetClass)) + .flatMap(key -> getFromClient(batchPolicy, key, targetClass)) .filter(keyRecord -> nonNull(keyRecord.record)); } } diff --git a/src/main/java/org/springframework/data/aerospike/core/TemplateUtils.java b/src/main/java/org/springframework/data/aerospike/core/TemplateUtils.java index 9f2d9f0a..da734c69 100644 --- a/src/main/java/org/springframework/data/aerospike/core/TemplateUtils.java +++ b/src/main/java/org/springframework/data/aerospike/core/TemplateUtils.java @@ -1,14 +1,25 @@ package org.springframework.data.aerospike.core; +import com.aerospike.client.IAerospikeClient; +import com.aerospike.client.policy.Policy; +import com.aerospike.client.reactor.IAerospikeReactorClient; import lombok.experimental.UtilityClass; import org.springframework.data.aerospike.mapping.AerospikePersistentEntity; import org.springframework.data.aerospike.mapping.AerospikePersistentProperty; import org.springframework.data.aerospike.mapping.BasicAerospikePersistentEntity; import org.springframework.data.aerospike.query.FilterOperation; +import org.springframework.data.aerospike.query.QueryEngine; import org.springframework.data.aerospike.query.qualifier.Qualifier; +import org.springframework.data.aerospike.repository.query.Query; +import org.springframework.data.aerospike.transaction.reactive.AerospikeReactiveTransactionResourceHolder; +import org.springframework.data.aerospike.transaction.sync.AerospikeTransactionResourceHolder; import org.springframework.data.mapping.PropertyHandler; import org.springframework.data.mapping.context.MappingContext; +import org.springframework.transaction.NoTransactionException; +import org.springframework.transaction.reactive.TransactionContextManager; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.util.Assert; +import reactor.core.publisher.Mono; import java.util.ArrayList; import java.util.Arrays; @@ -17,14 +28,13 @@ import java.util.stream.Collectors; import java.util.stream.StreamSupport; +import static org.springframework.data.aerospike.query.QualifierUtils.queryCriteriaIsNotNull; import static org.springframework.data.aerospike.query.qualifier.Qualifier.and; import static org.springframework.data.aerospike.query.qualifier.Qualifier.or; @UtilityClass public class TemplateUtils { - final String SERVER_VERSION_6 = "6.0.0"; - public static List getIdValue(Qualifier qualifier) { if (qualifier.hasId()) { return idObjectToList(qualifier.getId()); @@ -116,4 +126,40 @@ public static String[] getBinNamesFromTargetClass(Class targetClass, return binNamesList.toArray(new String[0]); } + public static Policy checkForTransaction(IAerospikeClient client, Policy policy) { + if (TransactionSynchronizationManager.hasResource(client)) { + AerospikeTransactionResourceHolder resourceHolder = + (AerospikeTransactionResourceHolder) TransactionSynchronizationManager.getResource(client); + if (resourceHolder != null) policy.txn = resourceHolder.getTransaction(); + return policy; + } + return policy; + } + + private static Policy getPolicyFilterExp(IAerospikeClient client, QueryEngine queryEngine, Query query) { + if (queryCriteriaIsNotNull(query)) { + Policy policy = new Policy(client.getReadPolicyDefault()); + Qualifier qualifier = query.getCriteriaObject(); + policy.filterExp = queryEngine.getFilterExpressionsBuilder().build(qualifier); + return policy; + } + return null; + } + + static Policy getPolicyFilterExpOrDefault(IAerospikeClient client, QueryEngine queryEngine, Query query) { + Policy policy = getPolicyFilterExp(client, queryEngine, query); + return checkForTransaction(client, policy != null ? policy : client.getReadPolicyDefault()); + } + + static Mono enrichPolicyWithTransaction(IAerospikeReactorClient reactiveClient, Policy policy) { + return TransactionContextManager.currentContext() + .map(ctx -> { + AerospikeReactiveTransactionResourceHolder resourceHolder = + (AerospikeReactiveTransactionResourceHolder) ctx.getResources().get(reactiveClient); + if (resourceHolder != null) policy.txn = resourceHolder.getTransaction(); + return policy; + }) + .onErrorResume(NoTransactionException.class, ignored -> + Mono.just(policy)); + } } diff --git a/src/main/java/org/springframework/data/aerospike/server/version/ServerVersionSupport.java b/src/main/java/org/springframework/data/aerospike/server/version/ServerVersionSupport.java index db49986c..db9ab225 100644 --- a/src/main/java/org/springframework/data/aerospike/server/version/ServerVersionSupport.java +++ b/src/main/java/org/springframework/data/aerospike/server/version/ServerVersionSupport.java @@ -17,6 +17,7 @@ public class ServerVersionSupport { private static final ModuleDescriptor.Version SERVER_VERSION_6_1_0_1 = ModuleDescriptor.Version.parse("6.1.0.1"); private static final ModuleDescriptor.Version SERVER_VERSION_6_3_0_0 = ModuleDescriptor.Version.parse("6.3.0.0"); private static final ModuleDescriptor.Version SERVER_VERSION_7_0_0_0 = ModuleDescriptor.Version.parse("7.0.0.0"); + private static final ModuleDescriptor.Version SERVER_VERSION_8_0_0_0 = ModuleDescriptor.Version.parse("8.0.0.0"); private final IAerospikeClient client; private final ScheduledExecutorService executorService; @@ -85,4 +86,12 @@ public boolean isServerVersionGtOrEq7() { return ModuleDescriptor.Version.parse(getServerVersion()) .compareTo(SERVER_VERSION_7_0_0_0) >= 0; } + + /** + * @return true if Server version is 8.0 or greater + */ + public boolean isMRTSupported() { + return ModuleDescriptor.Version.parse(getServerVersion()) + .compareTo(SERVER_VERSION_8_0_0_0) >= 0; + } } diff --git a/src/main/java/org/springframework/data/aerospike/transaction/reactive/AerospikeReactiveTransaction.java b/src/main/java/org/springframework/data/aerospike/transaction/reactive/AerospikeReactiveTransaction.java new file mode 100644 index 00000000..31ce1f4f --- /dev/null +++ b/src/main/java/org/springframework/data/aerospike/transaction/reactive/AerospikeReactiveTransaction.java @@ -0,0 +1,73 @@ +package org.springframework.data.aerospike.transaction.reactive; + +import org.springframework.data.aerospike.transaction.sync.AerospikeTransactionResourceHolder; +import org.springframework.lang.Nullable; +import org.springframework.transaction.support.SmartTransactionObject; +import org.springframework.util.Assert; + +/** + * A {@link SmartTransactionObject} implementation that has reactive transaction resource holder + * and basic transaction API + */ +public class AerospikeReactiveTransaction implements SmartTransactionObject { + + @Nullable + private AerospikeReactiveTransactionResourceHolder resourceHolder; + + AerospikeReactiveTransaction(@Nullable AerospikeReactiveTransactionResourceHolder resourceHolder) { + this.resourceHolder = resourceHolder; + } + + /** + * @return {@literal true} if {@link AerospikeReactiveTransactionResourceHolder} is set + */ + final boolean hasResourceHolder() { + return resourceHolder != null; + } + + AerospikeReactiveTransactionResourceHolder getRequiredResourceHolder() { + Assert.state(hasResourceHolder(), "Reactive resourceHolder is required to be not null"); + return resourceHolder; + } + + /** + * Set corresponding {@link AerospikeReactiveTransactionResourceHolder} + * + * @param resourceHolder can be {@literal null}. + */ + void setResourceHolder(@Nullable AerospikeReactiveTransactionResourceHolder resourceHolder) { + this.resourceHolder = resourceHolder; + } + + private void failIfNoTransaction() { + if (!hasResourceHolder()) { + throw new IllegalStateException("Error: expecting transaction to exist"); + } + } + + /** + * Commit the transaction + */ + public void commitTransaction() { + failIfNoTransaction(); + resourceHolder.getClient().getAerospikeClient().commit(resourceHolder.getTransaction()); + } + + /** + * Rollback (abort) the transaction + */ + public void abortTransaction() { + failIfNoTransaction(); + resourceHolder.getClient().getAerospikeClient().abort(resourceHolder.getTransaction()); + } + + @Override + public boolean isRollbackOnly() { + return hasResourceHolder() && this.resourceHolder.isRollbackOnly(); + } + + @Override + public void flush() { + throw new UnsupportedOperationException("Currently flush() is not supported for a reactive transaction"); + } +} diff --git a/src/main/java/org/springframework/data/aerospike/transaction/reactive/AerospikeReactiveTransactionManager.java b/src/main/java/org/springframework/data/aerospike/transaction/reactive/AerospikeReactiveTransactionManager.java new file mode 100644 index 00000000..c8db4f52 --- /dev/null +++ b/src/main/java/org/springframework/data/aerospike/transaction/reactive/AerospikeReactiveTransactionManager.java @@ -0,0 +1,155 @@ +package org.springframework.data.aerospike.transaction.reactive; + +import com.aerospike.client.reactor.IAerospikeReactorClient; +import lombok.Getter; +import org.springframework.lang.Nullable; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionException; +import org.springframework.transaction.TransactionSystemException; +import org.springframework.transaction.reactive.AbstractReactiveTransactionManager; +import org.springframework.transaction.reactive.GenericReactiveTransaction; +import org.springframework.transaction.reactive.TransactionSynchronizationManager; +import org.springframework.util.Assert; +import reactor.core.publisher.Mono; + +import static org.springframework.data.aerospike.transaction.reactive.AerospikeReactiveTransactionResourceHolder.determineTimeout; + +/** + * A {@link org.springframework.transaction.ReactiveTransactionManager} implementation for managing transactions + */ +@Getter +public class AerospikeReactiveTransactionManager extends AbstractReactiveTransactionManager { + + private final IAerospikeReactorClient client; + + /** + * Create a new instance of {@link AerospikeReactiveTransactionManager} + */ + + public AerospikeReactiveTransactionManager(IAerospikeReactorClient client) { + this.client = client; + } + + private static AerospikeReactiveTransaction toAerospikeTransaction(Object transaction) { + Assert.isInstanceOf(AerospikeReactiveTransaction.class, transaction, + () -> String.format("Expected to find instance of %s but instead found %s", + AerospikeReactiveTransaction.class, transaction.getClass())); + + return (AerospikeReactiveTransaction) transaction; + } + + private static AerospikeReactiveTransaction getTransaction(GenericReactiveTransaction status) { + Assert.isInstanceOf(AerospikeReactiveTransaction.class, status.getTransaction(), + () -> String.format("Expected to find instance of %s but instead found %s", + AerospikeReactiveTransaction.class, status.getTransaction().getClass())); + + return (AerospikeReactiveTransaction) status.getTransaction(); + } + + @Override + protected boolean isExistingTransaction(Object transaction) { + return toAerospikeTransaction(transaction).hasResourceHolder(); + } + + @Override + protected Object doGetTransaction(TransactionSynchronizationManager synchronizationManager) { + AerospikeReactiveTransactionResourceHolder resourceHolder = + (AerospikeReactiveTransactionResourceHolder) synchronizationManager.getResource(client); + return new AerospikeReactiveTransaction(resourceHolder); + } + + @Override + protected Mono doBegin(TransactionSynchronizationManager synchronizationManager, Object transaction, + TransactionDefinition definition) { + return Mono.defer(() -> { + AerospikeReactiveTransaction aerospikeTransaction = toAerospikeTransaction(transaction); + // create new resourceHolder with a new Tran, de facto start transaction + Mono resourceHolder = createResourceHolder(client, definition); + + return resourceHolder + .doOnNext(aerospikeTransaction::setResourceHolder) + .onErrorMap(e -> new TransactionSystemException("Could not start transaction", e)) + .doOnSuccess(rHolder -> { + rHolder.setSynchronizedWithTransaction(true); + synchronizationManager.bindResource(client, rHolder); + }) + .onErrorMap(e -> new TransactionSystemException("Could not bind transaction resource", e)) + .then(); + }); + } + + private Mono createResourceHolder(IAerospikeReactorClient client, + TransactionDefinition definition) { + AerospikeReactiveTransactionResourceHolder resourceHolder = + new AerospikeReactiveTransactionResourceHolder(client); + resourceHolder.setTimeoutIfNotDefault(determineTimeout(definition)); + return Mono.just(resourceHolder); + } + + @Override + protected Mono doCommit(TransactionSynchronizationManager synchronizationManager, + GenericReactiveTransaction status) { + return Mono.fromRunnable(() -> { + AerospikeReactiveTransaction transaction = getTransaction(status); + transaction.commitTransaction(); + }) + .onErrorMap(e -> new TransactionSystemException("Could not commit transaction", e)) + .then(); + } + + @Override + protected Mono doRollback(TransactionSynchronizationManager synchronizationManager, + GenericReactiveTransaction status) { + return Mono.fromRunnable(() -> { + AerospikeReactiveTransaction transaction = getTransaction(status); + transaction.abortTransaction(); + }) + .onErrorMap(e -> new TransactionSystemException("Could not abort transaction", e)) + .then(); + } + + @Override + protected Mono doSuspend(TransactionSynchronizationManager synchronizationManager, Object transaction) + throws TransactionException { + return Mono.fromSupplier(() -> { + AerospikeReactiveTransaction aerospikeTransaction = toAerospikeTransaction(transaction); + aerospikeTransaction.setResourceHolder(null); + + return synchronizationManager.unbindResource(client); + }).onErrorMap(e -> new TransactionSystemException("Could not suspend transaction", e)); + } + + @Override + protected Mono doResume(TransactionSynchronizationManager synchronizationManager, + @Nullable Object transaction, + Object suspendedResources) { + return Mono.fromRunnable(() -> synchronizationManager.bindResource(client, suspendedResources)) + .onErrorMap(e -> new TransactionSystemException("Could not resume transaction", e)) + .then(); + } + + @Override + protected Mono doSetRollbackOnly(TransactionSynchronizationManager synchronizationManager, + GenericReactiveTransaction status) throws TransactionException { + return Mono.fromRunnable(() -> { + AerospikeReactiveTransaction transaction = toAerospikeTransaction(status); + transaction.getRequiredResourceHolder().setRollbackOnly(); + }) + .onErrorMap(e -> new TransactionSystemException("Could not resume transaction", e)) + .then(); + } + + @Override + protected Mono doCleanupAfterCompletion(TransactionSynchronizationManager synchronizationManager, + Object transaction) { + return Mono.fromRunnable(() -> { + AerospikeReactiveTransaction aerospikeTransaction = toAerospikeTransaction(transaction); + + // Remove the value (resource holder) from the thread. + synchronizationManager.unbindResource(client); + aerospikeTransaction.getRequiredResourceHolder().clear(); + }) + .onErrorMap(e -> new TransactionSystemException("Could not resume transaction", e)) + .then(); + } +} diff --git a/src/main/java/org/springframework/data/aerospike/transaction/reactive/AerospikeReactiveTransactionResourceHolder.java b/src/main/java/org/springframework/data/aerospike/transaction/reactive/AerospikeReactiveTransactionResourceHolder.java new file mode 100644 index 00000000..1517b2df --- /dev/null +++ b/src/main/java/org/springframework/data/aerospike/transaction/reactive/AerospikeReactiveTransactionResourceHolder.java @@ -0,0 +1,37 @@ +package org.springframework.data.aerospike.transaction.reactive; + +import com.aerospike.client.Txn; +import com.aerospike.client.reactor.IAerospikeReactorClient; +import lombok.Getter; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.ResourceHolderSupport; + +/** + * Aerospike reactive transaction resource holder for managing transaction resources, + * extends {@link ResourceHolderSupport} + */ +@Getter +public class AerospikeReactiveTransactionResourceHolder extends ResourceHolderSupport { + + private final Txn transaction; + private final IAerospikeReactorClient client; + + public AerospikeReactiveTransactionResourceHolder(IAerospikeReactorClient client) { + this.client = client; + this.transaction = new Txn(); + } + + void setTimeoutIfNotDefault(int seconds) { + if (seconds != TransactionDefinition.TIMEOUT_DEFAULT) { + transaction.setTimeout(seconds); + setTimeoutInSeconds(seconds); + } + } + + static int determineTimeout(TransactionDefinition definition) { + if (definition.getTimeout() != TransactionDefinition.TIMEOUT_DEFAULT) { + return definition.getTimeout(); + } + return TransactionDefinition.TIMEOUT_DEFAULT; + } +} diff --git a/src/main/java/org/springframework/data/aerospike/transaction/sync/AerospikeTransaction.java b/src/main/java/org/springframework/data/aerospike/transaction/sync/AerospikeTransaction.java new file mode 100644 index 00000000..920ff26b --- /dev/null +++ b/src/main/java/org/springframework/data/aerospike/transaction/sync/AerospikeTransaction.java @@ -0,0 +1,72 @@ +package org.springframework.data.aerospike.transaction.sync; + +import org.springframework.lang.Nullable; +import org.springframework.transaction.support.SmartTransactionObject; +import org.springframework.transaction.support.TransactionSynchronizationUtils; +import org.springframework.util.Assert; + +/** + * A {@link SmartTransactionObject} implementation that has transaction resource holder and basic transaction API + */ +public class AerospikeTransaction implements SmartTransactionObject { + + @Nullable + private AerospikeTransactionResourceHolder resourceHolder; + + AerospikeTransaction(@Nullable AerospikeTransactionResourceHolder resourceHolder) { + this.resourceHolder = resourceHolder; + } + + /** + * @return {@literal true} if {@link AerospikeTransactionResourceHolder} is set + */ + final boolean hasResourceHolder() { + return resourceHolder != null; + } + + AerospikeTransactionResourceHolder getResourceHolderOrFail() { + Assert.state(hasResourceHolder(), "ResourceHolder is required to be not null"); + return resourceHolder; + } + + /** + * Set corresponding {@link AerospikeTransactionResourceHolder} + * + * @param resourceHolder can be {@literal null}. + */ + void setResourceHolder(@Nullable AerospikeTransactionResourceHolder resourceHolder) { + this.resourceHolder = resourceHolder; + } + + private void failIfNoTransaction() { + if (!hasResourceHolder()) { + throw new IllegalStateException("Error: expecting transaction to exist"); + } + } + + /** + * Commit the transaction + */ + public void commitTransaction() { + failIfNoTransaction(); + resourceHolder.getClient().commit(resourceHolder.getTransaction()); + } + + /** + * Rollback (abort) the transaction + */ + public void abortTransaction() { + failIfNoTransaction(); + resourceHolder.getClient().abort(resourceHolder.getTransaction()); + } + + @Override + public boolean isRollbackOnly() { + return hasResourceHolder() && this.resourceHolder.isRollbackOnly(); + } + + @Override + public void flush() { + TransactionSynchronizationUtils.triggerFlush(); + } +} diff --git a/src/main/java/org/springframework/data/aerospike/transaction/sync/AerospikeTransactionManager.java b/src/main/java/org/springframework/data/aerospike/transaction/sync/AerospikeTransactionManager.java new file mode 100644 index 00000000..62132551 --- /dev/null +++ b/src/main/java/org/springframework/data/aerospike/transaction/sync/AerospikeTransactionManager.java @@ -0,0 +1,135 @@ +package org.springframework.data.aerospike.transaction.sync; + +import com.aerospike.client.IAerospikeClient; +import lombok.Getter; +import org.springframework.lang.Nullable; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionException; +import org.springframework.transaction.TransactionSystemException; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.util.Assert; + +/** + * A {@link org.springframework.transaction.PlatformTransactionManager} implementation for managing transactions + */ +@Getter +public class AerospikeTransactionManager extends AbstractPlatformTransactionManager { + + private final IAerospikeClient client; + + /** + * Create a new instance of {@link AerospikeTransactionManager} + */ + public AerospikeTransactionManager(IAerospikeClient client) { + this.client = client; + } + + private static AerospikeTransaction toAerospikeTransaction(Object transaction) { + Assert.isInstanceOf(AerospikeTransaction.class, transaction, + () -> String.format("Expected to find instance of %s but instead found %s", AerospikeTransaction.class, + transaction.getClass())); + + return (AerospikeTransaction) transaction; + } + + @Override + protected boolean isExistingTransaction(Object transaction) throws TransactionException { + return toAerospikeTransaction(transaction).hasResourceHolder(); + } + + private static AerospikeTransaction getTransaction(DefaultTransactionStatus status) { + Assert.isInstanceOf(AerospikeTransaction.class, status.getTransaction(), + () -> String.format("Expected to find instance of %s but instead found %s", AerospikeTransaction.class, + status.getTransaction().getClass())); + + return (AerospikeTransaction) status.getTransaction(); + } + + @Override + protected Object doGetTransaction() throws TransactionException { + AerospikeTransactionResourceHolder resourceHolder = + (AerospikeTransactionResourceHolder) TransactionSynchronizationManager.getResource(getClient()); + return new AerospikeTransaction(resourceHolder); + } + + @Override + protected void doBegin(Object transaction, TransactionDefinition definition) throws TransactionException { + AerospikeTransaction aerospikeTransaction; + AerospikeTransactionResourceHolder resourceHolder; + try { + // get transaction from the transaction object + aerospikeTransaction = toAerospikeTransaction(transaction); + // create new resourceHolder with a new Tran, de facto start transaction + resourceHolder = createResourceHolder(definition, client); + } catch (Exception e) { + throw new TransactionSystemException("Could not start transaction", e); + } + + // associate resourceHolder with the transaction + aerospikeTransaction.setResourceHolder(resourceHolder); + + resourceHolder.setSynchronizedWithTransaction(true); + // bind the resource to the current thread (get ThreadLocal map or set if not found, set value) + TransactionSynchronizationManager.bindResource(client, resourceHolder); + } + + private AerospikeTransactionResourceHolder createResourceHolder(TransactionDefinition definition, + IAerospikeClient client) { + AerospikeTransactionResourceHolder resourceHolder = new AerospikeTransactionResourceHolder(client); + resourceHolder.setTimeoutIfNotDefault(determineTimeout(definition)); + return resourceHolder; + } + + @Override + protected void doCommit(DefaultTransactionStatus status) throws TransactionException { + AerospikeTransaction transaction = getTransaction(status); // get transaction with associated resourceHolder + transaction.commitTransaction(); + } + + @Override + protected void doRollback(DefaultTransactionStatus status) throws TransactionException { + AerospikeTransaction transaction = getTransaction(status); // get transaction with associated resourceHolder + transaction.abortTransaction(); + } + + @Override + protected Object doSuspend(Object transaction) throws TransactionException { + try { + AerospikeTransaction aerospikeTransaction = toAerospikeTransaction(transaction); + aerospikeTransaction.setResourceHolder(null); + return TransactionSynchronizationManager.unbindResource(client); + } catch (Exception e) { + throw new TransactionSystemException("Could not suspend transaction", e); + } + } + + @Override + protected void doResume(@Nullable Object transaction, Object suspendedResources) throws TransactionException { + try { + TransactionSynchronizationManager.bindResource(client, suspendedResources); + } catch (Exception e) { + throw new TransactionSystemException("Could not resume transaction", e); + } + } + + @Override + protected void doSetRollbackOnly(DefaultTransactionStatus status) throws TransactionException { + try { + AerospikeTransaction transaction = getTransaction(status); + transaction.getResourceHolderOrFail().setRollbackOnly(); + } catch (Exception e) { + throw new TransactionSystemException("Could not set rollback only for a transaction", e); + } + } + + @Override + protected void doCleanupAfterCompletion(Object transaction) { + AerospikeTransaction aerospikeTransaction = toAerospikeTransaction(transaction); + + // Remove the value (resource holder) from the thread + TransactionSynchronizationManager.unbindResource(client); + aerospikeTransaction.getResourceHolderOrFail().clear(); + } +} diff --git a/src/main/java/org/springframework/data/aerospike/transaction/sync/AerospikeTransactionResourceHolder.java b/src/main/java/org/springframework/data/aerospike/transaction/sync/AerospikeTransactionResourceHolder.java new file mode 100644 index 00000000..4c623370 --- /dev/null +++ b/src/main/java/org/springframework/data/aerospike/transaction/sync/AerospikeTransactionResourceHolder.java @@ -0,0 +1,29 @@ +package org.springframework.data.aerospike.transaction.sync; + +import com.aerospike.client.IAerospikeClient; +import com.aerospike.client.Txn; +import lombok.Getter; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.ResourceHolderSupport; + +/** + * Aerospike transaction resource holder for managing transaction resources, extends {@link ResourceHolderSupport} + */ +@Getter +public class AerospikeTransactionResourceHolder extends ResourceHolderSupport { + + private final Txn transaction; + private final IAerospikeClient client; + + public AerospikeTransactionResourceHolder(IAerospikeClient client) { + this.client = client; + this.transaction = new Txn(); + } + + void setTimeoutIfNotDefault(int seconds) { + if (seconds != TransactionDefinition.TIMEOUT_DEFAULT) { + transaction.setTimeout(seconds); + setTimeoutInSeconds(seconds); + } + } +} diff --git a/src/test/java/org/springframework/data/aerospike/BaseReactiveIntegrationTests.java b/src/test/java/org/springframework/data/aerospike/BaseReactiveIntegrationTests.java index 38d7bdc6..32ddcdeb 100644 --- a/src/test/java/org/springframework/data/aerospike/BaseReactiveIntegrationTests.java +++ b/src/test/java/org/springframework/data/aerospike/BaseReactiveIntegrationTests.java @@ -152,4 +152,21 @@ protected boolean queryHasSecIndexFilter(String methodName, Class returnEntit // Checking that the statement has secondary index filter (which means it will be used) return statement.getFilter() != null; } + + /** + * Delete all entities of a class or a set. + * + * @param objectsToDelete Each of the objects must be either a Class or a String. + */ + protected void deleteAll(Object... objectsToDelete) { + for (Object toDelete : objectsToDelete) { + if (toDelete instanceof Class) { + reactiveTemplate.deleteAll((Class) toDelete).block(); + } else if (toDelete instanceof String) { + reactiveTemplate.deleteAll((String) toDelete).block(); + } else { + throw new IllegalArgumentException("Expecting either a Class or a String"); + } + } + } } diff --git a/src/test/java/org/springframework/data/aerospike/config/BlockingTestConfig.java b/src/test/java/org/springframework/data/aerospike/config/BlockingTestConfig.java index 527e0dba..ac439ed3 100644 --- a/src/test/java/org/springframework/data/aerospike/config/BlockingTestConfig.java +++ b/src/test/java/org/springframework/data/aerospike/config/BlockingTestConfig.java @@ -14,7 +14,10 @@ import org.springframework.data.aerospike.sample.CustomerRepository; import org.springframework.data.aerospike.sample.SampleClasses; import org.springframework.data.aerospike.server.version.ServerVersionSupport; +import org.springframework.data.aerospike.transaction.sync.AerospikeTransactionManager; import org.springframework.data.aerospike.util.AdditionalAerospikeTestOperations; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.TransactionTemplate; import org.testcontainers.containers.GenericContainer; import java.util.Arrays; @@ -25,6 +28,7 @@ * @author Jean Mercier */ @EnableAerospikeRepositories(basePackageClasses = {ContactRepository.class, CustomerRepository.class}) +@EnableTransactionManagement public class BlockingTestConfig extends AbstractAerospikeDataConfiguration { @Autowired @@ -68,4 +72,14 @@ public IAerospikeClient aerospikeClient(AerospikeSettings settings) { public IndexedBinsAnnotationsProcessor someAnnotationProcessor() { return new IndexedBinsAnnotationsProcessor(); } + + @Bean + public AerospikeTransactionManager aerospikeTransactionManager(IAerospikeClient client) { + return new AerospikeTransactionManager(client); + } + + @Bean + public TransactionTemplate transactionTemplate(AerospikeTransactionManager transactionManager) { + return new TransactionTemplate(transactionManager); + } } diff --git a/src/test/java/org/springframework/data/aerospike/config/ReactiveTestConfig.java b/src/test/java/org/springframework/data/aerospike/config/ReactiveTestConfig.java index e357b7f2..b6356552 100644 --- a/src/test/java/org/springframework/data/aerospike/config/ReactiveTestConfig.java +++ b/src/test/java/org/springframework/data/aerospike/config/ReactiveTestConfig.java @@ -1,6 +1,8 @@ package org.springframework.data.aerospike.config; import com.aerospike.client.IAerospikeClient; +import com.aerospike.client.policy.ClientPolicy; +import com.aerospike.client.reactor.IAerospikeReactorClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.core.env.Environment; @@ -11,7 +13,12 @@ import org.springframework.data.aerospike.sample.ReactiveCustomerRepository; import org.springframework.data.aerospike.sample.SampleClasses; import org.springframework.data.aerospike.server.version.ServerVersionSupport; +import org.springframework.data.aerospike.transaction.reactive.AerospikeReactiveTransactionManager; import org.springframework.data.aerospike.util.AdditionalAerospikeTestOperations; +import org.springframework.transaction.ReactiveTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.reactive.TransactionalOperator; +import org.springframework.transaction.support.DefaultTransactionDefinition; import org.testcontainers.containers.GenericContainer; import java.util.Arrays; @@ -22,6 +29,7 @@ * @author Jean Mercier */ @EnableReactiveAerospikeRepositories(basePackageClasses = {ReactiveCustomerRepository.class}) +@EnableTransactionManagement public class ReactiveTestConfig extends AbstractReactiveAerospikeDataConfiguration { @Autowired @@ -43,4 +51,37 @@ public AdditionalAerospikeTestOperations aerospikeOperations(ReactiveAerospikeTe return new ReactiveBlockingAerospikeTestOperations(new IndexInfoParser(), client, aerospike, template, serverVersionSupport); } + + @Override + protected ClientPolicy getClientPolicy() { + ClientPolicy clientPolicy = super.getClientPolicy(); // applying default values first + int totalTimeout = 2000; + clientPolicy.readPolicyDefault.totalTimeout = totalTimeout; + clientPolicy.writePolicyDefault.totalTimeout = totalTimeout; + clientPolicy.batchPolicyDefault.totalTimeout = totalTimeout; + clientPolicy.infoPolicyDefault.timeout = totalTimeout; + clientPolicy.readPolicyDefault.maxRetries = 3; + return clientPolicy; + } + + @Bean + public ReactiveTransactionManager aerospikeReactiveTransactionManager(IAerospikeReactorClient client) { + return new AerospikeReactiveTransactionManager(client); + } + + @Bean(name = "reactiveTransactionalOperator") + public TransactionalOperator reactiveTransactionalOperator( + AerospikeReactiveTransactionManager reactiveTransactionManager + ) { + return TransactionalOperator.create(reactiveTransactionManager, new DefaultTransactionDefinition()); + } + + @Bean(name = "reactiveTransactionalOperatorWithTimeout2") + public TransactionalOperator reactiveTransactionalOperatorWithTimeout2( + AerospikeReactiveTransactionManager reactiveTransactionManager + ) { + DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); + definition.setTimeout(2); + return TransactionalOperator.create(reactiveTransactionManager, definition); + } } diff --git a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/delete/EqualsTests.java b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/delete/EqualsTests.java index d3263370..3c76cc2e 100644 --- a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/delete/EqualsTests.java +++ b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/delete/EqualsTests.java @@ -22,7 +22,7 @@ public class EqualsTests extends ReactiveCustomerRepositoryQueryTests { @Test public void deleteById_ShouldDeleteExistent() { - StepVerifier.create(reactiveRepository.deleteById(marge.getId()).subscribeOn(Schedulers.parallel())) + StepVerifier.create(reactiveRepository.deleteById(marge.getId())) .verifyComplete(); StepVerifier.create(reactiveRepository.findById(marge.getId())).expectNextCount(0).verifyComplete(); @@ -35,7 +35,7 @@ public void deleteById_ShouldDeleteExistent() { @Test public void deleteById_ShouldSkipNonexistent() { - StepVerifier.create(reactiveRepository.deleteById("non-existent-id").subscribeOn(Schedulers.parallel())) + StepVerifier.create(reactiveRepository.deleteById("non-existent-id")) .verifyComplete(); } @@ -50,8 +50,7 @@ public void deleteById_ShouldRejectNullObject() { public void deleteByIdPublisher_ShouldDeleteOnlyFirstElement() { StepVerifier.create( reactiveRepository - .deleteById(Flux.just(homer.getId(), marge.getId())) - .subscribeOn(Schedulers.parallel())) + .deleteById(Flux.just(homer.getId(), marge.getId()))) .verifyComplete(); StepVerifier.create(reactiveRepository.findById(homer.getId())).expectNextCount(0).verifyComplete(); @@ -66,8 +65,7 @@ public void deleteByIdPublisher_ShouldDeleteOnlyFirstElement() { @Test public void deleteByIdPublisher_ShouldSkipNonexistent() { - StepVerifier.create(reactiveRepository.deleteById(Flux.just("non-existent-id")) - .subscribeOn(Schedulers.parallel())) + StepVerifier.create(reactiveRepository.deleteById(Flux.just("non-existent-id"))) .verifyComplete(); } @@ -81,7 +79,7 @@ public void deleteByIdPublisher_ShouldRejectNullObject() { @Test public void delete_ShouldDeleteExistent() { - StepVerifier.create(reactiveRepository.delete(marge).subscribeOn(Schedulers.parallel())).verifyComplete(); + StepVerifier.create(reactiveRepository.delete(marge)).verifyComplete(); StepVerifier.create(reactiveRepository.findById(marge.getId())).expectNextCount(0).verifyComplete(); @@ -96,7 +94,7 @@ public void delete_ShouldSkipNonexistent() { Customer nonExistentCustomer = Customer.builder().id(nextId()).firstName("Bart").lastName("Simpson").age(15) .build(); - StepVerifier.create(reactiveRepository.delete(nonExistentCustomer).subscribeOn(Schedulers.parallel())) + StepVerifier.create(reactiveRepository.delete(nonExistentCustomer)) .verifyComplete(); } @@ -110,7 +108,7 @@ public void delete_ShouldRejectNullObject() { @Test public void deleteAllIterable_ShouldDeleteExistent() { - reactiveRepository.deleteAll(asList(homer, marge)).subscribeOn(Schedulers.parallel()).block(); + reactiveRepository.deleteAll(asList(homer, marge)).block(); StepVerifier.create(reactiveRepository.findById(homer.getId())).expectNextCount(0).verifyComplete(); StepVerifier.create(reactiveRepository.findById(marge.getId())).expectNextCount(0).verifyComplete(); @@ -141,13 +139,13 @@ public void deleteAllIterable_ShouldSkipNonexistentAndThrowException() { public void deleteAllIterable_ShouldRejectNullObject() { List entities = asList(homer, null, marge); - assertThatThrownBy(() -> reactiveRepository.deleteAll(entities).subscribeOn(Schedulers.parallel()).block()) + assertThatThrownBy(() -> reactiveRepository.deleteAll(entities).block()) .isInstanceOf(IllegalArgumentException.class); } @Test public void deleteAllPublisher_ShouldDeleteExistent() { - reactiveRepository.deleteAll(Flux.just(homer, marge)).subscribeOn(Schedulers.parallel()).block(); + reactiveRepository.deleteAll(Flux.just(homer, marge)).block(); StepVerifier.create(reactiveRepository.findById(homer.getId())).expectNextCount(0).verifyComplete(); StepVerifier.create(reactiveRepository.findById(marge.getId())).expectNextCount(0).verifyComplete(); @@ -163,7 +161,7 @@ public void deleteAllPublisher_ShouldNotSkipNonexistent() { Customer nonExistentCustomer = Customer.builder().id(nextId()).firstName("Bart").lastName("Simpson").age(15) .build(); - reactiveRepository.deleteAll(Flux.just(homer, nonExistentCustomer, marge)).subscribeOn(Schedulers.parallel()) + reactiveRepository.deleteAll(Flux.just(homer, nonExistentCustomer, marge)) .block(); StepVerifier.create(reactiveRepository.findById(homer.getId())).expectNextCount(0).verifyComplete(); @@ -177,7 +175,7 @@ public void deleteAllPublisher_ShouldNotSkipNonexistent() { @Test public void deleteAllById_ShouldDelete() { - reactiveRepository.deleteAllById(asList(homer.getId(), marge.getId())).subscribeOn(Schedulers.parallel()) + reactiveRepository.deleteAllById(asList(homer.getId(), marge.getId())) .block(); StepVerifier.create(reactiveRepository.findById(homer.getId())).expectNextCount(0).verifyComplete(); diff --git a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/exists/EqualsTests.java b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/exists/EqualsTests.java index c6e774ea..163d92fd 100644 --- a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/exists/EqualsTests.java +++ b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/exists/EqualsTests.java @@ -4,7 +4,6 @@ import org.springframework.data.aerospike.query.QueryParam; import org.springframework.data.aerospike.repository.query.reactive.ReactiveCustomerRepositoryQueryTests; import reactor.core.publisher.Flux; -import reactor.core.scheduler.Schedulers; import reactor.test.StepVerifier; /** @@ -14,32 +13,32 @@ public class EqualsTests extends ReactiveCustomerRepositoryQueryTests { @Test public void existsById_ShouldReturnTrueWhenExists() { - StepVerifier.create(reactiveRepository.existsById(leela.getId()).subscribeOn(Schedulers.parallel())) + StepVerifier.create(reactiveRepository.existsById(leela.getId())) .expectNext(true).verifyComplete(); } @Test public void existsById_ShouldReturnFalseWhenNotExists() { - StepVerifier.create(reactiveRepository.existsById("non-existent-id").subscribeOn(Schedulers.parallel())) - .expectNext(false).verifyComplete(); + StepVerifier.create(reactiveRepository.existsById("non-existent-id")) + .expectNext(false) + .verifyComplete(); } @Test public void existsByIdPublisher_ShouldReturnTrueWhenExists() { - StepVerifier.create(reactiveRepository.existsById(Flux.just(fry.getId())).subscribeOn(Schedulers.parallel())) + StepVerifier.create(reactiveRepository.existsById(Flux.just(fry.getId()))) .expectNext(true).verifyComplete(); } @Test public void existsByIdPublisher_ShouldReturnFalseWhenNotExists() { - StepVerifier.create(reactiveRepository.existsById(Flux.just("non-existent-id")).subscribeOn(Schedulers.parallel())) + StepVerifier.create(reactiveRepository.existsById(Flux.just("non-existent-id"))) .expectNext(false).verifyComplete(); } @Test public void existsByIdPublisher_ShouldCheckOnlyFirstElement() { - StepVerifier.create(reactiveRepository.existsById(Flux.just(fry.getId(), "non-existent-id")) - .subscribeOn(Schedulers.parallel())) + StepVerifier.create(reactiveRepository.existsById(Flux.just(fry.getId(), "non-existent-id"))) .expectNext(true).verifyComplete(); } diff --git a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/BetweenTests.java b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/BetweenTests.java index 89434c78..27fd14f0 100644 --- a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/BetweenTests.java +++ b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/BetweenTests.java @@ -19,7 +19,7 @@ public class BetweenTests extends ReactiveCustomerRepositoryQueryTests { @Test public void findBySimplePropertyBetween() { List results = reactiveRepository.findByAgeBetween(10, 40) - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).containsOnly(marge, bart, leela); } @@ -29,7 +29,7 @@ public void findBySimplePropertyBetween_AND_SimpleProperty() { QueryParam ageBetween = of(30, 70); QueryParam lastName = of("Simpson"); List results = reactiveRepository.findByAgeBetweenAndLastName(ageBetween, lastName) - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).containsOnly(homer, marge); } @@ -37,7 +37,7 @@ public void findBySimplePropertyBetween_AND_SimpleProperty() { @Test public void findBySimplePropertyBetween_OrderByFirstnameDesc() { List results = reactiveRepository.findByAgeBetweenOrderByFirstNameDesc(30, 70) - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).containsExactly(matt, marge, homer); } diff --git a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/ContainingTests.java b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/ContainingTests.java index 219af693..e28a2521 100644 --- a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/ContainingTests.java +++ b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/ContainingTests.java @@ -17,7 +17,7 @@ public class ContainingTests extends ReactiveCustomerRepositoryQueryTests { @Test public void findBySimplePropertyContaining() { List results = reactiveRepository.findByFirstNameContains("ar") - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).containsOnly(marge, bart); } @@ -25,7 +25,7 @@ public void findBySimplePropertyContaining() { @Test public void findBySimplePropertyContaining_IgnoreCase() { List results = reactiveRepository.findByFirstNameContainingIgnoreCase("m") - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).containsOnly(homer, marge, matt, maggie); } diff --git a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/EqualsTests.java b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/EqualsTests.java index 1aeb773a..307131a9 100644 --- a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/EqualsTests.java +++ b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/EqualsTests.java @@ -22,16 +22,14 @@ public class EqualsTests extends ReactiveCustomerRepositoryQueryTests { @Test public void findById_ShouldReturnExistent() { - Customer result = reactiveRepository.findById(marge.getId()) - .subscribeOn(Schedulers.parallel()).block(); + Customer result = reactiveRepository.findById(marge.getId()).block(); assertThat(result).isEqualTo(marge); } @Test public void findById_ShouldNotReturnNonExistent() { - Customer result = reactiveRepository.findById("non-existent-id") - .subscribeOn(Schedulers.parallel()).block(); + Customer result = reactiveRepository.findById("non-existent-id").block(); assertThat(result).isNull(); } @@ -40,7 +38,7 @@ public void findById_ShouldNotReturnNonExistent() { public void findByIdPublisher_ShouldReturnFirst() { Publisher ids = Flux.just(marge.getId(), matt.getId()); - Customer result = reactiveRepository.findById(ids).subscribeOn(Schedulers.parallel()).block(); + Customer result = reactiveRepository.findById(ids).block(); assertThat(result).isEqualTo(marge); } @@ -48,13 +46,13 @@ public void findByIdPublisher_ShouldReturnFirst() { public void findByIdPublisher_ShouldNotReturnFirstNonExistent() { Publisher ids = Flux.just("non-existent-id", marge.getId(), matt.getId()); - Customer result = reactiveRepository.findById(ids).subscribeOn(Schedulers.parallel()).block(); + Customer result = reactiveRepository.findById(ids).block(); assertThat(result).isNull(); } @Test public void findAll_ShouldReturnAll() { - List results = reactiveRepository.findAll().subscribeOn(Schedulers.parallel()).collectList().block(); + List results = reactiveRepository.findAll().collectList().block(); assertThat(results).contains(homer, marge, bart, matt); } @@ -63,7 +61,7 @@ public void findAllByIdsIterable_ShouldReturnAllExistent() { Iterable ids = asList(marge.getId(), "non-existent-id", matt.getId()); List results = reactiveRepository.findAllById(ids) - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).containsOnly(marge, matt); } @@ -73,7 +71,7 @@ public void findAllByIDsPublisher_ShouldReturnAllExistent() { Publisher ids = Flux.just(homer.getId(), marge.getId(), matt.getId(), "non-existent-id"); List results = reactiveRepository.findAllById(ids) - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).containsOnly(homer, marge, matt); } @@ -81,7 +79,7 @@ public void findAllByIDsPublisher_ShouldReturnAllExistent() { @Test public void findBySimpleProperty() { List results = reactiveRepository.findByLastName("Simpson") - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).containsOnly(homer, marge, bart, lisa, maggie); } @@ -89,7 +87,7 @@ public void findBySimpleProperty() { @Test public void findBySimpleProperty_Projection() { List results = reactiveRepository.findCustomerSomeFieldsByLastName("Simpson") - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).contains(homer.toCustomerSomeFields(), marge.toCustomerSomeFields(), bart.toCustomerSomeFields(), lisa.toCustomerSomeFields(), maggie.toCustomerSomeFields()); @@ -99,7 +97,7 @@ public void findBySimpleProperty_Projection() { public void findDynamicTypeBySimpleProperty_DynamicProjection() { List results = reactiveRepository .findByLastName("Simpson", CustomerSomeFields.class) - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).containsOnly(homer.toCustomerSomeFields(), marge.toCustomerSomeFields(), bart.toCustomerSomeFields(), lisa.toCustomerSomeFields(), maggie.toCustomerSomeFields()); @@ -107,8 +105,7 @@ public void findDynamicTypeBySimpleProperty_DynamicProjection() { @Test public void findOneBySimpleProperty() { - Customer result = reactiveRepository.findOneByLastName("Groening") - .subscribeOn(Schedulers.parallel()).block(); + Customer result = reactiveRepository.findOneByLastName("Groening").block(); assertThat(result).isEqualTo(matt); } @@ -116,7 +113,7 @@ public void findOneBySimpleProperty() { @Test public void findBySimpleProperty_OrderByAsc() { List results = reactiveRepository.findByLastNameOrderByFirstNameAsc("Simpson") - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).contains(bart, homer, marge); } @@ -124,7 +121,7 @@ public void findBySimpleProperty_OrderByAsc() { @Test public void findBySimpleProperty_OrderByDesc() { List results = reactiveRepository.findByLastNameOrderByFirstNameDesc("Simpson") - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).contains(marge, homer, bart); } @@ -134,7 +131,7 @@ public void findBySimpleProperty_AND_SimpleProperty_String() { QueryParam firstName = of("Bart"); QueryParam lastName = of("Simpson"); Customer result = reactiveRepository.findByFirstNameAndLastName(firstName, lastName) - .subscribeOn(Schedulers.parallel()).blockLast(); + .blockLast(); assertThat(result).isEqualTo(bart); } @@ -144,7 +141,7 @@ public void findBySimpleProperty_AND_SimpleProperty_Integer() { QueryParam lastName = of("Simpson"); QueryParam age = of(10); Customer result = reactiveRepository.findByLastNameAndAge(lastName, age) - .subscribeOn(Schedulers.parallel()).blockLast(); + .blockLast(); assertThat(result).isEqualTo(bart); } @@ -152,7 +149,7 @@ public void findBySimpleProperty_AND_SimpleProperty_Integer() { @Test public void findBySimpleProperty_Char() { List results = reactiveRepository.findByGroup('b') - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).containsOnly(marge, bart); } diff --git a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/InTests.java b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/InTests.java index 88a5b7be..baf11770 100644 --- a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/InTests.java +++ b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/InTests.java @@ -20,7 +20,7 @@ public class InTests extends ReactiveCustomerRepositoryQueryTests { @Test public void findByFirstnameIn_ShouldWorkProperly() { List results = reactiveRepository.findByFirstNameIn(asList("Matt", "Homer")) - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).containsOnly(homer, matt); } diff --git a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/LessThanTests.java b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/LessThanTests.java index dccfe6b1..653cce76 100644 --- a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/LessThanTests.java +++ b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/LessThanTests.java @@ -19,7 +19,7 @@ public class LessThanTests extends ReactiveCustomerRepositoryQueryTests { @Test public void findByAgeLessThan_ShouldWorkProperly() { List results = reactiveRepository.findByAgeLessThan(40, Sort.by(asc("firstName"))) - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).containsExactly(bart, leela, lisa, maggie, marge); } diff --git a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/NotEqualTests.java b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/NotEqualTests.java index 988e5176..55f44afa 100644 --- a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/NotEqualTests.java +++ b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/NotEqualTests.java @@ -17,7 +17,7 @@ public class NotEqualTests extends ReactiveCustomerRepositoryQueryTests { @Test public void findBySimplePropertyNot() { List results = reactiveRepository.findByLastNameNot("Simpson") - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).contains(matt); } diff --git a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/StartsWithTests.java b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/StartsWithTests.java index 72c80f47..fac559cc 100644 --- a/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/StartsWithTests.java +++ b/src/test/java/org/springframework/data/aerospike/repository/query/reactive/find/StartsWithTests.java @@ -18,7 +18,7 @@ public class StartsWithTests extends ReactiveCustomerRepositoryQueryTests { @Test public void findByFirstnameStartsWithOrderByAgeAsc_ShouldWorkProperly() { List results = reactiveRepository.findByFirstNameStartsWithOrderByAgeAsc("Ma") - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).containsExactly(maggie, marge, matt); } @@ -27,7 +27,7 @@ public void findByFirstnameStartsWithOrderByAgeAsc_ShouldWorkProperly() { public void findCustomerSomeFieldsByFirstnameStartsWithOrderByAgeAsc_ShouldWorkProperly() { List results = reactiveRepository.findCustomerSomeFieldsByFirstNameStartsWithOrderByFirstNameAsc("Ma") - .subscribeOn(Schedulers.parallel()).collectList().block(); + .collectList().block(); assertThat(results).containsExactly( maggie.toCustomerSomeFields(), marge.toCustomerSomeFields(), matt.toCustomerSomeFields()); diff --git a/src/test/java/org/springframework/data/aerospike/transaction/reactive/ReactiveAerospikeTemplateTransactionTests.java b/src/test/java/org/springframework/data/aerospike/transaction/reactive/ReactiveAerospikeTemplateTransactionTests.java new file mode 100644 index 00000000..281af25e --- /dev/null +++ b/src/test/java/org/springframework/data/aerospike/transaction/reactive/ReactiveAerospikeTemplateTransactionTests.java @@ -0,0 +1,347 @@ +/* + * Copyright 2024 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.aerospike.transaction.reactive; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.RecoverableDataAccessException; +import org.springframework.data.aerospike.BaseReactiveIntegrationTests; +import org.springframework.data.aerospike.sample.Person; +import org.springframework.data.aerospike.sample.SampleClasses; +import org.springframework.data.aerospike.sample.SampleClasses.DocumentWithPrimitiveIntId; +import org.springframework.data.aerospike.util.AsyncUtils; +import org.springframework.data.aerospike.util.TestUtils; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.reactive.TransactionalOperator; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +@Slf4j +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class ReactiveAerospikeTemplateTransactionTests extends BaseReactiveIntegrationTests { + + @Autowired + AerospikeReactiveTransactionManager transactionManager; + + @Autowired + @Qualifier("reactiveTransactionalOperator") + TransactionalOperator transactionalOperator; + + @Autowired + @Qualifier("reactiveTransactionalOperatorWithTimeout2") + TransactionalOperator transactionalOperatorWithTimeout2; + + AerospikeReactiveTransactionManager mockTxManager = mock(AerospikeReactiveTransactionManager.class); + TransactionalOperator mockTxOperator = + TransactionalOperator.create(mockTxManager, new DefaultTransactionDefinition()); + + @BeforeAll + public void beforeAll() { + TestUtils.checkAssumption(serverVersionSupport.isMRTSupported(), + "Skipping transactions tests because Aerospike Server 8.0.0+ is required", log); + } + + @BeforeEach + public void beforeEach() { + deleteAll(Person.class, DocumentWithPrimitiveIntId.class, + SampleClasses.DocumentWithIntegerId.class); + } + + @AfterEach + void verifyTransactionResourcesReleased() { + assertThat(TransactionSynchronizationManager.getResourceMap().isEmpty()).isTrue(); + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); + } + + @AfterAll + public void afterAll() { + deleteAll(Person.class, DocumentWithPrimitiveIntId.class, SampleClasses.DocumentWithIntegerId.class); + } + + @Test + public void verifyOneWriteInTransaction() { + // Multi-record transactions are supported starting with Server version 8.0+ + SampleClasses.DocumentWithIntegerId document = new SampleClasses.DocumentWithIntegerId(500, "test1"); + + // only for testing purposes as performing one write in a transaction lacks sense + reactiveTemplate.insert(document) + // Declaratively apply transaction boundary to the reactive operation + .as(transactionalOperator::transactional) + .as(StepVerifier::create) + .expectNext(document) + .verifyComplete(); + + reactiveTemplate + .findById(500, SampleClasses.DocumentWithIntegerId.class) + .as(StepVerifier::create) + .consumeNextWith(result -> assertThat(result.getContent().equals("test1")).isTrue()) + .verifyComplete(); + } + + @Test + public void verifyMultipleWritesInTransaction() { + // Multi-record transactions are supported starting with Server version 8.0+ + SampleClasses.DocumentWithIntegerId document1 = new SampleClasses.DocumentWithIntegerId(501, "test1"); + SampleClasses.DocumentWithIntegerId document2 = new SampleClasses.DocumentWithIntegerId(501, "test2"); + + reactiveTemplate.insert(document1) + .then(reactiveTemplate.save(document2)) + .then() + .as(transactionalOperator::transactional) + .as(StepVerifier::create) + .verifyComplete(); + + reactiveTemplate + .findById(501, SampleClasses.DocumentWithIntegerId.class) + .as(StepVerifier::create) + .consumeNextWith(result -> assertThat(result.getContent().equals("test2")).isTrue()) + .verifyComplete(); + } + + @Test + public void verifyMultipleWritesInTransactionWithTimeout() { + // Multi-record transactions are supported starting with Server version 8.0+ + SampleClasses.DocumentWithIntegerId document1 = new SampleClasses.DocumentWithIntegerId(501, "test1"); + SampleClasses.DocumentWithIntegerId document2 = new SampleClasses.DocumentWithIntegerId(501, "test2"); + + reactiveTemplate.insert(document1) + // wait less than the specified timeout for this transactional operator + .delayElement(Duration.ofSeconds(1)) + .then(reactiveTemplate.save(document2)) + .then() + .as(transactionalOperatorWithTimeout2::transactional) + .as(StepVerifier::create) + .verifyComplete(); + + reactiveTemplate + .findById(501, SampleClasses.DocumentWithIntegerId.class) + .as(StepVerifier::create) + .consumeNextWith(result -> assertThat(result.getContent().equals("test2")).isTrue()) + .verifyComplete(); + } + + @Test + public void verifyMultipleWritesInTransactionWithTimeoutExpired() { + // Multi-record transactions are supported starting with Server version 8.0+ + SampleClasses.DocumentWithIntegerId document1 = new SampleClasses.DocumentWithIntegerId(501, "test1"); + SampleClasses.DocumentWithIntegerId document2 = new SampleClasses.DocumentWithIntegerId(501, "test2"); + + reactiveTemplate.insert(document1) + // wait more than the specified timeout for this transactional operator + .delayElement(Duration.ofSeconds(3)) + .then(reactiveTemplate.save(document2)) + .then() + .as(transactionalOperatorWithTimeout2::transactional) + .as(StepVerifier::create) + .verifyErrorMatches(throwable -> { + if (throwable instanceof RecoverableDataAccessException) { + return throwable.getMessage().contains("MRT expired"); + } + return false; + }); + } + + @Test + public void oneWriteInTransaction_manual_transactional() { + // Multi-record transactions are supported starting with Server version 8.0+ + SampleClasses.DocumentWithIntegerId document = new SampleClasses.DocumentWithIntegerId(502, "test1"); + + transactionalOperator.transactional(reactiveTemplate.insert(document)).then() + .as(StepVerifier::create) + .verifyComplete(); + + reactiveTemplate + .findById(502, SampleClasses.DocumentWithIntegerId.class) + .as(StepVerifier::create) + .consumeNextWith(result -> assertThat(result.getContent().equals("test1")).isTrue()) + .verifyComplete(); + } + + @Test + public void oneWriteInTransaction_manual_execute() { + // Multi-record transactions are supported starting with Server version 8.0+ + SampleClasses.DocumentWithIntegerId document = new SampleClasses.DocumentWithIntegerId(503, "test1"); + + // Manually manage the transaction by using transactionalOperator.execute() + transactionalOperator.execute(transaction -> { + assertThat(transaction.isNewTransaction()).isTrue(); + assertThat(transaction.hasTransaction()).isTrue(); + assertThat(transaction.isCompleted()).isFalse(); + return reactiveTemplate.insert(document); + }).then() + .as(StepVerifier::create) + .verifyComplete(); + + reactiveTemplate + .findById(503, SampleClasses.DocumentWithIntegerId.class) + .as(StepVerifier::create) + .consumeNextWith(result -> assertThat(result.getContent().equals("test1")).isTrue()) + .verifyComplete(); + } + + @Test + public void multipleWritesInTransaction_manual_execute() { + // Multi-record transactions are supported starting with Server version 8.0+ + SampleClasses.DocumentWithIntegerId document1 = new SampleClasses.DocumentWithIntegerId(504, "test1"); + SampleClasses.DocumentWithIntegerId document2 = new SampleClasses.DocumentWithIntegerId(505, "test2"); + + // Manually manage the transaction by using transactionalOperator.execute() + transactionalOperator.execute(transaction -> { + assertThat(transaction.isNewTransaction()).isTrue(); + assertThat(transaction.hasTransaction()).isTrue(); + assertThat(transaction.isCompleted()).isFalse(); + return reactiveTemplate.insert(document1).then(reactiveTemplate.save(document2)); + }).then() + .as(StepVerifier::create) + .verifyComplete(); + + reactiveTemplate + .count(SampleClasses.DocumentWithIntegerId.class) + .as(StepVerifier::create) + .consumeNextWith(result -> assertThat(result == 2).isTrue()) // both records were written + .verifyComplete(); + } + + @Test + public void verifyRepeatingCommit() { + // Multi-record transactions are supported starting with Server version 8.0+ + SampleClasses.DocumentWithIntegerId document1 = new SampleClasses.DocumentWithIntegerId(506, "test1"); + + // Manually manage the transaction by using transactionalOperator.execute() + transactionalOperator.execute(transaction -> { + assertThat(transaction.isNewTransaction()).isTrue(); + assertThat(transaction.hasTransaction()).isTrue(); + assertThat(transaction.isCompleted()).isFalse(); + return reactiveTemplate.insert(document1) + // calling the first commit manually before end of transaction + .then(transactionManager.commit(transaction)); + }).then() + .as(StepVerifier::create) + .expectError(IllegalTransactionStateException.class); + + reactiveTemplate + .findById(506, SampleClasses.DocumentWithIntegerId.class) + .doOnSuccess(result -> { + assertThat(result).isNull(); // rollback, nothing was written + }) + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + public void verifyTransactionRollback() { + // Multi-record transactions are supported starting with Server version 8.0+ + SampleClasses.DocumentWithIntegerId document = new SampleClasses.DocumentWithIntegerId(507, "test1"); + + reactiveTemplate.insert(document).then(reactiveTemplate.insert(document)) + .as(transactionalOperator::transactional) // Apply transactional context + .as(StepVerifier::create) + .expectError(DuplicateKeyException.class) + .verify(); + + reactiveTemplate + .count(SampleClasses.DocumentWithIntegerId.class) + .as(StepVerifier::create) + .consumeNextWith(result -> assertThat(result == 0).isTrue()) // rollback, nothing was written + .verifyComplete(); + } + + @Test + public void oneWriteInTransaction_multipleThreads() { + // Multi-record transactions are supported starting with Server version 8.0+ + AtomicInteger counter = new AtomicInteger(); + int threadsNumber = 5; + AsyncUtils.executeConcurrently(threadsNumber, () -> { + int counterValue = counter.incrementAndGet(); + reactiveTemplate + .insert(new SampleClasses.DocumentWithIntegerId(508 + counterValue, "test" + counterValue)) + .then() + .as(transactionalOperator::transactional) + .as(StepVerifier::create) + .expectNext() + .verifyComplete(); + }); + + List results = + reactiveTemplate.findAll(SampleClasses.DocumentWithIntegerId.class).collectList().block(); + assertThat(results).isNotNull(); + assertThat(results.size()).isEqualTo(threadsNumber); + } + + @Test + public void rollbackTransaction_multipleThreads() { + // Multi-record transactions are supported starting with Server version 8.0+ + AtomicInteger counter = new AtomicInteger(); + int threadsNumber = 5; + AsyncUtils.executeConcurrently(threadsNumber, () -> { + int counterValue = counter.incrementAndGet(); + reactiveTemplate + .insert(new SampleClasses.DocumentWithIntegerId(509 + counterValue, "test" + counterValue)) + .then(reactiveTemplate.insert( // duplicate insert causes transaction rollback due to an exception + new SampleClasses.DocumentWithIntegerId(509 + counterValue, "test" + counterValue))) + .then() + .as(transactionalOperator::transactional) + .as(StepVerifier::create) + .expectError(); + }); + + List results = + reactiveTemplate.findAll(SampleClasses.DocumentWithIntegerId.class).collectList().block(); + assertThat(results).isNotNull(); + assertThat(results.isEmpty()).isTrue(); + } + + @Test + public void multipleWritesInTransaction_multipleThreads() { + // Multi-record transactions are supported starting with Server version 8.0+ + AtomicInteger counter = new AtomicInteger(); + int threadsNumber = 5; + AsyncUtils.executeConcurrently(threadsNumber, () -> { + int counterValue = counter.incrementAndGet(); + reactiveTemplate + .insert(new SampleClasses.DocumentWithIntegerId(510 + counterValue, "test" + counterValue)) + .then(reactiveTemplate.save( + new SampleClasses.DocumentWithIntegerId(510 + counterValue, "test" + counterValue))) + .then() + .as(transactionalOperator::transactional) + .as(StepVerifier::create) + .verifyComplete(); + }); + + List results = + reactiveTemplate.findAll(SampleClasses.DocumentWithIntegerId.class) + .collectList().block(); + assertThat(results).isNotNull(); + assertThat(results.size()).isEqualTo(threadsNumber); + } +} diff --git a/src/test/java/org/springframework/data/aerospike/transaction/reactive/ReactiveAerospikeTemplateTransactionUnitTests.java b/src/test/java/org/springframework/data/aerospike/transaction/reactive/ReactiveAerospikeTemplateTransactionUnitTests.java new file mode 100644 index 00000000..0a443a2f --- /dev/null +++ b/src/test/java/org/springframework/data/aerospike/transaction/reactive/ReactiveAerospikeTemplateTransactionUnitTests.java @@ -0,0 +1,474 @@ +/* + * Copyright 2024 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.aerospike.transaction.reactive; + +import com.aerospike.client.policy.WritePolicy; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.aerospike.BaseReactiveIntegrationTests; +import org.springframework.data.aerospike.core.model.GroupedEntities; +import org.springframework.data.aerospike.core.model.GroupedKeys; +import org.springframework.data.aerospike.sample.Person; +import org.springframework.data.aerospike.sample.SampleClasses; +import org.springframework.data.aerospike.sample.SampleClasses.DocumentWithPrimitiveIntId; +import org.springframework.data.aerospike.util.TestUtils; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.TransactionSystemException; +import org.springframework.transaction.reactive.GenericReactiveTransaction; +import org.springframework.transaction.reactive.TransactionalOperator; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_MANDATORY; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_NESTED; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_NEVER; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_NOT_SUPPORTED; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_REQUIRED; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_REQUIRES_NEW; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_SUPPORTS; +import static org.springframework.transaction.reactive.TransactionSynchronizationManager.forCurrentTransaction; + +@Slf4j +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class ReactiveAerospikeTemplateTransactionUnitTests extends BaseReactiveIntegrationTests { + + @Autowired + AerospikeReactiveTransactionManager transactionManager; + + @Autowired + @Qualifier("reactiveTransactionalOperator") + TransactionalOperator transactionalOperator; + + AerospikeReactiveTransactionManager mockTxManager = mock(AerospikeReactiveTransactionManager.class); + TransactionalOperator mockTxOperator = + TransactionalOperator.create(mockTxManager, new DefaultTransactionDefinition()); + private ReactiveAerospikeTransactionTestUtils utils; + + @BeforeAll + public void beforeAll() { + TestUtils.checkAssumption(serverVersionSupport.isMRTSupported(), + "Skipping transactions tests because Aerospike Server 8.0.0+ is required", log); + when(mockTxManager.getReactiveTransaction(any())) + .thenReturn(Mono.just( + new GenericReactiveTransaction("name", new AerospikeReactiveTransaction(null), + true, true, false, false, false, null) + )); + when(mockTxManager.commit(any())).thenReturn(Mono.empty()); + when(mockTxManager.rollback(any())).thenReturn(Mono.empty()); + utils = new ReactiveAerospikeTransactionTestUtils(reactorClient, reactiveTemplate, transactionManager); + } + + @BeforeEach + public void beforeEach() { + deleteAll(Person.class, DocumentWithPrimitiveIntId.class, + SampleClasses.DocumentWithIntegerId.class); + } + + @AfterEach + void verifyTransactionResourcesReleased() { + assertThat(TransactionSynchronizationManager.getResourceMap().isEmpty()).isTrue(); + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); + } + + @AfterAll + public void afterAll() { + deleteAll(Person.class, DocumentWithPrimitiveIntId.class, + SampleClasses.DocumentWithIntegerId.class); + } + + @Test + public void writeInTransaction_verifyCommit() { + reactiveTemplate.insert(new DocumentWithPrimitiveIntId(600)).then() + .as(mockTxOperator::transactional) + .as(StepVerifier::create) + .verifyComplete(); + + // verify that commit() was called + verify(mockTxManager).commit(any(GenericReactiveTransaction.class)); + + // resource holder must be already released + assertThat(TransactionSynchronizationManager.getResource(reactorClient)).isNull(); + } + + @Test + public void verifyTransactionExists() { + utils.getTransaction() + .as(transactionalOperator::transactional) + .as(StepVerifier::create) + .consumeNextWith(tran -> { + assertThat(tran).isNotNull(); + assertThat(tran.getId()).isNotNull(); + }) + .verifyComplete(); + } + + private void performTxVerifyCommit(Object documents, Mono action) { + performTxVerifyCommit(documents, action, false); + } + + private void performTxVerifyCommit(Object documents, Mono action, boolean isVoid) { + AerospikeReactiveTransactionManager trackedTxManager = spy(transactionManager); + TransactionalOperator txOperator = TransactionalOperator.create(trackedTxManager); + + if (isVoid) { + utils.performInTxAndVerifyCommitOnComplete(trackedTxManager, txOperator, + action.flatMap(i -> Mono.just(documents))); + } else { + utils.performInTxAndVerifyCommitOnNext(trackedTxManager, txOperator, + action.flatMap(i -> Mono.just(documents))); + } + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void insertInTransaction_verifyCommit() { + DocumentWithPrimitiveIntId document = new DocumentWithPrimitiveIntId(601); + Mono action = reactiveTemplate.insert(document); + + performTxVerifyCommit(document, action); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void insertAllInTransaction_verifyCommit() { + List documents = + List.of(new DocumentWithPrimitiveIntId(602)); + Mono action = reactiveTemplate.insertAll(documents).next(); + + performTxVerifyCommit(documents, action); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void saveInTransaction_verifyCommit() { + DocumentWithPrimitiveIntId document = new DocumentWithPrimitiveIntId(603); + Mono action = reactiveTemplate.save(document); + + performTxVerifyCommit(document, action); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void saveAllInTransaction_verifyCommit() { + List documents = + List.of(new DocumentWithPrimitiveIntId(604)); + Mono action = reactiveTemplate.saveAll(documents).next(); + + performTxVerifyCommit(documents, action); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void updateInTransaction_verifyCommit() { + DocumentWithPrimitiveIntId document = new DocumentWithPrimitiveIntId(605); + reactiveTemplate.insert(document).block(); + Mono action = reactiveTemplate.update(document); + + performTxVerifyCommit(document, action); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void updateAllInTransaction_verifyCommit() { + List documents = + List.of(new DocumentWithPrimitiveIntId(606)); + reactiveTemplate.insertAll(documents).blockLast(); + Mono action = reactiveTemplate.updateAll(documents).next(); + + performTxVerifyCommit(documents, action); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void addInTransaction_verifyCommit() { + DocumentWithPrimitiveIntId document = new DocumentWithPrimitiveIntId(607); + Mono action = reactiveTemplate.add(document, "bin", 607L); + + performTxVerifyCommit(document, action); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void appendInTransaction_verifyCommit() { + DocumentWithPrimitiveIntId document = new DocumentWithPrimitiveIntId(608); + Mono action = reactiveTemplate.append(document, "bin", "test"); + + performTxVerifyCommit(document, action); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void persistInTransaction_verifyCommit() { + DocumentWithPrimitiveIntId document = new DocumentWithPrimitiveIntId(609); + Mono action = + reactiveTemplate.persist(document, reactorClient.getWritePolicyDefault()); + + performTxVerifyCommit(document, action); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void findByIdInTransaction_verifyCommit() { + int id = 610; + reactiveTemplate.insert(new DocumentWithPrimitiveIntId(id)).block(); + Mono action = + reactiveTemplate.findById(id, DocumentWithPrimitiveIntId.class); + + performTxVerifyCommit(id, action); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void findByIdsInTransaction_verifyCommit() { + int id = 611; + List ids = List.of(id); + reactiveTemplate.insert(new DocumentWithPrimitiveIntId(id)).block(); + Mono action = + reactiveTemplate.findByIds(ids, DocumentWithPrimitiveIntId.class).next(); + + performTxVerifyCommit(id, action); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void findByGroupedEntitiesInTransaction_verifyCommit() { + GroupedKeys groupedKeys = GroupedKeys.builder() + .entityKeys(DocumentWithPrimitiveIntId.class, List.of(612)) + .build(); + int id = 612; + reactiveTemplate.insert(new DocumentWithPrimitiveIntId(id)).block(); + Mono action = reactiveTemplate.findByIds(groupedKeys); + + performTxVerifyCommit(id, action); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void existsInTransaction_verifyCommit() { + int id = 613; + reactiveTemplate.insert(new DocumentWithPrimitiveIntId(id)).block(); + Mono action = + reactiveTemplate.exists(id, DocumentWithPrimitiveIntId.class); + + performTxVerifyCommit(id, action); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void deleteInTransaction_verifyCommit() { + DocumentWithPrimitiveIntId document = new DocumentWithPrimitiveIntId(614); + reactiveTemplate.insert(document).block(); + Mono action = reactiveTemplate.delete(document); + + performTxVerifyCommit(document, action); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void deleteAllInTransaction_verifyCommit() { + List documents = List.of(new DocumentWithPrimitiveIntId(615)); + reactiveTemplate.insertAll(documents).blockLast(); + Mono action = reactiveTemplate.deleteAll(documents); + + performTxVerifyCommit(documents, action, true); + } + + @Test + public void verifyOngoingTransaction_withPropagation_required() { + DocumentWithPrimitiveIntId doc = new DocumentWithPrimitiveIntId(701); + + // join an existing transaction if available, it is the default propagation level + utils.verifyOngoingTransaction_withPropagation(doc, PROPAGATION_REQUIRED, 0) + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + public void verifyOngoingTransaction_withPropagation_requiresNew() { + DocumentWithPrimitiveIntId doc = new DocumentWithPrimitiveIntId(702); + + // always create a new transaction + utils.verifyOngoingTransaction_withPropagation(doc, PROPAGATION_REQUIRES_NEW, 1) + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + public void verifyOngoingTransaction_withPropagation_supports() { + DocumentWithPrimitiveIntId doc = new DocumentWithPrimitiveIntId(700); + + // participate in a transaction, or if no transaction is present, run non-transactionally + utils.verifyOngoingTransaction_withPropagation(doc, PROPAGATION_SUPPORTS, 0) + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + public void verifyOngoingTransaction_withPropagation_notSupported() { + DocumentWithPrimitiveIntId doc = new DocumentWithPrimitiveIntId(703); + + // execute non-transactionally, regardless of the presence of an active transaction; + // if a transaction is already active, it will be suspended for the duration of the method execution + utils.verifyOngoingTransaction_withPropagation(doc, PROPAGATION_NOT_SUPPORTED, 1) + .as(StepVerifier::create) + .verifyComplete(); + ; + } + + @Test + public void verifyOngoingTransaction_withPropagation_mandatory() { + DocumentWithPrimitiveIntId doc = new DocumentWithPrimitiveIntId(704); + + // must run within an active transaction + utils.verifyOngoingTransaction_withPropagation(doc, PROPAGATION_MANDATORY, 0) + .as(StepVerifier::create) + .verifyComplete(); + ; + } + + @Test + public void verifyOngoingTransaction_withPropagation_never() { + DocumentWithPrimitiveIntId doc = new DocumentWithPrimitiveIntId(706); + + // never run within a transaction + utils.verifyOngoingTransaction_withPropagation(doc, PROPAGATION_NEVER, 0) + .as(StepVerifier::create) + .expectErrorMatches(e -> { + if (!(e instanceof IllegalTransactionStateException)) { + return false; + } + + String errMsg = "Existing transaction found for transaction marked with propagation 'never'"; + return errMsg.equals(e.getMessage()); + }) + .verify(); + } + + @Test + public void verifyOngoingTransaction_withPropagation_nested() { + DocumentWithPrimitiveIntId doc = new DocumentWithPrimitiveIntId(707); + + // if a transaction exists, mark a savepoint to roll back to in case of an exception + // nested transactions are not supported + utils.verifyOngoingTransaction_withPropagation(doc, PROPAGATION_NESTED, 0) + .as(StepVerifier::create) + .expectErrorMatches(e -> { + if (!(e instanceof TransactionSystemException)) { + return false; + } + + String errMsg = "Could not bind transaction resource"; + return e.getMessage().contains(errMsg); + }) + .verify(); + } + + @Test + public void nativeSessionSynchronization_verifyTransactionProperties() { + transactionalOperator + .execute(transaction -> forCurrentTransaction() + .doOnNext(synchronizationManager -> { + assertThat(synchronizationManager.isSynchronizationActive()).isTrue(); + assertThat(transaction.isNewTransaction()).isTrue(); + assertThat(synchronizationManager.hasResource(reactorClient)).isTrue(); + + assertThat(transaction.isRollbackOnly()).isFalse(); + assertThat(transaction.isCompleted()).isFalse(); + assertThat(transaction.isNested()).isFalse(); + })) + .then() + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + public void nativeSessionSynchronization_verifyTransactionExists() { + transactionalOperator + .execute(transaction -> forCurrentTransaction() + .doOnNext(synchronizationManager -> { + WritePolicy wPolicy = reactorClient.getWritePolicyDefault(); + AerospikeReactiveTransactionResourceHolder rHolder = + (AerospikeReactiveTransactionResourceHolder) synchronizationManager.getResource(reactorClient); + wPolicy.txn = rHolder != null ? rHolder.getTransaction() : null; + assertThat(wPolicy.txn).isNotNull().isEqualTo(rHolder.getTransaction()); + })).then() + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + public void nativeSessionSynchronization_verifySetRollbackOnly() { + transactionalOperator + .execute(transaction -> { + transaction.setRollbackOnly(); + return forCurrentTransaction() + .doOnNext(synchronizationManager -> { + assertThat(synchronizationManager.isSynchronizationActive()).isTrue(); + assertThat(transaction.isNewTransaction()).isTrue(); + assertThat(synchronizationManager.hasResource(reactorClient)).isTrue(); + assertThat(transaction.isRollbackOnly()).isTrue(); + assertThat(transaction.isRollbackOnly()).isTrue(); + }); + }) + .then() + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + public void nativeSessionSynchronization_verifyRollback() { + SampleClasses.DocumentWithIntegerId document = new SampleClasses.DocumentWithIntegerId(616, "test1"); + transactionalOperator + .execute(transaction -> forCurrentTransaction() + .doOnNext(synchronizationManager -> { + WritePolicy wPolicy = reactorClient.getWritePolicyDefault(); + AerospikeReactiveTransactionResourceHolder rHolder = + (AerospikeReactiveTransactionResourceHolder) synchronizationManager.getResource(reactorClient); + wPolicy.txn = rHolder != null ? rHolder.getTransaction() : null; + assertThat(wPolicy.txn).isNotNull().isEqualTo(rHolder.getTransaction()); + + reactiveTemplate.insert(document) + .then(reactiveTemplate.insert(document)) // duplicate insert generates an exception + .as(StepVerifier::create) + .expectError(); + })).then() + .as(StepVerifier::create) + .verifyComplete(); + + reactiveTemplate + .count(SampleClasses.DocumentWithIntegerId.class) + .as(StepVerifier::create) + .consumeNextWith(result -> assertThat(result == 0).isTrue()) // rollback, nothing was written + .verifyComplete(); + } +} diff --git a/src/test/java/org/springframework/data/aerospike/transaction/reactive/ReactiveAerospikeTransactionTestUtils.java b/src/test/java/org/springframework/data/aerospike/transaction/reactive/ReactiveAerospikeTransactionTestUtils.java new file mode 100644 index 00000000..d522466d --- /dev/null +++ b/src/test/java/org/springframework/data/aerospike/transaction/reactive/ReactiveAerospikeTransactionTestUtils.java @@ -0,0 +1,100 @@ +package org.springframework.data.aerospike.transaction.reactive; + +import com.aerospike.client.Txn; +import com.aerospike.client.reactor.IAerospikeReactorClient; +import org.springframework.data.aerospike.core.ReactiveAerospikeTemplate; +import org.springframework.data.aerospike.sample.SampleClasses; +import org.springframework.transaction.NoTransactionException; +import org.springframework.transaction.ReactiveTransactionManager; +import org.springframework.transaction.reactive.TransactionContextManager; +import org.springframework.transaction.reactive.TransactionalOperator; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.transaction.reactive.TransactionSynchronizationManager.forCurrentTransaction; + +public class ReactiveAerospikeTransactionTestUtils { + + private final IAerospikeReactorClient client; + private final ReactiveAerospikeTemplate template; + private final AerospikeReactiveTransactionManager txManager; + + public ReactiveAerospikeTransactionTestUtils(IAerospikeReactorClient client, ReactiveAerospikeTemplate template, + AerospikeReactiveTransactionManager txManager) { + this.client = client; + this.template = template; + this.txManager = txManager; + } + + protected Mono verifyOngoingTransaction_withPropagation(SampleClasses.DocumentWithPrimitiveIntId document, + int propagationType, int numberOfSuspendCalls) { + // Multi-record transactions are supported starting with Server version 8.0+ + AerospikeReactiveTransactionManager trackedTxManager = spy(txManager); + DefaultTransactionDefinition tranDefinition = new DefaultTransactionDefinition(); + tranDefinition.setPropagationBehavior(propagationType); + TransactionalOperator txOperator = TransactionalOperator.create(trackedTxManager, tranDefinition); + + return forCurrentTransaction() + .flatMap(trSyncManager -> { + AerospikeReactiveTransactionResourceHolder rHolder = + new AerospikeReactiveTransactionResourceHolder(client); + trSyncManager.bindResource(client, rHolder); + return txOperator.execute(transaction -> template.insert(document)).then(); + }) + .contextWrite(TransactionContextManager.getOrCreateContext()) + .contextWrite(TransactionContextManager.getOrCreateContextHolder()) + .doOnNext(ignored -> verify(trackedTxManager, times(numberOfSuspendCalls)).doSuspend(any(), any())) + .doOnNext(ignored -> template.count(SampleClasses.DocumentWithPrimitiveIntId.class) + .map(count -> assertThat(count) + .withFailMessage("Record was not written") + .isEqualTo(1))) + .then(); + } + + protected Mono getTransaction() { + return TransactionContextManager.currentContext() + .flatMap(ctx -> { + AerospikeReactiveTransactionResourceHolder resourceHolder = + (AerospikeReactiveTransactionResourceHolder) ctx.getResources().get(client); + Txn tran = resourceHolder != null ? + resourceHolder.getTransaction() : null; + return Mono.just(tran); + }) + .onErrorResume(NoTransactionException.class, ignored -> + Mono.empty()); + } + + protected Mono performInTxAndVerifyCommitOnNext(ReactiveTransactionManager txManager, + TransactionalOperator txOperator, Mono action) { + action + .as(txOperator::transactional) + .as(StepVerifier::create) + .consumeNextWith(item -> { + // verify that commit() was called + verify(txManager).commit(any()); + }) + .verifyComplete(); + + return Mono.empty(); + } + + protected Mono performInTxAndVerifyCommitOnComplete(ReactiveTransactionManager txManager, + TransactionalOperator txOperator, Mono action) { + action + .as(txOperator::transactional) + .doOnSuccess(i -> { + // verify that commit() was called + verify(txManager).commit(any()); + }) + .as(StepVerifier::create) + .verifyComplete(); + + return Mono.empty(); + } +} diff --git a/src/test/java/org/springframework/data/aerospike/transaction/sync/AerospikeTemplateTransactionTests.java b/src/test/java/org/springframework/data/aerospike/transaction/sync/AerospikeTemplateTransactionTests.java new file mode 100644 index 00000000..d9f95d0c --- /dev/null +++ b/src/test/java/org/springframework/data/aerospike/transaction/sync/AerospikeTemplateTransactionTests.java @@ -0,0 +1,436 @@ +/* + * Copyright 2024 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.aerospike.transaction.sync; + +import com.aerospike.client.AerospikeException; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.RecoverableDataAccessException; +import org.springframework.data.aerospike.BaseBlockingIntegrationTests; +import org.springframework.data.aerospike.sample.Person; +import org.springframework.data.aerospike.sample.SampleClasses; +import org.springframework.data.aerospike.sample.SampleClasses.DocumentWithPrimitiveIntId; +import org.springframework.data.aerospike.util.AsyncUtils; +import org.springframework.data.aerospike.util.AwaitilityUtils; +import org.springframework.data.aerospike.util.TestUtils; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.UnexpectedRollbackException; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.transaction.support.TransactionCallbackWithoutResult; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +@Slf4j +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class AerospikeTemplateTransactionTests extends BaseBlockingIntegrationTests { + + @Autowired + AerospikeTransactionManager transactionManager; + + @Autowired + TransactionTemplate transactionTemplate; + + @BeforeAll + public void beforeAll() { + TestUtils.checkAssumption(serverVersionSupport.isMRTSupported(), + "Skipping transactions tests because Aerospike Server 8.0.0+ is required", log); + } + + @BeforeEach + public void beforeEach() { + deleteAll(Person.class, DocumentWithPrimitiveIntId.class, + SampleClasses.DocumentWithIntegerId.class); + } + + @AfterEach + void verifyTransactionResourcesReleased() { + assertThat(TransactionSynchronizationManager.getResourceMap().isEmpty()).isTrue(); + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); + } + + @AfterAll + public void afterAll() { + deleteAll(Person.class, DocumentWithPrimitiveIntId.class, + SampleClasses.DocumentWithIntegerId.class); + } + + @Test + // just for testing purposes as performing only one write in a transaction lacks sense + public void writeInTransaction() { + // Multi-record transactions are supported starting with Server version 8.0+ + transactionTemplate.executeWithoutResult(status -> { + assertThat(status.isNewTransaction()).isTrue(); + template.insert(new SampleClasses.DocumentWithIntegerId(107, "test1")); + }); + + SampleClasses.DocumentWithIntegerId result = template.findById(107, SampleClasses.DocumentWithIntegerId.class); + assertThat(result.getContent().equals("test1")).isTrue(); + } + + @Test + public void multipleWritesInTransaction() { + // Multi-record transactions are supported starting with Server version 8.0+ + transactionTemplate.executeWithoutResult(status -> { + assertThat(status.isNewTransaction()).isTrue(); + template.insert(new SampleClasses.DocumentWithIntegerId(101, "test1")); + template.save(new SampleClasses.DocumentWithIntegerId(101, "test2")); + }); + + SampleClasses.DocumentWithIntegerId result = template.findById(101, SampleClasses.DocumentWithIntegerId.class); + assertThat(result.getContent().equals("test2")).isTrue(); + } + + @Test + public void multipleWritesInTransactionWithTimeout() { + // Multi-record transactions are supported starting with Server version 8.0+ + transactionTemplate.setTimeout(2); // timeout after the first command within a transaction + transactionTemplate.executeWithoutResult(status -> { + assertThat(status.isNewTransaction()).isTrue(); + template.insert(new SampleClasses.DocumentWithIntegerId(114, "test1")); + AwaitilityUtils.wait(1, SECONDS); // timeout does not expire during this wait + template.save(new SampleClasses.DocumentWithIntegerId(114, "test2")); + }); + + SampleClasses.DocumentWithIntegerId result = + template.findById(114, SampleClasses.DocumentWithIntegerId.class); + assertThat(result.getContent().equals("test2")).isTrue(); + } + + @Test + public void multipleWritesInTransactionWithTimeoutExpired() { + // Multi-record transactions are supported starting with Server version 8.0+ + transactionTemplate.setTimeout(2); // timeout after the first command within a transaction + assertThatThrownBy(() -> transactionTemplate.executeWithoutResult(status -> { + template.insert(new SampleClasses.DocumentWithIntegerId(115, "test1")); + AwaitilityUtils.wait(5, SECONDS); // timeout expires during this wait + template.save(new SampleClasses.DocumentWithIntegerId(115, "test2")); + })) + .isInstanceOf(RecoverableDataAccessException.class) + .hasMessageContaining("MRT expired"); + + SampleClasses.DocumentWithIntegerId result = template.findById(115, SampleClasses.DocumentWithIntegerId.class); + assertThat(result).isNull(); // No record is written because all commands were in the same transaction + } + + @Test + // just for testing purposes as performing only one write in a transaction lacks sense + public void batchWriteInTransaction() { + // Multi-record transactions are supported starting with Server version 8.0+ + List persons = IntStream.range(1, 10) + .mapToObj(age -> Person.builder().id(nextId()) + .firstName("Gregor") + .age(age).build()) + .collect(Collectors.toList()); + + transactionTemplate.executeWithoutResult(status -> { + assertThat(status.isNewTransaction()).isTrue(); + template.insertAll(persons); + }); + + Stream results = template.findAll(Person.class); + assertThat(results.toList()).containsAll(persons); + } + + @Test + public void multipleBatchWritesInTransactionWithTimeout() { + // Multi-record transactions are supported starting with Server version 8.0+ + transactionTemplate.setTimeout(2); // timeout after the first command within a transaction + transactionTemplate.executeWithoutResult(status -> { + assertThat(status.isNewTransaction()).isTrue(); + template.insertAll(List.of(new DocumentWithPrimitiveIntId(116), + new DocumentWithPrimitiveIntId(117))); + AwaitilityUtils.wait(1, SECONDS); // timeout does not expire during this wait + template.insertAll(List.of(new DocumentWithPrimitiveIntId(118), + new DocumentWithPrimitiveIntId(119))); + }); + + assertThat(template.findById(116, DocumentWithPrimitiveIntId.class)).isNotNull(); + assertThat(template.findById(117, DocumentWithPrimitiveIntId.class)).isNotNull(); + assertThat(template.findById(118, DocumentWithPrimitiveIntId.class)).isNotNull(); + assertThat(template.findById(119, DocumentWithPrimitiveIntId.class)).isNotNull(); + } + + @Test + public void multipleBatchWritesInTransactionWithTimeoutExpired() { + // Multi-record transactions are supported starting with Server version 8.0+ + transactionTemplate.setTimeout(2); // timeout after the first command within a transaction + assertThatThrownBy(() -> transactionTemplate.executeWithoutResult(status -> { + template.insertAll(List.of(new DocumentWithPrimitiveIntId(120), + new DocumentWithPrimitiveIntId(121))); + AwaitilityUtils.wait(3, SECONDS); // timeout expires during this wait + template.insertAll(List.of(new DocumentWithPrimitiveIntId(122), + new DocumentWithPrimitiveIntId(123))); + })) + .isInstanceOf(AerospikeException.BatchRecordArray.class) + .hasMessageContaining("Batch failed"); + + SampleClasses.DocumentWithIntegerId result = + template.findById(120, SampleClasses.DocumentWithIntegerId.class); + assertThat(result).isNull(); // No record is written because of rollback of the transaction + } + + @Test + public void oneWriteInTransaction_rollback() { + template.insert(new DocumentWithPrimitiveIntId(102)); + + assertThatThrownBy(() -> transactionTemplate.executeWithoutResult(status -> + template.insert(new DocumentWithPrimitiveIntId(102)))) + .isInstanceOf(DuplicateKeyException.class) + .hasMessageContaining("Key already exists"); + + DocumentWithPrimitiveIntId result = template.findById(102, + DocumentWithPrimitiveIntId.class); + assertThat(result.getId()).isEqualTo(102); + } + + @Test + public void multipleWritesInTransaction_rollback() { + assertThatThrownBy(() -> transactionTemplate.executeWithoutResult(status -> { + template.insert(new DocumentWithPrimitiveIntId(103)); + template.insert(new DocumentWithPrimitiveIntId(103)); + })) + .isInstanceOf(DuplicateKeyException.class) + .hasMessageContaining("Key already exists"); + + // No record is written because all inserts were in the same transaction + assertThat(template.findById(103, DocumentWithPrimitiveIntId.class)).isNull(); + } + + @Test + public void ongoingTransaction_commit() { + // initialize a new transaction with an empty resource holder + // subsequently doBegin() gets called which adds a new resource holder to the transaction + TransactionStatus transactionStatus = transactionManager.getTransaction(new DefaultTransactionDefinition()); + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + + transactionTemplate.execute(new TransactionCallbackWithoutResult() { + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + template.insert(new DocumentWithPrimitiveIntId(104)); + AwaitilityUtils.wait(5, SECONDS); + } + }); + + transactionManager.commit(transactionStatus); + assertThat(template.findById(104, DocumentWithPrimitiveIntId.class)).isNotNull(); + } + + @Test + public void ongoingTransaction_repeatingCommit() { + // initialize a new transaction with an empty resource holder + // subsequently doBegin() gets called which adds a new resource holder to the transaction + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + + transactionTemplate.execute(new TransactionCallbackWithoutResult() { + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + template.insert(new DocumentWithPrimitiveIntId(105)); + } + }); + + TransactionStatus transactionStatus = transactionManager.getTransaction(new DefaultTransactionDefinition()); + transactionManager.commit(transactionStatus); + assertThatThrownBy(() -> transactionManager.commit(transactionStatus)) + .isInstanceOf(IllegalTransactionStateException.class) + .hasMessageContaining("Transaction is already completed"); + assertThat(template.findById(105, DocumentWithPrimitiveIntId.class)).isNotNull(); + } + + @Test + public void ongoingTransaction_commit_existingTransaction() { + // in the regular flow binding a resource is done within doBegin() in AerospikeTransactionManager + // binding resource holder manually here to make getTransaction() recognize an ongoing transaction + // and not to start a new transaction automatically in order to be able to start it manually later + AerospikeTransactionResourceHolder resourceHolder = new AerospikeTransactionResourceHolder(client); + TransactionSynchronizationManager.bindResource(client, resourceHolder); + + // initialize a new transaction with the existing resource holder if it is already bound + TransactionStatus transactionStatus = spy( + transactionManager.getTransaction(new DefaultTransactionDefinition())); + resourceHolder.setSynchronizedWithTransaction(true); + + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.execute(new TransactionCallbackWithoutResult() { + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + template.insert(new DocumentWithPrimitiveIntId(106)); + } + }); + + // changing the status manually here to simulate a new transaction + // because otherwise with an ongoing transaction (isNextTransaction() == false) and the default propagation + // doBegin() and doCommit() are not called automatically waiting to participate in the ongoing transaction + when(transactionStatus.isNewTransaction()).thenReturn(true); + transactionManager.commit(transactionStatus); + assertThat(template.findById(106, DocumentWithPrimitiveIntId.class).getId()) + .isEqualTo(106); + } + + @Test + public void ongoingTransaction_withStatusRollbackOnly() { + // initialize a new transaction with an empty resource holder + // subsequently doBegin() gets called which adds a new resource holder to the transaction + TransactionStatus transactionStatus = transactionManager.getTransaction(new DefaultTransactionDefinition()); + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + + transactionTemplate.execute(new TransactionCallbackWithoutResult() { + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + template.insert(new DocumentWithPrimitiveIntId(108)); + status.setRollbackOnly(); // set rollbackOnly + } + }); + + assertThatThrownBy(() -> transactionManager.commit(transactionStatus)) + .isInstanceOf(UnexpectedRollbackException.class); + assertThat(template.findById(108, DocumentWithPrimitiveIntId.class)).isNull(); + } + + @Test + public void ongoingTransaction_rollback() { + // initialize a new transaction with an empty resource holder + // subsequently doBegin() gets called which adds a new resource holder to the transaction + TransactionStatus transactionStatus = transactionManager.getTransaction(new DefaultTransactionDefinition()); + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + + transactionTemplate.execute(new TransactionCallbackWithoutResult() { + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + template.insert(new DocumentWithPrimitiveIntId(109)); + } + }); + + transactionManager.rollback(transactionStatus); + assertThat(template.findById(109, DocumentWithPrimitiveIntId.class)).isNull(); + } + + @Test + public void ongoingTransaction_repeatingRollback() { + // initialize a new transaction with an empty resource holder + // subsequently doBegin() gets called which adds a new resource holder to the transaction + TransactionStatus transactionStatus = transactionManager.getTransaction(new DefaultTransactionDefinition()); + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + + transactionTemplate.execute(new TransactionCallbackWithoutResult() { + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + template.insert(new DocumentWithPrimitiveIntId(110)); + } + }); + + transactionManager.rollback(transactionStatus); + assertThatThrownBy(() -> transactionManager.rollback(transactionStatus)) + .isInstanceOf(IllegalTransactionStateException.class) + .hasMessageContaining("Transaction is already completed"); + assertThat(template.findById(110, DocumentWithPrimitiveIntId.class)).isNull(); + } + + @Test + public void oneWriteInTransaction_multipleThreads() { + // Multi-record transactions are supported starting with Server version 8.0+ + AtomicInteger counter = new AtomicInteger(); + int threadsNumber = 5; + AsyncUtils.executeConcurrently(threadsNumber, () -> { + int counterValue = counter.incrementAndGet(); + transactionTemplate.executeWithoutResult(status -> { + assertThat(status.isNewTransaction()).isTrue(); + template.insert( + new SampleClasses.DocumentWithIntegerId(111 + counterValue, "test" + counterValue)); + }); + }); + + List results = + template.findAll(SampleClasses.DocumentWithIntegerId.class).toList(); + assertThat(results.size() == threadsNumber).isTrue(); + } + + @Test + public void rollbackTransaction_multipleThreads() { + // Multi-record transactions are supported starting with Server version 8.0+ + AtomicInteger counter = new AtomicInteger(); + int threadsNumber = 10; + AsyncUtils.executeConcurrently(threadsNumber, () -> { + int counterValue = counter.incrementAndGet(); + assertThatThrownBy(() -> transactionTemplate.executeWithoutResult(status -> { + template.insert(new SampleClasses.DocumentWithIntegerId(112 + counterValue, "test" + counterValue)); + template.insert(new SampleClasses.DocumentWithIntegerId(112 + counterValue, "test" + counterValue)); + })) + .isInstanceOf(DuplicateKeyException.class) + .hasMessageContaining("Key already exists"); + }); + + List results = + template.findAll(SampleClasses.DocumentWithIntegerId.class).toList(); + assertThat(results.isEmpty()).isTrue(); + } + + @Test + public void multipleWritesInTransaction_multipleThreads() { + // Multi-record transactions are supported starting with Server version 8.0+ + AtomicInteger counter = new AtomicInteger(); + int threadsNumber = 10; + AsyncUtils.executeConcurrently(threadsNumber, () -> { + int counterValue = counter.incrementAndGet(); + transactionTemplate.executeWithoutResult(status -> { + assertThat(status.isNewTransaction()).isTrue(); + template.insert( + new SampleClasses.DocumentWithIntegerId(113 + counterValue, "test" + counterValue)); + template.save( + new SampleClasses.DocumentWithIntegerId(113 + counterValue, "test_`" + counterValue)); + }); + }); + + List results = + template.findAll(SampleClasses.DocumentWithIntegerId.class).toList(); + assertThat(results.size() == threadsNumber).isTrue(); + } + + @Test + // just for testing purposes as performing only one write in a transaction lacks sense + public void deleteInTransaction() { + DocumentWithPrimitiveIntId doc = new DocumentWithPrimitiveIntId(1000); + template.insert(doc); + // Multi-record transactions are supported starting with Server version 8.0+ + transactionTemplate.executeWithoutResult(status -> { + assertThat(status.isNewTransaction()).isTrue(); + template.delete(doc); + }); + + DocumentWithPrimitiveIntId result = + template.findById(1000, DocumentWithPrimitiveIntId.class); + assertThat(result).isNull(); + } +} diff --git a/src/test/java/org/springframework/data/aerospike/transaction/sync/AerospikeTemplateTransactionUnitTests.java b/src/test/java/org/springframework/data/aerospike/transaction/sync/AerospikeTemplateTransactionUnitTests.java new file mode 100644 index 00000000..25a772c3 --- /dev/null +++ b/src/test/java/org/springframework/data/aerospike/transaction/sync/AerospikeTemplateTransactionUnitTests.java @@ -0,0 +1,282 @@ +/* + * Copyright 2024 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.aerospike.transaction.sync; + +import com.aerospike.client.Txn; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.aerospike.BaseBlockingIntegrationTests; +import org.springframework.data.aerospike.core.model.GroupedKeys; +import org.springframework.data.aerospike.sample.Person; +import org.springframework.data.aerospike.sample.SampleClasses; +import org.springframework.data.aerospike.sample.SampleClasses.DocumentWithPrimitiveIntId; +import org.springframework.data.aerospike.util.TestUtils; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.NestedTransactionNotSupportedException; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.springframework.data.aerospike.transaction.sync.AerospikeTransactionTestUtils.getTransaction; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_MANDATORY; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_NESTED; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_NEVER; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_NOT_SUPPORTED; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_REQUIRED; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_REQUIRES_NEW; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_SUPPORTS; + +@Slf4j +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class AerospikeTemplateTransactionUnitTests extends BaseBlockingIntegrationTests { + + @Autowired + TransactionTemplate transactionTemplate; + + private AerospikeTransactionTestUtils utils; + + @BeforeAll + public void beforeAll() { + TestUtils.checkAssumption(serverVersionSupport.isMRTSupported(), + "Skipping transactions tests because Aerospike Server 8.0.0+ is required", log); + utils = new AerospikeTransactionTestUtils(client, template); + } + + @BeforeEach + public void beforeEach() { + deleteAll(Person.class, DocumentWithPrimitiveIntId.class, + SampleClasses.DocumentWithIntegerId.class); + } + + @AfterEach + void verifyTransactionResourcesReleased() { + assertThat(TransactionSynchronizationManager.getResourceMap().isEmpty()).isTrue(); + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); + } + + @AfterAll + public void afterAll() { + deleteAll(Person.class, DocumentWithPrimitiveIntId.class, + SampleClasses.DocumentWithIntegerId.class); + } + + @Test + public void verifyTransactionExists() { + AtomicReference tx = new AtomicReference<>(); + + transactionTemplate.executeWithoutResult(status -> { + tx.set(getTransaction(client)); + }); + + Txn transaction = tx.get(); + assertThat(transaction).isNotNull(); + assertThat(transaction.getId()).isNotNull(); + } + + private void performTxVerifyCommit(T documents, AerospikeTransactionTestUtils.TransactionAction action) { + AerospikeTransactionManager mockTxManager = mock(AerospikeTransactionManager.class); + TransactionTemplate mockTxTemplate = new TransactionTemplate(mockTxManager); + + utils.performInTxAndVerifyCommit(mockTxManager, mockTxTemplate, documents, action); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void insertInTransaction_verifyCommit() { + DocumentWithPrimitiveIntId document = new DocumentWithPrimitiveIntId(200); + performTxVerifyCommit(document, (argument, status) -> template.insert(document)); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void insertAllInTransaction_verifyCommit() { + List documents = List.of(new DocumentWithPrimitiveIntId(201)); + performTxVerifyCommit(documents, (argument, status) -> template.insertAll(documents)); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void saveInTransaction_verifyCommit() { + DocumentWithPrimitiveIntId document = new DocumentWithPrimitiveIntId(202); + performTxVerifyCommit(document, (argument, status) -> template.save(document)); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void saveAllInTransaction_verifyCommit() { + List documents = List.of(new DocumentWithPrimitiveIntId(203)); + performTxVerifyCommit(documents, (argument, status) -> template.saveAll(documents)); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void updateInTransaction_verifyCommit() { + DocumentWithPrimitiveIntId document = new DocumentWithPrimitiveIntId(204); + template.insert(document); + performTxVerifyCommit(document, (argument, status) -> template.update(document)); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void updateAllInTransaction_verifyCommit() { + List documents = List.of(new DocumentWithPrimitiveIntId(205)); + template.insertAll(documents); + performTxVerifyCommit(documents, (argument, status) -> template.updateAll(documents)); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void addInTransaction_verifyCommit() { + DocumentWithPrimitiveIntId document = new DocumentWithPrimitiveIntId(206); + performTxVerifyCommit(document, (argument, status) -> template.add(document, "bin", 206L)); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void appendInTransaction_verifyCommit() { + DocumentWithPrimitiveIntId document = new DocumentWithPrimitiveIntId(207); + performTxVerifyCommit(document, (argument, status) -> template.append(document, "bin", "test")); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void persistInTransaction_verifyCommit() { + DocumentWithPrimitiveIntId document = new DocumentWithPrimitiveIntId(208); + performTxVerifyCommit(document, (argument, status) -> template.persist(document, client.getWritePolicyDefault())); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void findByIdInTransaction_verifyCommit() { + int id = 209; + performTxVerifyCommit(id, (argument, status) -> template.findById(id, DocumentWithPrimitiveIntId.class)); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void findByIdsInTransaction_verifyCommit() { + List ids = List.of(210); + performTxVerifyCommit(ids, (argument, status) -> template.findByIds(ids, DocumentWithPrimitiveIntId.class)); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void findByGroupedEntitiesInTransaction_verifyCommit() { + GroupedKeys groupedKeys = GroupedKeys.builder() + .entityKeys(DocumentWithPrimitiveIntId.class, List.of(211)) + .build(); + performTxVerifyCommit(groupedKeys, (argument, status) -> template.findByIds(groupedKeys)); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void existsInTransaction_verifyCommit() { + int id = 212; + performTxVerifyCommit(id, (argument, status) -> template.exists(id, DocumentWithPrimitiveIntId.class)); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void deleteInTransaction_verifyCommit() { + DocumentWithPrimitiveIntId document = new DocumentWithPrimitiveIntId(213); + template.insert(document); + performTxVerifyCommit(document, (argument, status) -> template.delete(document)); + } + + @Test + // just for testing purposes as performing only one operation in a transaction lacks sense + public void deleteAllInTransaction_verifyCommit() { + List documents = List.of(new DocumentWithPrimitiveIntId(214)); + template.insertAll(documents); + performTxVerifyCommit(documents, (argument, status) -> template.deleteAll(documents)); + } + + @Test + public void ongoingTransactions_twoWrites_withPropagation_required() { + // join an existing transaction if available, it is the default propagation level + utils.verifyTwoWritesEachInOngoingTransactionWithPropagation(PROPAGATION_REQUIRED, 1, 0); + } + + @Test + public void ongoingTransactions_twoWrites_withPropagation_requiresNew() { + // always create a new transaction + utils.verifyTwoWritesEachInOngoingTransactionWithPropagation(PROPAGATION_REQUIRES_NEW, 2, 2); + } + + @Test + public void ongoingTransactions_twoWrites_withPropagation_supports() { + // participate in a transaction, or if no transaction is present, run non-transactionally + utils.verifyTwoWritesEachInOngoingTransactionWithPropagation(PROPAGATION_SUPPORTS, 1, 0); + } + + @Test + public void ongoingTransactions_twoWrites_withPropagation_notSupported() { + // execute non-transactionally, regardless of the presence of an active transaction; + // if a transaction is already active, it will be suspended for the duration of the method execution + utils.verifyTwoWritesEachInOngoingTransactionWithPropagation(PROPAGATION_NOT_SUPPORTED, 0, 2); + } + + @Test + public void ongoingTransactions_twoWrites_withPropagation_mandatory() { + // must run within an active transaction + utils.verifyTwoWritesEachInOngoingTransactionWithPropagation(PROPAGATION_MANDATORY, 1, 0); + } + + @Test + public void ongoingTransactions_twoWrites_withPropagation_never() { + // never run within a transaction + assertThatThrownBy(() -> + utils.verifyTwoWritesEachInOngoingTransactionWithPropagation(PROPAGATION_NEVER, 0, 0)) + .isInstanceOf(IllegalTransactionStateException.class) + .hasMessageContaining("Existing transaction found for transaction marked with propagation 'never'"); + + TransactionSynchronizationManager.unbindResource(client); // cleanup + TransactionSynchronizationManager.clear(); + } + + @Test + public void ongoingTransactions_twoWrites_withPropagation_nested() { + // if a transaction exists, mark a savepoint to roll back to in case of an exception + // nested transactions are not supported + assertThatThrownBy(() -> + utils.verifyTwoWritesEachInOngoingTransactionWithPropagation(PROPAGATION_NESTED, 0, 0)) + .isInstanceOf(NestedTransactionNotSupportedException.class) + .hasMessageContaining("Transaction manager does not allow nested transactions by default - " + + "specify 'nestedTransactionAllowed' property with value 'true'"); + + TransactionSynchronizationManager.unbindResource(client); // cleanup + TransactionSynchronizationManager.clear(); + + assertThatThrownBy(() -> + utils.verifyTwoWritesEachInOngoingTransactionWithPropagation(PROPAGATION_NESTED, 1, 0, true)) + .isInstanceOf(NestedTransactionNotSupportedException.class) + .hasMessageMatching("Transaction object .* does not support savepoints"); + + TransactionSynchronizationManager.unbindResource(client); // cleanup + TransactionSynchronizationManager.clear(); + } +} diff --git a/src/test/java/org/springframework/data/aerospike/transaction/sync/AerospikeTransactionTestUtils.java b/src/test/java/org/springframework/data/aerospike/transaction/sync/AerospikeTransactionTestUtils.java new file mode 100644 index 00000000..8f3d5492 --- /dev/null +++ b/src/test/java/org/springframework/data/aerospike/transaction/sync/AerospikeTransactionTestUtils.java @@ -0,0 +1,145 @@ +package org.springframework.data.aerospike.transaction.sync; + +import com.aerospike.client.IAerospikeClient; +import com.aerospike.client.Txn; +import org.springframework.data.aerospike.core.AerospikeTemplate; +import org.springframework.data.aerospike.sample.SampleClasses; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.transaction.support.TransactionCallbackWithoutResult; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_MANDATORY; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_REQUIRED; +import static org.springframework.transaction.TransactionDefinition.PROPAGATION_SUPPORTS; + +public class AerospikeTransactionTestUtils { + + private final IAerospikeClient client; + private final AerospikeTemplate template; + + public AerospikeTransactionTestUtils(IAerospikeClient client, AerospikeTemplate template) { + this.client = client; + this.template = template; + } + + protected void verifyTwoWritesEachInOngoingTransactionWithPropagation(int propagationType, + int numberOfCommitCalls, + int numberOfSuspendCalls) { + verifyTwoWritesEachInOngoingTransactionWithPropagation(propagationType, numberOfCommitCalls, + numberOfSuspendCalls, false); + } + + protected void verifyTwoWritesEachInOngoingTransactionWithPropagation(int propagationType, + int numberOfCommitCalls, + int numberOfSuspendCalls, + boolean isPropagationNested) { + // in the regular flow binding a resource is done within doBegin() in AerospikeTransactionManager + // binding resource holder manually here to make getTransaction() recognize an ongoing transaction + // and not to start a new transaction automatically in order to be able to start it manually later + AerospikeTransactionResourceHolder resourceHolder = new AerospikeTransactionResourceHolder(client); + TransactionSynchronizationManager.bindResource(client, resourceHolder); + + AerospikeTransactionManager trackedTransactionManager = spy(new AerospikeTransactionManager(client)); + if (isPropagationNested) trackedTransactionManager.setNestedTransactionAllowed(true); + TransactionTemplate trackedTransactionTemplate = spy(new TransactionTemplate(trackedTransactionManager)); + + // initialize a new transaction with the existing resource holder if it is already bound + TransactionStatus trackedTransactionStatus = spy( + trackedTransactionManager.getTransaction(new DefaultTransactionDefinition())); + resourceHolder.setSynchronizedWithTransaction(true); + + trackedTransactionTemplate.setPropagationBehavior(propagationType); + // execute() will call getTransaction() with the existing resource holder + // and handleExistingTransaction() with behaviour depending on the given propagation + trackedTransactionTemplate.execute(new TransactionCallbackWithoutResult() { + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + template.insert(new SampleClasses.DocumentWithPrimitiveIntId(100)); + template.save(new SampleClasses.DocumentWithPrimitiveIntId(200)); + } + }); + + // execute() will call getTransaction() with the existing resource holder + // and handleExistingTransaction() with behaviour depending on the given propagation + trackedTransactionTemplate.execute(new TransactionCallbackWithoutResult() { + @Override + protected void doInTransactionWithoutResult(TransactionStatus status) { + template.insert(new SampleClasses.DocumentWithPrimitiveIntId(300)); + template.save(new SampleClasses.DocumentWithPrimitiveIntId(400)); + } + }); + + List propagationToUseExistingTransaction = List.of(PROPAGATION_REQUIRED, PROPAGATION_SUPPORTS, + PROPAGATION_MANDATORY); + + if (propagationToUseExistingTransaction.contains(propagationType)) { + // changing the status manually here to simulate a new transaction + // because otherwise with an ongoing transaction (isNextTransaction() == false) and the given propagation + // doBegin() and doCommit() are not called automatically waiting to participate in the ongoing transaction + doReturn(true).when(trackedTransactionStatus).isNewTransaction(); + } + trackedTransactionManager.commit(trackedTransactionStatus); + + verify(trackedTransactionManager, times(numberOfCommitCalls)).doCommit(any()); + verify(trackedTransactionManager, times(numberOfSuspendCalls)).doSuspend(any()); + + if (!propagationToUseExistingTransaction.contains(propagationType)) { + // otherwise cleanup happens automatically + TransactionSynchronizationManager.unbindResource(client); // cleanup + TransactionSynchronizationManager.clear(); + resourceHolder.clear(); + } + } + + protected static Txn getTransaction(IAerospikeClient client) { + Txn tran = null; + if (TransactionSynchronizationManager.hasResource(client)) { + AerospikeTransactionResourceHolder resourceHolder = + (AerospikeTransactionResourceHolder) TransactionSynchronizationManager.getResource(client); + tran = resourceHolder.getTransaction(); + } + return tran; + } + + protected static Txn getTransaction2(IAerospikeClient client) { + Txn tran = null; + if (TransactionSynchronizationManager.hasResource(client)) { + AerospikeTransactionResourceHolder resourceHolder = + (AerospikeTransactionResourceHolder) TransactionSynchronizationManager.getResource(client); + tran = resourceHolder.getTransaction(); + } + return tran; + } + + protected static Txn callGetTransaction(IAerospikeClient client) { + return getTransaction(client); + } + + @FunctionalInterface + protected interface TransactionAction { + + void perform(T argument, TransactionStatus status); + } + + protected void performInTxAndVerifyCommit(PlatformTransactionManager txManager, TransactionTemplate txTemplate, + T documents, TransactionAction action) { + txTemplate.executeWithoutResult(status -> action.perform(documents, status)); + + // verify that commit() was called + verify(txManager).commit(any()); + + // resource holder must be already released + assertThat(TransactionSynchronizationManager.getResource(client)).isNull(); + } +} diff --git a/src/test/java/org/springframework/data/aerospike/transaction/sync/AerospikeTransactionalAnnotationTests.java b/src/test/java/org/springframework/data/aerospike/transaction/sync/AerospikeTransactionalAnnotationTests.java new file mode 100644 index 00000000..7b80d5d6 --- /dev/null +++ b/src/test/java/org/springframework/data/aerospike/transaction/sync/AerospikeTransactionalAnnotationTests.java @@ -0,0 +1,270 @@ +/* + * Copyright 2024 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.aerospike.transaction.sync; + +import com.aerospike.client.AerospikeException; +import com.aerospike.client.Txn; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.RecoverableDataAccessException; +import org.springframework.data.aerospike.BaseBlockingIntegrationTests; +import org.springframework.data.aerospike.sample.Person; +import org.springframework.data.aerospike.sample.SampleClasses; +import org.springframework.data.aerospike.util.AwaitilityUtils; +import org.springframework.data.aerospike.util.TestUtils; +import org.springframework.test.annotation.Rollback; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.data.aerospike.transaction.sync.AerospikeTransactionTestUtils.callGetTransaction; +import static org.springframework.data.aerospike.transaction.sync.AerospikeTransactionTestUtils.getTransaction; +import static org.springframework.data.aerospike.transaction.sync.AerospikeTransactionTestUtils.getTransaction2; + +@Slf4j +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class AerospikeTransactionalAnnotationTests extends BaseBlockingIntegrationTests { + + @BeforeAll + public void beforeAll() { + TestUtils.checkAssumption(serverVersionSupport.isMRTSupported(), + "Skipping transactions tests because Aerospike Server 8.0.0+ is required", log); + } + + @BeforeEach + public void beforeEach() { + deleteAll(Person.class, SampleClasses.DocumentWithPrimitiveIntId.class, + SampleClasses.DocumentWithIntegerId.class); + } + + @AfterAll + public void afterAll() { + deleteAll(Person.class, SampleClasses.DocumentWithPrimitiveIntId.class, + SampleClasses.DocumentWithIntegerId.class); + } + + @Test + @Transactional(transactionManager = "aerospikeTransactionManager") + @Rollback(value = false) + public void verifyTransactionExists_oneMethod() { + Txn tx = getTransaction(client); + assertThat(tx).isNotNull(); + } + + @Test + @Transactional(transactionManager = "aerospikeTransactionManager") + @Rollback(value = false) + public void verifyTransactionExists_chainedMethods() { + Txn tx = callGetTransaction(client); + assertThat(tx).isNotNull(); + } + + @Test + @Transactional(transactionManager = "aerospikeTransactionManager") + @Rollback(value = false) + public void verifyTransactionExists_multipleMethods() { + Txn tx1 = callGetTransaction(client); + assertThat(tx1).isNotNull(); + Txn tx2 = getTransaction2(client); + assertThat(tx2).isEqualTo(tx1); + } + + @Test + @Transactional() + @Rollback(value = false) + // only for testing purposes as performing one write in a transaction lacks sense + public void verifyTransaction_oneInsert() { + TestTransactionSynchronization testSync = new TestTransactionSynchronization(() -> { + SampleClasses.DocumentWithPrimitiveIntId result = + template.findById(300, SampleClasses.DocumentWithPrimitiveIntId.class); + assertThat(result.getId()).isEqualTo(300); + System.out.println("Verified"); + }); + // Register the action to perform after transaction is completed + testSync.register(); + + template.insert(new SampleClasses.DocumentWithPrimitiveIntId(300)); + } + + @Test + @Transactional + @Rollback(value = false) + // just for testing purposes as performing only one write in a transactions lacks sense + public void verifyTransaction_batchInsert() { + TestTransactionSynchronization testSync = new TestTransactionSynchronization(() -> { + SampleClasses.DocumentWithPrimitiveIntId result1 = + template.findById(301, SampleClasses.DocumentWithPrimitiveIntId.class); + SampleClasses.DocumentWithPrimitiveIntId result2 = + template.findById(401, SampleClasses.DocumentWithPrimitiveIntId.class); + assertThat(result1.getId()).isEqualTo(301); + assertThat(result2.getId()).isEqualTo(401); + System.out.println("Verified"); + }); + // Register the action to perform after transaction is completed + testSync.register(); + + template.insertAll(List.of(new SampleClasses.DocumentWithPrimitiveIntId(301), + new SampleClasses.DocumentWithPrimitiveIntId(401))); + } + + public void transactional_multipleInserts(Object document1, Object document2) { + template.insert(document1); + template.insert(document2); + } + + @Test + @Transactional + @Rollback(value = false) + public void verifyTransaction_multipleWrites() { + TestTransactionSynchronization testSync = new TestTransactionSynchronization(() -> { + SampleClasses.DocumentWithPrimitiveIntId result1 = + template.findById(302, SampleClasses.DocumentWithPrimitiveIntId.class); + SampleClasses.DocumentWithPrimitiveIntId result2 = + template.findById(402, SampleClasses.DocumentWithPrimitiveIntId.class); + assertThat(result1.getId()).isEqualTo(302); + assertThat(result2.getId()).isEqualTo(402); + System.out.println("Verified"); + }); + // Register the action to perform after transaction is completed + testSync.register(); + + transactional_multipleInserts(new SampleClasses.DocumentWithPrimitiveIntId(302), + new SampleClasses.DocumentWithPrimitiveIntId(402)); + } + + @Test + @Transactional(timeout = 2) // timeout after the first command within a transaction + @Rollback(value = false) + public void verifyTransaction_multipleInserts_withTimeout() { + TestTransactionSynchronization testSync = new TestTransactionSynchronization(() -> { + SampleClasses.DocumentWithPrimitiveIntId result1 = + template.findById(304, SampleClasses.DocumentWithPrimitiveIntId.class); + SampleClasses.DocumentWithPrimitiveIntId result2 = + template.findById(305, SampleClasses.DocumentWithPrimitiveIntId.class); + assertThat(result1.getId()).isEqualTo(304); + assertThat(result2.getId()).isEqualTo(305); + System.out.println("Verified"); + }); + // Register the action to perform after transaction is completed + testSync.register(); + + template.insert(new SampleClasses.DocumentWithPrimitiveIntId(304)); + AwaitilityUtils.wait(1, SECONDS); // wait less than the given timeout + template.insert(new SampleClasses.DocumentWithPrimitiveIntId(305)); + } + + @Test + @Transactional(timeout = 2) // timeout after the first command within a transaction + @Rollback(value = false) + public void verifyTransaction_multipleInserts_withTimeoutExpired() { + template.insert(new SampleClasses.DocumentWithPrimitiveIntId(305)); + AwaitilityUtils.wait(3, SECONDS); // wait more than the given timeout + assertThatThrownBy(() -> template.insert(new SampleClasses.DocumentWithPrimitiveIntId(306))) + .isInstanceOf(RecoverableDataAccessException.class) + .hasMessageContaining("MRT expired"); + } + + @Test + @Transactional + @Rollback() // rollback is set to true to simulate propagating exception that rolls back transaction + public void verifyTransaction_multipleWrites_rollback() { + TestTransactionSynchronization testSync = new TestTransactionSynchronization(() -> { + SampleClasses.DocumentWithPrimitiveIntId result = + template.findById(303, SampleClasses.DocumentWithPrimitiveIntId.class); + assertThat(result).isNull(); + System.out.println("Verified"); + }); + // Register the action to perform after transaction is completed + testSync.register(); + + assertThatThrownBy(() -> + transactional_multipleInserts(new SampleClasses.DocumentWithPrimitiveIntId(303), + new SampleClasses.DocumentWithPrimitiveIntId(303))) + .isInstanceOf(DuplicateKeyException.class) + .hasMessageContaining("Key already exists"); + } + + @Test + @Transactional(timeout = 2) + @Rollback(value = false) + public void verifyTransaction_multipleBatchInserts_withTimeout() { + TestTransactionSynchronization testSync = new TestTransactionSynchronization(() -> { + SampleClasses.DocumentWithPrimitiveIntId result1 = + template.findById(307, SampleClasses.DocumentWithPrimitiveIntId.class); + SampleClasses.DocumentWithPrimitiveIntId result2 = + template.findById(407, SampleClasses.DocumentWithPrimitiveIntId.class); + assertThat(result1.getId()).isEqualTo(307); + assertThat(result2.getId()).isEqualTo(407); + SampleClasses.DocumentWithPrimitiveIntId result3 = + template.findById(308, SampleClasses.DocumentWithPrimitiveIntId.class); + SampleClasses.DocumentWithPrimitiveIntId result4 = + template.findById(408, SampleClasses.DocumentWithPrimitiveIntId.class); + assertThat(result3.getId()).isEqualTo(308); + assertThat(result4.getId()).isEqualTo(408); + System.out.println("Verified"); + }); + // Register the action to perform after transaction is completed + testSync.register(); + + template.insertAll(List.of(new SampleClasses.DocumentWithPrimitiveIntId(307), + new SampleClasses.DocumentWithPrimitiveIntId(407))); + AwaitilityUtils.wait(1, SECONDS); // wait less than the given timeout + template.insertAll(List.of(new SampleClasses.DocumentWithPrimitiveIntId(308), + new SampleClasses.DocumentWithPrimitiveIntId(408))); + } + + @Test + @Transactional(timeout = 2) + @Rollback(value = false) + public void verifyTransaction_multipleBatchInserts_withTimeoutExpired() { + template.insertAll(List.of(new SampleClasses.DocumentWithPrimitiveIntId(309), + new SampleClasses.DocumentWithPrimitiveIntId(409))); + AwaitilityUtils.wait(3, SECONDS); // wait more than the given timeout + try { + template.insertAll(List.of(new SampleClasses.DocumentWithPrimitiveIntId(310), + new SampleClasses.DocumentWithPrimitiveIntId(410))); + } catch (AerospikeException.BatchRecordArray e) { + System.out.println("MRT expired"); + } + } + + @Test + @Transactional() + @Rollback(value = false) + // only for testing purposes as performing one write in a transaction lacks sense + public void verifyTransaction_oneDelete() { + TestTransactionSynchronization testSync = new TestTransactionSynchronization(() -> { + SampleClasses.DocumentWithPrimitiveIntId result = + template.findById(1004, SampleClasses.DocumentWithPrimitiveIntId.class); + assertThat(result.getId()).isNull(); + System.out.println("Verified"); + }); + // Register the action to perform after transaction is completed + testSync.register(); + + SampleClasses.DocumentWithPrimitiveIntId doc = new SampleClasses.DocumentWithPrimitiveIntId(1004); + template.insert(doc); + template.delete(doc); + } +} diff --git a/src/test/java/org/springframework/data/aerospike/transaction/sync/TestTransactionSynchronization.java b/src/test/java/org/springframework/data/aerospike/transaction/sync/TestTransactionSynchronization.java new file mode 100644 index 00000000..47a33887 --- /dev/null +++ b/src/test/java/org/springframework/data/aerospike/transaction/sync/TestTransactionSynchronization.java @@ -0,0 +1,27 @@ +package org.springframework.data.aerospike.transaction.sync; + +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +public class TestTransactionSynchronization implements TransactionSynchronization { + + private final Runnable afterCompletion; + + public TestTransactionSynchronization(Runnable afterCompletion) { + this.afterCompletion = afterCompletion; + } + + @Override + public void afterCompletion(int status) { + // after transaction completion (commit/rollback) + if (afterCompletion != null) { + afterCompletion.run(); + } + } + + public void register() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(this); + } + } +} diff --git a/src/test/java/org/springframework/data/aerospike/util/TestUtils.java b/src/test/java/org/springframework/data/aerospike/util/TestUtils.java index 52d6f0a3..80dd64df 100644 --- a/src/test/java/org/springframework/data/aerospike/util/TestUtils.java +++ b/src/test/java/org/springframework/data/aerospike/util/TestUtils.java @@ -1,5 +1,7 @@ package org.springframework.data.aerospike.util; +import org.junit.jupiter.api.Assumptions; +import org.slf4j.Logger; import org.springframework.data.aerospike.repository.AerospikeRepository; import org.springframework.data.aerospike.repository.ReactiveAerospikeRepository; import org.springframework.data.aerospike.sample.IndexedPerson; @@ -23,4 +25,11 @@ public static void setFriendsToNull(ReactiveAerospikeRepository