Fix block() issues with reactive transactions. (#1537)
* Fix block() issues with reactive transactions. Closes #1530. * Removing the form of checkForTransactionInThreadLocalStorage (#1538) that does not use TransactionMarkerOwner. As this bypasses a critical check on whether we are inside a blocking transaction. Two new tests in SDKTransactionsSaveIntegrationTests (reactiveSaveInBlockingTransaction and blockingSaveInBlockingTransaction) will fail without this, as the .save() does not realise it's in a transaction. Adding new tests, for .save() and for performing transactions in reactor blocking threads. Co-authored-by: Graham Pople <grahampople@gmail.com>
This commit is contained in:
@@ -59,7 +59,7 @@ public class ReactiveCouchbaseTemplate implements ReactiveCouchbaseOperations, A
|
||||
}
|
||||
|
||||
public ReactiveCouchbaseTemplate(final CouchbaseClientFactory clientFactory, final CouchbaseConverter converter,
|
||||
final TranslationService translationService) {
|
||||
final TranslationService translationService) {
|
||||
this(clientFactory, converter, translationService, null);
|
||||
}
|
||||
|
||||
@@ -75,35 +75,48 @@ public class ReactiveCouchbaseTemplate implements ReactiveCouchbaseOperations, A
|
||||
@Override
|
||||
public <T> Mono<T> save(T entity, String... scopeAndCollection) {
|
||||
Assert.notNull(entity, "Entity must not be null!");
|
||||
|
||||
String scope = scopeAndCollection.length > 0 ? scopeAndCollection[0] : null;
|
||||
String collection = scopeAndCollection.length > 1 ? scopeAndCollection[1] : null;
|
||||
Mono<T> result;
|
||||
final CouchbasePersistentEntity<?> mapperEntity = getConverter().getMappingContext()
|
||||
.getPersistentEntity(entity.getClass());
|
||||
final CouchbasePersistentProperty versionProperty = mapperEntity.getVersionProperty();
|
||||
final boolean versionPresent = versionProperty != null;
|
||||
final Long version = versionProperty == null || versionProperty.getField() == null ? null
|
||||
: (Long) ReflectionUtils.getField(versionProperty.getField(), entity);
|
||||
final boolean existingDocument = version != null && version > 0;
|
||||
return Mono.defer(() -> {
|
||||
final CouchbasePersistentEntity<?> mapperEntity = getConverter().getMappingContext()
|
||||
.getPersistentEntity(entity.getClass());
|
||||
final CouchbasePersistentProperty versionProperty = mapperEntity.getVersionProperty();
|
||||
final boolean versionPresent = versionProperty != null;
|
||||
final Long version = versionProperty == null || versionProperty.getField() == null ? null
|
||||
: (Long) ReflectionUtils.getField(versionProperty.getField(),
|
||||
entity);
|
||||
final boolean existingDocument = version != null && version > 0;
|
||||
|
||||
Class clazz = entity.getClass();
|
||||
Class clazz = entity.getClass();
|
||||
|
||||
if (!versionPresent) { // the entity doesn't have a version property
|
||||
// No version field - no cas
|
||||
// If in a transaction, insert is the only thing that will work
|
||||
if (TransactionalSupport.checkForTransactionInThreadLocalStorage().block().isPresent()) {
|
||||
result = (Mono<T>) insertById(clazz).inScope(scope).inCollection(collection).one(entity);
|
||||
} else { // if not in a tx, then upsert will work
|
||||
result = (Mono<T>) upsertById(clazz).inScope(scope).inCollection(collection).one(entity);
|
||||
if (!versionPresent) { // the entity doesn't have a version property
|
||||
// No version field - no cas
|
||||
// If in a transaction, insert is the only thing that will work
|
||||
return TransactionalSupport.checkForTransactionInThreadLocalStorage()
|
||||
.flatMap(ctx -> {
|
||||
if (ctx.isPresent()) {
|
||||
return (Mono<T>) insertById(clazz).inScope(scope)
|
||||
.inCollection(collection)
|
||||
.one(entity);
|
||||
} else { // if not in a tx, then upsert will work
|
||||
return (Mono<T>) upsertById(clazz).inScope(scope)
|
||||
.inCollection(collection)
|
||||
.one(entity);
|
||||
}
|
||||
});
|
||||
} else if (existingDocument) { // there is a version property, and it is non-zero
|
||||
// Updating existing document with cas
|
||||
return (Mono<T>) replaceById(clazz).inScope(scope)
|
||||
.inCollection(collection)
|
||||
.one(entity);
|
||||
} else { // there is a version property, but it's zero or not set.
|
||||
// Creating new document
|
||||
return (Mono<T>) insertById(clazz).inScope(scope)
|
||||
.inCollection(collection)
|
||||
.one(entity);
|
||||
}
|
||||
} else if (existingDocument) { // there is a version property, and it is non-zero
|
||||
// Updating existing document with cas
|
||||
result = (Mono<T>) replaceById(clazz).inScope(scope).inCollection(collection).one(entity);
|
||||
} else { // there is a version property, but it's zero or not set.
|
||||
// Creating new document
|
||||
result = (Mono<T>) insertById(clazz).inScope(scope).inCollection(collection).one(entity);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public <T> Mono<Long> count(Query query, Class<T> domainType) {
|
||||
|
||||
@@ -52,18 +52,10 @@ public class TransactionalSupport {
|
||||
});
|
||||
}
|
||||
|
||||
public static Optional<CouchbaseResourceHolder> checkForTransactionInThreadLocalStorage(ContextView ctx) {
|
||||
return Optional.ofNullable(ctx.hasKey(TransactionMarker.class) ? new CouchbaseResourceHolder(ctx.get(TransactionMarker.class).context()) : null);
|
||||
}
|
||||
|
||||
//public static Optional<CouchbaseResourceHolder> blockingCheckForTransactionInThreadLocalStorage() {
|
||||
// return TransactionMarkerOwner.marker;
|
||||
// }
|
||||
|
||||
public static Mono<Void> verifyNotInTransaction(String methodName) {
|
||||
return checkForTransactionInThreadLocalStorage().flatMap(s -> {
|
||||
if (s.isPresent()) {
|
||||
return Mono.error(new IllegalArgumentException(methodName + "can not be used inside a transaction"));
|
||||
return Mono.error(new IllegalArgumentException(methodName + " can not be used inside a transaction"));
|
||||
} else {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ public class CouchbaseList implements CouchbaseStorable {
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException(
|
||||
"Attribute of type " + clazz.getCanonicalName() + "can not be stored and must be converted.");
|
||||
"Attribute of type " + clazz.getCanonicalName() + " can not be stored and must be converted.");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,7 +44,6 @@ import com.couchbase.client.java.transactions.TransactionResult;
|
||||
import com.couchbase.client.java.transactions.config.TransactionOptions;
|
||||
import com.couchbase.client.java.transactions.error.TransactionCommitAmbiguousException;
|
||||
import com.couchbase.client.java.transactions.error.TransactionFailedException;
|
||||
import reactor.util.context.ContextView;
|
||||
|
||||
/**
|
||||
* The Couchbase transaction manager, providing support for @Transactional methods.
|
||||
@@ -74,7 +73,9 @@ public class CouchbaseCallbackTransactionManager implements CallbackPreferringPl
|
||||
|
||||
@Override
|
||||
public <T> T execute(TransactionDefinition definition, TransactionCallback<T> callback) throws TransactionException {
|
||||
boolean createNewTransaction = handlePropagation(definition, null);
|
||||
boolean isInExistingTransaction = TransactionalSupport.checkForTransactionInThreadLocalStorage().block()
|
||||
.isPresent();
|
||||
boolean createNewTransaction = handlePropagation(definition, isInExistingTransaction);
|
||||
|
||||
setOptionsFromDefinition(definition);
|
||||
|
||||
@@ -88,8 +89,9 @@ public class CouchbaseCallbackTransactionManager implements CallbackPreferringPl
|
||||
@Stability.Internal
|
||||
<T> Flux<T> executeReactive(TransactionDefinition definition,
|
||||
org.springframework.transaction.reactive.TransactionCallback<T> callback) {
|
||||
return Flux.deferContextual((ctx) -> {
|
||||
boolean createNewTransaction = handlePropagation(definition, ctx);
|
||||
return TransactionalSupport.checkForTransactionInThreadLocalStorage().flatMapMany(isInTransaction -> {
|
||||
boolean isInExistingTransaction = isInTransaction.isPresent();
|
||||
boolean createNewTransaction = handlePropagation(definition, isInExistingTransaction);
|
||||
|
||||
setOptionsFromDefinition(definition);
|
||||
|
||||
@@ -188,9 +190,7 @@ public class CouchbaseCallbackTransactionManager implements CallbackPreferringPl
|
||||
}
|
||||
|
||||
// Propagation defines what happens when a @Transactional method is called from another @Transactional method.
|
||||
private boolean handlePropagation(TransactionDefinition definition, ContextView ctx) {
|
||||
boolean isExistingTransaction = ctx != null ? TransactionalSupport.checkForTransactionInThreadLocalStorage(ctx).isPresent() :
|
||||
TransactionalSupport.checkForTransactionInThreadLocalStorage().block().isPresent();
|
||||
private Boolean handlePropagation(TransactionDefinition definition, boolean isExistingTransaction) {
|
||||
|
||||
LOGGER.trace("Deciding propagation behaviour from {} and {}", definition.getPropagationBehavior(),
|
||||
isExistingTransaction);
|
||||
|
||||
@@ -19,6 +19,7 @@ package org.springframework.data.couchbase.transactions;
|
||||
import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.couchbase.domain.PersonWithoutVersion;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
@@ -49,9 +50,9 @@ import com.couchbase.client.java.Cluster;
|
||||
|
||||
/**
|
||||
* todo gp: these tests are using the `.as(transactionalOperator::transactional)` method which is for the chopping
|
||||
* block, so presumably these tests are too
|
||||
* todo mr: I'm not sure how as(transactionalOperator::transactional) is different than
|
||||
* todo mr: transactionOperator.transaction(...)in CouchbaseTransactionalOperatorTemplateIntegrationTests ?
|
||||
* block, so presumably these tests are too todo mr: I'm not sure how as(transactionalOperator::transactional) is
|
||||
* different than todo mr: transactionOperator.transaction(...)in CouchbaseTransactionalOperatorTemplateIntegrationTests
|
||||
* ?
|
||||
*
|
||||
* @author Michael Reiche
|
||||
*/
|
||||
@@ -72,6 +73,7 @@ public class CouchbasePersonTransactionReactiveIntegrationTests extends JavaInte
|
||||
String sName = "_default";
|
||||
String cName = "_default";
|
||||
Person WalterWhite;
|
||||
PersonWithoutVersion BobbyBlackWithoutVersion;
|
||||
|
||||
@BeforeAll
|
||||
public static void beforeAll() {
|
||||
@@ -86,6 +88,7 @@ public class CouchbasePersonTransactionReactiveIntegrationTests extends JavaInte
|
||||
@BeforeEach
|
||||
public void beforeEachTest() {
|
||||
WalterWhite = new Person("Walter", "White");
|
||||
BobbyBlackWithoutVersion = new PersonWithoutVersion("Bobby", "Black");
|
||||
TransactionTestUtil.assertNotInTransaction();
|
||||
List<RemoveResult> pr = operations.removeByQuery(Person.class).withConsistency(REQUEST_PLUS).all().collectList()
|
||||
.block();
|
||||
@@ -140,6 +143,12 @@ public class CouchbasePersonTransactionReactiveIntegrationTests extends JavaInte
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void commitShouldPersistTxEntriesOfTxAnnotatedMethodNoVersion() {
|
||||
personService.declarativeSavePersonWithoutVersion(BobbyBlackWithoutVersion).as(StepVerifier::create) //
|
||||
.expectError(UnsupportedOperationException.class); //
|
||||
}
|
||||
|
||||
@Test
|
||||
public void commitShouldPersistTxEntriesAcrossCollections() {
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.springframework.data.couchbase.core.TransactionalSupport;
|
||||
import org.springframework.data.couchbase.domain.PersonWithoutVersion;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
@@ -109,6 +110,11 @@ class PersonServiceReactive {
|
||||
return personOperationsRx.save(person);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<PersonWithoutVersion> declarativeSavePersonWithoutVersion(PersonWithoutVersion person) {
|
||||
return personOperationsRx.save(person);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Mono<Person> declarativeSavePersonErrors(Person person) {
|
||||
return personOperationsRx.insertById(Person.class).one(person)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2022 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
|
||||
*
|
||||
* https://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.couchbase.transactions.sdk;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
import static org.springframework.data.couchbase.transactions.util.TransactionTestUtil.assertNotInTransaction;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.couchbase.CouchbaseClientFactory;
|
||||
import org.springframework.data.couchbase.core.CouchbaseTemplate;
|
||||
import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate;
|
||||
import org.springframework.data.couchbase.domain.Person;
|
||||
import org.springframework.data.couchbase.transactions.TransactionsConfig;
|
||||
import org.springframework.data.couchbase.util.Capabilities;
|
||||
import org.springframework.data.couchbase.util.ClusterType;
|
||||
import org.springframework.data.couchbase.util.IgnoreWhen;
|
||||
import org.springframework.data.couchbase.util.JavaIntegrationTests;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
/**
|
||||
* Added for issue 1527: Tests running regular SDK transactions (blocking and reactive) on a reactor non-blocking
|
||||
* thread.
|
||||
*
|
||||
* @author Graham Pople
|
||||
*/
|
||||
@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED)
|
||||
@SpringJUnitConfig(TransactionsConfig.class)
|
||||
public class SDKTransactionsNonBlockingThreadIntegrationTests extends JavaIntegrationTests {
|
||||
@Autowired private CouchbaseClientFactory couchbaseClientFactory;
|
||||
@Autowired private CouchbaseTemplate ops;
|
||||
@Autowired private ReactiveCouchbaseTemplate reactiveOps;
|
||||
|
||||
@BeforeEach
|
||||
public void beforeEachTest() {
|
||||
assertNotInTransaction();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void afterEachTest() {
|
||||
assertNotInTransaction();
|
||||
}
|
||||
|
||||
@DisplayName("Trying to run a blocking transaction (or anything blocking) on a non-blocking thread, will not work")
|
||||
@Test
|
||||
public void blockingTransactionOnNonBlockingThread() {
|
||||
try {
|
||||
Mono.just(1).publishOn(Schedulers.parallel()).flatMap(ignore -> {
|
||||
assertTrue(Schedulers.isInNonBlockingThread());
|
||||
assertTrue(Thread.currentThread().getName().contains("parallel"));
|
||||
|
||||
couchbaseClientFactory.getCluster().transactions().run(ctx -> {
|
||||
ops.insertById(Person.class).one(new Person("Walter", "White"));
|
||||
});
|
||||
return Mono.empty();
|
||||
}).block();
|
||||
fail();
|
||||
} catch (IllegalStateException ignored) {}
|
||||
}
|
||||
|
||||
@DisplayName("Trying to run a reactive transaction on a non-blocking thread should work")
|
||||
@Test
|
||||
public void reactiveTransactionOnNonBlockingThread() {
|
||||
Mono.just(1).publishOn(Schedulers.parallel()).flatMap(ignore -> {
|
||||
return couchbaseClientFactory.getCluster().reactive().transactions().run(ctx -> {
|
||||
return reactiveOps.insertById(Person.class).one(new Person("Walter", "White"));
|
||||
});
|
||||
}).block();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright 2022 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
|
||||
*
|
||||
* https://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.couchbase.transactions.sdk;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
import static org.springframework.data.couchbase.transactions.util.TransactionTestUtil.assertNotInTransaction;
|
||||
|
||||
import org.springframework.data.couchbase.domain.PersonWithoutVersion;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.couchbase.CouchbaseClientFactory;
|
||||
import org.springframework.data.couchbase.core.CouchbaseTemplate;
|
||||
import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate;
|
||||
import org.springframework.data.couchbase.domain.Person;
|
||||
import org.springframework.data.couchbase.transactions.TransactionsConfig;
|
||||
import org.springframework.data.couchbase.util.Capabilities;
|
||||
import org.springframework.data.couchbase.util.ClusterType;
|
||||
import org.springframework.data.couchbase.util.IgnoreWhen;
|
||||
import org.springframework.data.couchbase.util.JavaIntegrationTests;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
/**
|
||||
* Added for issue 1527: Tests the .save() command.
|
||||
*
|
||||
* @author Graham Pople
|
||||
*/
|
||||
@IgnoreWhen(missesCapabilities = Capabilities.QUERY, clusterTypes = ClusterType.MOCKED)
|
||||
@SpringJUnitConfig(TransactionsConfig.class)
|
||||
public class SDKTransactionsSaveIntegrationTests extends JavaIntegrationTests {
|
||||
@Autowired private CouchbaseClientFactory couchbaseClientFactory;
|
||||
@Autowired private CouchbaseTemplate ops;
|
||||
@Autowired private ReactiveCouchbaseTemplate reactiveOps;
|
||||
|
||||
@BeforeEach
|
||||
public void beforeEachTest() {
|
||||
assertNotInTransaction();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void afterEachTest() {
|
||||
assertNotInTransaction();
|
||||
}
|
||||
|
||||
@DisplayName("ReactiveCouchbaseTemplate.save() called inside a reactive SDK transaction should work")
|
||||
@Test
|
||||
public void reactiveSaveInReactiveTransaction() {
|
||||
couchbaseClientFactory.getCluster().reactive().transactions().run(ctx -> {
|
||||
PersonWithoutVersion p = new PersonWithoutVersion("Walter", "White");
|
||||
return reactiveOps.save(p);
|
||||
}).block();
|
||||
}
|
||||
|
||||
@DisplayName("ReactiveCouchbaseTemplate.save().block() called inside a non-reactive SDK transaction should work")
|
||||
@Test
|
||||
public void reactiveSaveInBlockingTransaction() {
|
||||
couchbaseClientFactory.getCluster().transactions().run(ctx -> {
|
||||
PersonWithoutVersion p = new PersonWithoutVersion("Walter", "White");
|
||||
reactiveOps.save(p).block();
|
||||
});
|
||||
}
|
||||
|
||||
@DisplayName("ReactiveCouchbaseTemplate.save() called inside a reactive SDK transaction should work")
|
||||
@Test
|
||||
public void blockingSaveInReactiveTransaction() {
|
||||
couchbaseClientFactory.getCluster().reactive().transactions().run(ctx -> {
|
||||
PersonWithoutVersion p = new PersonWithoutVersion("Walter", "White");
|
||||
ops.save(p);
|
||||
return Mono.empty();
|
||||
}).block();
|
||||
}
|
||||
|
||||
@DisplayName("ReactiveCouchbaseTemplate.save().block() called inside a non-reactive SDK transaction should work")
|
||||
@Test
|
||||
public void blockingSaveInBlockingTransaction() {
|
||||
couchbaseClientFactory.getCluster().transactions().run(ctx -> {
|
||||
PersonWithoutVersion p = new PersonWithoutVersion("Walter", "White");
|
||||
ops.save(p);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user