From f296a499e5e5abae5b724c1998b1c7566c5fe743 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Fri, 11 May 2018 10:38:50 +0200 Subject: [PATCH] DATAMONGO-1970 - Add support for MongoDB 4.0 transactions (reactive). We now support Mongo Transactions through the reactive Template API. However, there's no reactive repository transaction support yet. Mono result = template.inTransaction() .execute(action -> action.remove(query(where("id").is("step-1")), Step.class)); Original pull request: #560. --- pom.xml | 4 +- .../data/mongodb/MongoTransactionManager.java | 2 +- .../mongodb/ReactiveMongoDatabaseFactory.java | 2 +- .../mongodb/core/ReactiveMongoContext.java | 56 ++++++ .../mongodb/core/ReactiveMongoOperations.java | 77 +++++--- .../mongodb/core/ReactiveMongoTemplate.java | 37 +++- .../mongodb/core/ReactiveSessionCallback.java | 6 +- .../mongodb/core/ReactiveSessionScoped.java | 59 +++++- .../SimpleReactiveMongoDatabaseFactory.java | 12 +- .../core/ReactiveClientSessionTests.java | 37 ++-- ...ReactiveMongoTemplateTransactionTests.java | 174 ++++++++++++++++++ ...iveSessionBoundMongoTemplateUnitTests.java | 2 +- ...ReactiveMongoDatabaseFactoryUnitTests.java | 2 +- .../PersonRepositoryTransactionalTests.java | 36 ++-- .../mongodb/test/util/MongoTestUtils.java | 46 ++++- .../client-session-transactions.adoc | 75 ++++++++ 16 files changed, 551 insertions(+), 76 deletions(-) create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoContext.java create mode 100644 spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateTransactionTests.java diff --git a/pom.xml b/pom.xml index ff6cdb7d9..7227da358 100644 --- a/pom.xml +++ b/pom.xml @@ -28,8 +28,8 @@ multi spring-data-mongodb 2.1.0.BUILD-SNAPSHOT - 3.8.0-beta1 - 1.8.0 + 3.8.0-beta2 + 1.9.0-beta1 1.19 diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoTransactionManager.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoTransactionManager.java index fc403adae..5ec4e10e7 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoTransactionManager.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoTransactionManager.java @@ -360,7 +360,7 @@ public class MongoTransactionManager extends AbstractPlatformTransactionManager debugString += String.format("causallyConsistent = %s, ", session.isCausallyConsistent()); debugString += String.format("txActive = %s, ", session.hasActiveTransaction()); debugString += String.format("txNumber = %d, ", session.getServerSession().getTransactionNumber()); - debugString += String.format("statementId = %d, ", session.getServerSession().getStatementId()); + debugString += String.format("closed = %d, ", session.getServerSession().isClosed()); debugString += String.format("clusterTime = %s", session.getClusterTime()); } else { debugString += "id = n/a"; diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/ReactiveMongoDatabaseFactory.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/ReactiveMongoDatabaseFactory.java index 1b6dbf73b..69d606c84 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/ReactiveMongoDatabaseFactory.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/ReactiveMongoDatabaseFactory.java @@ -24,7 +24,7 @@ import org.springframework.dao.support.PersistenceExceptionTranslator; import org.springframework.data.mongodb.core.MongoExceptionTranslator; import com.mongodb.ClientSessionOptions; -import com.mongodb.session.ClientSession; +import com.mongodb.reactivestreams.client.ClientSession; import com.mongodb.reactivestreams.client.MongoDatabase; /** diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoContext.java new file mode 100644 index 000000000..9120af61e --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoContext.java @@ -0,0 +1,56 @@ +/* + * Copyright 2018 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.mongodb.core; + +import reactor.core.publisher.Mono; +import reactor.util.context.Context; + +import com.mongodb.reactivestreams.client.ClientSession; + +/** + * {@link ReactiveMongoContext} utilizes and enriches the Reactor {@link Context} with information protentially required + * for e.g. {@link ClientSession} handling and transactions. + * + * @author Christoph Strobl + * @since 2.1 + * @see Mono#subscriberContext() + * @see Context + */ +public class ReactiveMongoContext { + + private static final Class SESSION_KEY = ClientSession.class; + + /** + * Gets the {@code Mono} from Reactor {@link reactor.util.context.Context} + * + * @return the {@link Mono} emitting the client session. + */ + static Mono getSession() { + + return Mono.subscriberContext().filter(ctx -> ctx.hasKey(SESSION_KEY)) + .flatMap(ctx -> ctx.> get(SESSION_KEY)); + } + + /** + * Sets the {@link ClientSession} into the Reactor {@link reactor.util.context.Context} + * + * @return a new {@link Context}. + * @see Context#put(Object, Object) + */ + static Context setSession(Context context, Mono session) { + return context.put(SESSION_KEY, session); + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoOperations.java index 1dca1b0d8..b75d93803 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoOperations.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoOperations.java @@ -46,8 +46,8 @@ import com.mongodb.ClientSessionOptions; import com.mongodb.ReadPreference; import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; +import com.mongodb.reactivestreams.client.ClientSession; import com.mongodb.reactivestreams.client.MongoCollection; -import com.mongodb.session.ClientSession; /** * Interface that specifies a basic set of MongoDB operations executed in a reactive way. @@ -156,7 +156,7 @@ public interface ReactiveMongoOperations extends ReactiveFluentMongoOperations { * {@link ClientSession} when done. * * @param sessionProvider must not be {@literal null}. - * @return new instance of {@link SessionScoped}. Never {@literal null}. + * @return new instance of {@link ReactiveSessionScoped}. Never {@literal null}. * @since 2.1 */ default ReactiveSessionScoped withSession(Supplier sessionProvider) { @@ -169,52 +169,79 @@ public interface ReactiveMongoOperations extends ReactiveFluentMongoOperations { /** * Obtain a {@link ClientSession session} bound instance of {@link SessionScoped} binding a new {@link ClientSession} * with given {@literal sessionOptions} to each and every command issued against MongoDB. + *

+ * Note: It is up to the caller to manage the {@link ClientSession} lifecycle. Use + * {@link ReactiveSessionScoped#execute(ReactiveSessionCallback, Consumer)} to provide a hook for processing the + * {@link ClientSession} when done. * * @param sessionOptions must not be {@literal null}. - * @return new instance of {@link SessionScoped}. Never {@literal null}. + * @return new instance of {@link ReactiveSessionScoped}. Never {@literal null}. * @since 2.1 */ ReactiveSessionScoped withSession(ClientSessionOptions sessionOptions); /** - * Obtain a {@link ClientSession session} bound instance of {@link SessionScoped} binding the {@link ClientSession} - * provided by the given {@link Supplier} to each and every command issued against MongoDB. + * Obtain a {@link ClientSession session} bound instance of {@link ReactiveSessionScoped} binding the + * {@link ClientSession} provided by the given {@link Publisher} to each and every command issued against MongoDB. *

- * Note: It is up to the caller to manage the {@link ClientSession} lifecycle. Use the - * {@literal onComplete} hook to potentially close the {@link ClientSession}. + * Note: It is up to the caller to manage the {@link ClientSession} lifecycle. Use + * {@link ReactiveSessionScoped#execute(ReactiveSessionCallback, Consumer)} to provide a hook for processing the + * {@link ClientSession} when done. * * @param sessionProvider must not be {@literal null}. - * @return new instance of {@link SessionScoped}. Never {@literal null}. + * @return new instance of {@link ReactiveSessionScoped}. Never {@literal null}. * @since 2.1 */ - default ReactiveSessionScoped withSession(Publisher sessionProvider) { - - return new ReactiveSessionScoped() { - - private final Mono cachedSession = Mono.from(sessionProvider).cache(); - - @Override - public Flux execute(ReactiveSessionCallback action, Consumer doFinally) { - - return cachedSession.flatMapMany(session -> { - return Flux.from(action.doInSession(ReactiveMongoOperations.this.withSession(session))) - .doFinally((signalType) -> doFinally.accept(session)); - }); - } - }; - } + ReactiveSessionScoped withSession(Publisher sessionProvider); /** * Obtain a {@link ClientSession} bound instance of {@link ReactiveMongoOperations}. *

* Note: It is up to the caller to manage the {@link ClientSession} lifecycle. - * + * * @param session must not be {@literal null}. * @return {@link ClientSession} bound instance of {@link ReactiveMongoOperations}. * @since 2.1 */ ReactiveMongoOperations withSession(ClientSession session); + /** + * Initiate a new {@link ClientSession} and obtain a {@link ClientSession session} bound instance of + * {@link ReactiveSessionScoped}. Starts the transaction and adds the {@link ClientSession} to each and every command + * issued against MongoDB. + * + * @return new instance of {@link ReactiveSessionScoped}. Never {@literal null}. + */ + ReactiveSessionScoped inTransaction(); + + /** + * Obtain a {@link ClientSession session} bound instance of {@link ReactiveSessionScoped}, start the transaction and + * bind the {@link ClientSession} provided by the given {@link Publisher} to each and every command issued against + * MongoDB. + * + * @param sessionProvider must not be {@literal null}. + * @return new instance of {@link ReactiveSessionScoped}. Never {@literal null}. + * @since 2.1 + */ + default ReactiveSessionScoped inTransaction(Publisher sessionProvider) { + + return new ReactiveSessionScoped() { + + @Override + public Flux flatMap(ReactiveSessionCallback action, Consumer doFinally) { + + return ReactiveMongoOperations.this.withSession(Mono.from(sessionProvider).flatMap(session -> { + + if (!session.hasActiveTransaction()) { + session.startTransaction(); + } + + return Mono.just(session); + })).execute(action, doFinally); + } + }; + } + /** * Create an uncapped collection with a name based on the provided entity class. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java index b974d2440..7f21da2fa 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java @@ -136,6 +136,7 @@ import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; import com.mongodb.reactivestreams.client.AggregatePublisher; import com.mongodb.reactivestreams.client.ChangeStreamPublisher; +import com.mongodb.reactivestreams.client.ClientSession; import com.mongodb.reactivestreams.client.DistinctPublisher; import com.mongodb.reactivestreams.client.FindPublisher; import com.mongodb.reactivestreams.client.MapReducePublisher; @@ -143,7 +144,6 @@ import com.mongodb.reactivestreams.client.MongoClient; import com.mongodb.reactivestreams.client.MongoCollection; import com.mongodb.reactivestreams.client.MongoDatabase; import com.mongodb.reactivestreams.client.Success; -import com.mongodb.session.ClientSession; import com.mongodb.util.JSONParseException; /** @@ -476,17 +476,46 @@ public class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati return new ReactiveSessionScoped() { @Override - public Flux execute(ReactiveSessionCallback action, Consumer doFinally) { + public Flux flatMap(ReactiveSessionCallback action, Consumer doFinally) { return cachedSession.flatMapMany(session -> { + return Flux - .from(action.doInSession(new ReactiveSessionBoundMongoTemplate(session, ReactiveMongoTemplate.this))) - .doFinally((signalType) -> doFinally.accept(session)); + + .from(action.doInSession(new ReactiveSessionBoundMongoTemplate(session, ReactiveMongoTemplate.this))) // + .subscriberContext(ctx -> ReactiveMongoContext.setSession(ctx, cachedSession)) // + .materialize() // + .flatMap(signal -> { + + if (session.hasActiveTransaction()) { + if (signal.isOnComplete()) { + return Mono.from(session.commitTransaction()).thenReturn(signal); + } + if (signal.isOnError()) { + return Mono.from(session.abortTransaction()).thenReturn(signal); + } + } + return Mono.just(signal); + }) // + . dematerialize() // + .doFinally(signalType -> { + doFinally.accept(session); + }); }); } }; } + /* + * (non-Javadoc) + * @see org.springframework.data.mongodb.core.ReactiveMongoOperations#inTransaction() + */ + @Override + public ReactiveSessionScoped inTransaction() { + return inTransaction( + mongoDatabaseFactory.getSession(ClientSessionOptions.builder().causallyConsistent(true).build())); + } + /* * (non-Javadoc) * @see org.springframework.data.mongodb.core.ReactiveMongoOperations#withSession(com.mongodb.session.ClientSession) diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveSessionCallback.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveSessionCallback.java index a6f46b033..253ae13cd 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveSessionCallback.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveSessionCallback.java @@ -19,12 +19,12 @@ import org.reactivestreams.Publisher; import org.springframework.data.mongodb.core.query.Query; /** - * Callback interface for executing operations within a {@link com.mongodb.session.ClientSession} using reactive - * infrastructure. + * Callback interface for executing operations within a {@link com.mongodb.reactivestreams.client.ClientSession} using + * reactive infrastructure. * * @author Christoph Strobl * @since 2.1 - * @see com.mongodb.session.ClientSession + * @see com.mongodb.reactivestreams.client.ClientSession */ @FunctionalInterface public interface ReactiveSessionCallback { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveSessionScoped.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveSessionScoped.java index 047dbe6ef..f25be4ddc 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveSessionScoped.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveSessionScoped.java @@ -16,10 +16,13 @@ package org.springframework.data.mongodb.core; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import java.util.function.Consumer; -import com.mongodb.session.ClientSession; +import org.reactivestreams.Publisher; + +import com.mongodb.reactivestreams.client.ClientSession; /** * Gateway interface to execute {@link ClientSession} bound operations against MongoDB via a @@ -58,5 +61,57 @@ public interface ReactiveSessionScoped { * @param return type. * @return a result object returned by the action. Can be {@literal null}. */ - Flux execute(ReactiveSessionCallback action, Consumer doFinally); + default Flux execute(ReactiveSessionCallback action, Consumer doFinally) { + return Flux.from(flatMap(action, doFinally)); + } + + /** + * Executes the given {@link ReactiveSessionCallback} within the {@link com.mongodb.session.ClientSession} returning a + * plain {@link Publisher}. For more convenience use {@link #execute(ReactiveSessionCallback, Consumer)} or + * {@link #executeSingle(ReactiveSessionCallback, Consumer)} to get the Reactor types. + *

+ * It is up to the caller to make sure the {@link com.mongodb.session.ClientSession} is {@link ClientSession#close() + * closed} when done. + * + * @param action callback object that specifies the MongoDB action the callback action. Must not be {@literal null}. + * @param doFinally callback object that accepts {@link ClientSession} after invoking {@link ReactiveSessionCallback}. + * This {@link Consumer} is guaranteed to be notified in any case (successful and exceptional outcome of + * {@link ReactiveSessionCallback}). + * @param return type. + * @return a result object returned by the action. Can be {@literal null}. + */ + Publisher flatMap(ReactiveSessionCallback action, Consumer doFinally); + + /** + * Executes the given {@link ReactiveSessionCallback} within the {@link com.mongodb.session.ClientSession} returning + * single result emitted via {@link Mono}. + *

+ * It is up to the caller to make sure the {@link com.mongodb.session.ClientSession} is {@link ClientSession#close() + * closed} when done. + * + * @param action callback object that specifies the MongoDB action the callback action. Must not be {@literal null}. + * @param return type. + * @return a result object returned by the action. Can be {@literal null}. + */ + default Mono executeSingle(ReactiveSessionCallback action) { + return executeSingle(action, (session) -> {}); + } + + /** + * Executes the given {@link ReactiveSessionCallback} within the {@link com.mongodb.session.ClientSession} returning + * single result emitted via {@link Mono}. + *

+ * It is up to the caller to make sure the {@link com.mongodb.session.ClientSession} is {@link ClientSession#close() + * closed} when done. + * + * @param action callback object that specifies the MongoDB action the callback action. Must not be {@literal null}. + * @param doFinally callback object that accepts {@link ClientSession} after invoking {@link ReactiveSessionCallback}. + * This {@link Consumer} is guaranteed to be notified in any case (successful and exceptional outcome of + * {@link ReactiveSessionCallback}). + * @param return type. + * @return a result object returned by the action. Can be {@literal null}. + */ + default Mono executeSingle(ReactiveSessionCallback action, Consumer doFinally) { + return Mono.from(flatMap(action, doFinally)); + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SimpleReactiveMongoDatabaseFactory.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SimpleReactiveMongoDatabaseFactory.java index 726fb4e9b..71d73a442 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SimpleReactiveMongoDatabaseFactory.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/SimpleReactiveMongoDatabaseFactory.java @@ -30,11 +30,11 @@ import org.springframework.util.Assert; import com.mongodb.ClientSessionOptions; import com.mongodb.ConnectionString; import com.mongodb.WriteConcern; +import com.mongodb.reactivestreams.client.ClientSession; import com.mongodb.reactivestreams.client.MongoClient; import com.mongodb.reactivestreams.client.MongoClients; import com.mongodb.reactivestreams.client.MongoCollection; import com.mongodb.reactivestreams.client.MongoDatabase; -import com.mongodb.session.ClientSession; /** * Factory to create {@link MongoDatabase} instances from a {@link MongoClient} instance. @@ -215,23 +215,23 @@ public class SimpleReactiveMongoDatabaseFactory implements DisposableBean, React return createProxyInstance(session, database, MongoDatabase.class); } - private MongoDatabase proxyDatabase(ClientSession session, MongoDatabase database) { + private MongoDatabase proxyDatabase(com.mongodb.session.ClientSession session, MongoDatabase database) { return createProxyInstance(session, database, MongoDatabase.class); } - private MongoCollection proxyCollection(ClientSession session, MongoCollection collection) { + private MongoCollection proxyCollection(com.mongodb.session.ClientSession session, MongoCollection collection) { return createProxyInstance(session, collection, MongoCollection.class); } - private T createProxyInstance(ClientSession session, T target, Class targetType) { + private T createProxyInstance(com.mongodb.session.ClientSession session, T target, Class targetType) { ProxyFactory factory = new ProxyFactory(); factory.setTarget(target); factory.setInterfaces(targetType); factory.setOpaque(true); - factory.addAdvice(new SessionAwareMethodInterceptor<>(session, target, ClientSession.class, MongoDatabase.class, this::proxyDatabase, - MongoCollection.class, this::proxyCollection)); + factory.addAdvice(new SessionAwareMethodInterceptor<>(session, target, ClientSession.class, MongoDatabase.class, + this::proxyDatabase, MongoCollection.class, this::proxyCollection)); return targetType.cast(factory.getProxy()); } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveClientSessionTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveClientSessionTests.java index d409c07b2..9aa4f489b 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveClientSessionTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveClientSessionTests.java @@ -29,37 +29,43 @@ import org.junit.ClassRule; import org.junit.Test; import org.junit.rules.TestRule; import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.test.util.MongoTestUtils; import org.springframework.data.mongodb.test.util.MongoVersionRule; import org.springframework.data.mongodb.test.util.ReplicaSet; import org.springframework.data.util.Version; import com.mongodb.ClientSessionOptions; +import com.mongodb.reactivestreams.client.ClientSession; import com.mongodb.reactivestreams.client.MongoClient; -import com.mongodb.reactivestreams.client.MongoClients; -import com.mongodb.session.ClientSession; +import com.mongodb.reactivestreams.client.Success; /** * @author Christoph Strobl * @author Mark Paluch + * @currentRead Beyond the Shadows - Brent Weeks */ public class ReactiveClientSessionTests { public static @ClassRule MongoVersionRule REQUIRES_AT_LEAST_3_6_0 = MongoVersionRule.atLeast(Version.parse("3.6.0")); public static @ClassRule TestRule replSet = ReplicaSet.required(); + static final String DATABASE_NAME = "reflective-client-session-tests"; + static final String COLLECTION_NAME = "test"; + MongoClient client; ReactiveMongoTemplate template; @Before public void setUp() { - client = MongoClients.create(); + client = MongoTestUtils.reactiveReplSetClient(); - template = new ReactiveMongoTemplate(client, "reflective-client-session-tests"); + template = new ReactiveMongoTemplate(client, DATABASE_NAME); - StepVerifier.create(template.dropCollection("test")).verifyComplete(); + StepVerifier.create(MongoTestUtils.createOrReplaceCollection(DATABASE_NAME, COLLECTION_NAME, client)) + .expectNext(Success.SUCCESS).verifyComplete(); - StepVerifier.create(template.insert(new Document("_id", "id-1").append("value", "spring"), "test")) + StepVerifier.create(template.insert(new Document("_id", "id-1").append("value", "spring"), COLLECTION_NAME)) .expectNextCount(1).verifyComplete(); } @@ -71,7 +77,8 @@ public class ReactiveClientSessionTests { assertThat(session.getOperationTime()).isNull(); - StepVerifier.create(template.withSession(() -> session).execute(action -> action.findAll(Document.class, "test"))) + StepVerifier + .create(template.withSession(() -> session).execute(action -> action.findAll(Document.class, COLLECTION_NAME))) .expectNextCount(1).verifyComplete(); assertThat(session.getOperationTime()).isNotNull(); @@ -89,8 +96,8 @@ public class ReactiveClientSessionTests { assertThat(session.getOperationTime()).isNull(); StepVerifier - .create( - template.withSession(() -> session).execute(action -> action.findOne(new Query(), Document.class, "test"))) + .create(template.withSession(() -> session) + .execute(action -> action.findOne(new Query(), Document.class, COLLECTION_NAME))) .expectNextCount(1).verifyComplete(); assertThat(session.getOperationTime()).isNotNull(); @@ -108,13 +115,21 @@ public class ReactiveClientSessionTests { ReactiveSessionScoped sessionScoped = template.withSession(sessionSupplier); - sessionScoped.execute(action -> action.findOne(new Query(), Document.class, "test")).blockFirst(); + sessionScoped.execute(action -> action.findOne(new Query(), Document.class, COLLECTION_NAME)).blockFirst(); assertThat(sessionSupplier.getInvocationCount()).isEqualTo(1); - sessionScoped.execute(action -> action.findOne(new Query(), Document.class, "test")).blockFirst(); + sessionScoped.execute(action -> action.findOne(new Query(), Document.class, COLLECTION_NAME)).blockFirst(); assertThat(sessionSupplier.getInvocationCount()).isEqualTo(1); } + @Test // DATAMONGO-1970 + public void addsClientSessionToContext() { + + template.withSession(client.startSession(ClientSessionOptions.builder().causallyConsistent(true).build())) + .execute(action -> ReactiveMongoContext.getSession()).as(StepVerifier::create) + .consumeNextWith(session -> assertThat(session).isNotNull()).verifyComplete(); + } + static class CountingSessionSupplier implements Supplier { AtomicInteger invocationCount = new AtomicInteger(0); diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateTransactionTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateTransactionTests.java new file mode 100644 index 000000000..ccf585fc8 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateTransactionTests.java @@ -0,0 +1,174 @@ +/* + * Copyright 2018 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.mongodb.core; + +import static org.springframework.data.mongodb.core.query.Criteria.*; +import static org.springframework.data.mongodb.core.query.Query.*; + +import org.springframework.data.mongodb.core.query.Update; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.Arrays; +import java.util.stream.Collectors; + +import org.bson.Document; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TestRule; +import org.reactivestreams.Publisher; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.test.util.MongoTestUtils; +import org.springframework.data.mongodb.test.util.MongoVersionRule; +import org.springframework.data.mongodb.test.util.ReplicaSet; +import org.springframework.data.util.Version; + +import com.mongodb.ClientSessionOptions; +import com.mongodb.reactivestreams.client.ClientSession; +import com.mongodb.reactivestreams.client.MongoClient; +import com.mongodb.reactivestreams.client.Success; + +/** + * @author Christoph Strobl + * @currentRead The Core - Peter V. Brett + */ +public class ReactiveMongoTemplateTransactionTests { + + public static @ClassRule MongoVersionRule REQUIRES_AT_LEAST_3_7_5 = MongoVersionRule.atLeast(Version.parse("3.7.5")); + public static @ClassRule TestRule replSet = ReplicaSet.required(); + + static final String DATABASE_NAME = "reactive-template-tx-tests"; + static final String COLLECTION_NAME = "test"; + static final Document DOCUMENT = new Document("_id", "id-1").append("value", "spring"); + static final Query ID_QUERY = query(where("_id").is("id-1")); + + static final Person AHMANN = new Person("ahmann", 32); + static final Person ARLEN = new Person("arlen", 24); + static final Person LEESHA = new Person("leesha", 22); + static final Person RENNA = new Person("renna", 22); + + MongoClient client; + ReactiveMongoTemplate template; + + @Before + public void setUp() { + + client = MongoTestUtils.reactiveReplSetClient(); + + template = new ReactiveMongoTemplate(client, DATABASE_NAME); + + StepVerifier.create(MongoTestUtils.createOrReplaceCollection(DATABASE_NAME, COLLECTION_NAME, client)) + .expectNext(Success.SUCCESS).verifyComplete(); + + StepVerifier.create(MongoTestUtils.createOrReplaceCollection(DATABASE_NAME, "person", client)) + .expectNext(Success.SUCCESS).verifyComplete(); + + StepVerifier.create(template.insert(DOCUMENT, COLLECTION_NAME)).expectNextCount(1).verifyComplete(); + + template.insertAll(Arrays.asList(AHMANN, ARLEN, LEESHA, RENNA)).as(StepVerifier::create).expectNextCount(4) + .verifyComplete(); + } + + @Test // DATAMONGO-1970 + public void reactiveTransactionWithExplicitTransactionStart() throws InterruptedException { + + Publisher sessionPublisher = client + .startSession(ClientSessionOptions.builder().causallyConsistent(true).build()); + + template.withSession(sessionPublisher) + .executeSingle(action -> ReactiveMongoContext.getSession().flatMap(session -> { + + session.startTransaction(); + return action.remove(ID_QUERY, Document.class, COLLECTION_NAME); + + })).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + + template.exists(ID_QUERY, COLLECTION_NAME) // + .as(StepVerifier::create).expectNext(false).verifyComplete(); + } + + @Test // DATAMONGO-1970 + public void reactiveTransactionsCommitOnComplete() throws InterruptedException { + + template.inTransaction().execute(action -> action.remove(ID_QUERY, Document.class, COLLECTION_NAME)) // + .as(StepVerifier::create).expectNextCount(1).verifyComplete(); + + template.exists(ID_QUERY, COLLECTION_NAME) // + .as(StepVerifier::create).expectNext(false).verifyComplete(); + } + + @Test // DATAMONGO-1970 + public void reactiveTransactionsAbortOnError() throws InterruptedException { + + template.inTransaction().execute(action -> { + return action.remove(ID_QUERY, Document.class, COLLECTION_NAME).flatMap(result -> Mono.fromSupplier(() -> { + throw new RuntimeException("¯\\_(ツ)_/¯"); + })); + }).as(StepVerifier::create).expectError().verify(); + + template.exists(ID_QUERY, COLLECTION_NAME) // + .as(StepVerifier::create).expectNext(true).verifyComplete(); + } + + @Test // DATAMONGO-1970 + public void changesNotVisibleOutsideTransaction() throws InterruptedException { + + template.inTransaction().execute(action -> { + return action.remove(ID_QUERY, Document.class, COLLECTION_NAME).flatMap(val -> { + + // once we use the collection directly we're no longer participating in the tx + return Mono.from(template.getCollection(COLLECTION_NAME).find(ID_QUERY.getQueryObject())); + }); + }).as(StepVerifier::create).expectNext(DOCUMENT).verifyComplete(); + + template.exists(ID_QUERY, COLLECTION_NAME) // + .as(StepVerifier::create).expectNext(false).verifyComplete(); + } + + @Test // DATAMONGO-1970 + public void takeDoesNotAbortTransaction() throws InterruptedException { + + template.inTransaction().execute(action -> { + return action.find(query(where("age").exists(true)).with(Sort.by("age")), Person.class).take(3) + .flatMap(person -> { + return action.remove(person); + }); + }).as(StepVerifier::create).expectNextCount(3).verifyComplete(); + + template.count(query(where("age").exists(true)), Person.class) // + .as(StepVerifier::create).expectNext(1L).verifyComplete(); + } + + @Test // DATAMONGO-1970 + public void errorInFlowOutsideTransactionDoesNotAbortIt() throws InterruptedException { + + template.inTransaction().execute(action -> { + + return action.find(query(where("age").is(22)).with(Sort.by("age")), Person.class).buffer(2).flatMap(values -> { + + return action.remove(query(where("id").in(values.stream().map(Person::getId).collect(Collectors.toList()))), + Person.class).then(Mono.just(values)); + }); + }).flatMap(deleted -> { + throw new RuntimeException("error outside the transaction does not influence it."); + }).as(StepVerifier::create).expectError().verify(); + + template.count(query(where("age").exists(true)), Person.class) // + .as(StepVerifier::create).expectNext(2L).verifyComplete(); + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveSessionBoundMongoTemplateUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveSessionBoundMongoTemplateUnitTests.java index 412eec50a..05c940d5c 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveSessionBoundMongoTemplateUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveSessionBoundMongoTemplateUnitTests.java @@ -53,12 +53,12 @@ import com.mongodb.client.model.DeleteOptions; import com.mongodb.client.model.FindOneAndUpdateOptions; import com.mongodb.client.model.UpdateOptions; import com.mongodb.reactivestreams.client.AggregatePublisher; +import com.mongodb.reactivestreams.client.ClientSession; import com.mongodb.reactivestreams.client.DistinctPublisher; import com.mongodb.reactivestreams.client.FindPublisher; import com.mongodb.reactivestreams.client.MongoClient; import com.mongodb.reactivestreams.client.MongoCollection; import com.mongodb.reactivestreams.client.MongoDatabase; -import com.mongodb.session.ClientSession; /** * Unit tests for {@link ReactiveSessionBoundMongoTemplate}. diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SimpleReactiveMongoDatabaseFactoryUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SimpleReactiveMongoDatabaseFactoryUnitTests.java index df7dbe599..42e96f8eb 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SimpleReactiveMongoDatabaseFactoryUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/SimpleReactiveMongoDatabaseFactoryUnitTests.java @@ -31,9 +31,9 @@ import org.springframework.aop.framework.AopProxyUtils; import org.springframework.data.mongodb.ReactiveMongoDatabaseFactory; import org.springframework.test.util.ReflectionTestUtils; +import com.mongodb.reactivestreams.client.ClientSession; import com.mongodb.reactivestreams.client.MongoClient; import com.mongodb.reactivestreams.client.MongoDatabase; -import com.mongodb.session.ClientSession; /** * Unit tests for {@link SimpleReactiveMongoDatabaseFactory}. diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepositoryTransactionalTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepositoryTransactionalTests.java index 1883d1613..9807422e8 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepositoryTransactionalTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepositoryTransactionalTests.java @@ -54,8 +54,8 @@ import org.springframework.test.context.transaction.BeforeTransaction; import org.springframework.transaction.annotation.Transactional; import com.mongodb.MongoClient; -import com.mongodb.MongoClientOptions; import com.mongodb.ReadPreference; +import com.mongodb.WriteConcern; import com.mongodb.client.MongoCollection; import com.mongodb.client.model.Filters; @@ -123,20 +123,30 @@ public class PersonRepositoryTransactionalTests { } @AfterTransaction - public void verifyDbState() { + public void verifyDbState() throws InterruptedException { - MongoCollection collection = client.getDatabase(DB_NAME) + Thread.sleep(100); + + MongoCollection collection = client.getDatabase(DB_NAME) // + .withWriteConcern(WriteConcern.MAJORITY) // + .withReadPreference(ReadPreference.primary()) // .getCollection(template.getCollectionName(Person.class)); - assertionList.forEach(it -> { + try { + assertionList.forEach(it -> { - boolean isPresent = collection.find(Filters.eq("_id", new ObjectId(it.getId().toString()))).limit(1).iterator() - .hasNext(); + boolean isPresent = collection.find(Filters.eq("_id", new ObjectId(it.getId().toString()))).iterator() + .hasNext(); - assertThat(isPresent).isEqualTo(it.shouldBePresent()) - .withFailMessage(String.format("After transaction entity %s should %s.", it.getPersistable(), - it.shouldBePresent() ? "be present" : "NOT be present")); - }); + assertThat(isPresent) // + .withFailMessage(String.format("After transaction entity %s should %s.", it.getPersistable(), + it.shouldBePresent() ? "be present" : "NOT be present")) + .isEqualTo(it.shouldBePresent()); + + }); + } finally { + assertionList.clear(); + } } @Rollback(false) @@ -144,7 +154,6 @@ public class PersonRepositoryTransactionalTests { public void shouldHonorCommitForDerivedQuery() { repository.removePersonByLastnameUsingAnnotatedQuery(durzo.getLastname()); - repository.removePersonByLastnameUsingAnnotatedQuery(durzo.getLastname()); assertAfterTransaction(durzo).isNotPresent(); } @@ -184,6 +193,11 @@ public class PersonRepositoryTransactionalTests { public boolean isNew() { return person.id != null; } + + @Override + public String toString() { + return getId() + " - " + person.toString(); + } }); assertionList.add(assertion); diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestUtils.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestUtils.java index 023665b41..18fad010c 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestUtils.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/test/util/MongoTestUtils.java @@ -15,20 +15,25 @@ */ package org.springframework.data.mongodb.test.util; +import reactor.core.publisher.Mono; + import org.bson.Document; -import com.mongodb.MongoClient; -import com.mongodb.MongoClientOptions; +import com.mongodb.MongoClientURI; import com.mongodb.ReadPreference; import com.mongodb.WriteConcern; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; +import com.mongodb.reactivestreams.client.MongoClients; +import com.mongodb.reactivestreams.client.Success; /** * @author Christoph Strobl */ public class MongoTestUtils { + public static final String CONNECTION_STRING = "mongodb://localhost:27017/?replicaSet=rs0"; // &readPreference=primary&w=majority + /** * Create a {@link com.mongodb.client.MongoCollection} if it does not exist, or drop and recreate it if it does. * @@ -37,7 +42,7 @@ public class MongoTestUtils { * @param client must not be {@literal null}. */ public static MongoCollection createOrReplaceCollection(String dbName, String collectionName, - MongoClient client) { + com.mongodb.MongoClient client) { MongoDatabase database = client.getDatabase(dbName); @@ -59,14 +64,39 @@ public class MongoTestUtils { } /** - * Create a new {@link MongoClient} with defaults suitable for replica set usage. + * Create a {@link com.mongodb.client.MongoCollection} if it does not exist, or drop and recreate it if it does. * - * @return new instance of {@link MongoClient}. + * @param dbName must not be {@literal null}. + * @param collectionName must not be {@literal null}. + * @param client must not be {@literal null}. */ - public static MongoClient replSetClient() { + public static Mono createOrReplaceCollection(String dbName, String collectionName, + com.mongodb.reactivestreams.client.MongoClient client) { - return new MongoClient("localhost", - MongoClientOptions.builder().requiredReplicaSetName("rs0").build()); + com.mongodb.reactivestreams.client.MongoDatabase database = client.getDatabase(dbName) + .withWriteConcern(WriteConcern.MAJORITY).withReadPreference(ReadPreference.primary()); + + return Mono.from(database.getCollection(collectionName).drop()) + .then(Mono.from(database.createCollection(collectionName))); + } + + /** + * Create a new {@link com.mongodb.MongoClient} with defaults suitable for replica set usage. + * + * @return new instance of {@link com.mongodb.MongoClient}. + */ + public static com.mongodb.MongoClient replSetClient() { + + return new com.mongodb.MongoClient(new MongoClientURI(CONNECTION_STRING)); + } + + /** + * Create a new {@link com.mongodb.reactivestreams.client.MongoClient} with defaults suitable for replica set usage. + * + * @return new instance of {@link com.mongodb.reactivestreams.client.MongoClient}. + */ + public static com.mongodb.reactivestreams.client.MongoClient reactiveReplSetClient() { + return MongoClients.create(CONNECTION_STRING); } } diff --git a/src/main/asciidoc/reference/client-session-transactions.adoc b/src/main/asciidoc/reference/client-session-transactions.adoc index f2c11591c..8b9018fac 100644 --- a/src/main/asciidoc/reference/client-session-transactions.adoc +++ b/src/main/asciidoc/reference/client-session-transactions.adoc @@ -9,6 +9,9 @@ Both `MongoOperations` and `ReactiveMongoOperations` provide gateway methods for NOTE: Methods such as `(Reactive)MongoOperations#getCollection` return native MongoDB Java Driver gateway objects (such as `MongoCollection`) that themselves offer dedicated methods for `ClientSession`. These methods are *NOT* session-proxied. You should provide the `ClientSession` where needed when interacting directly with a `MongoCollection` or `MongoDatabase` and not through one of the `#execute` callbacks on `MongoOperations`. +[[mongo.sessions.sync]] +== Synchronous `ClientSession` support. + The following example shows the usage of a session: .`ClientSession` with `MongoOperations` @@ -45,6 +48,9 @@ session.close() <4> WARNING: When dealing with `DBRef` instances, especially lazily loaded ones, it is essential to *not* close the `ClientSession` before all data is loaded. Otherwise, lazy fetch fails. +[[mongo.sessions.reactive]] +== Reactive `ClientSession` support + The reactive counterpart uses the same building blocks as the imperative one, as the following example shows: .ClientSession with `ReactiveMongoOperations` @@ -207,3 +213,72 @@ public class StateService { NOTE: `@Transactional(readOnly = true)` advises `MongoTransactionManager` to also start a transaction that adds the `ClientSession` to outgoing requests. + +== Reactive transactions + +Same as with the reactive `ClientSession` support, the `ReactiveMongoTemplate` offers dedicated methods for operating +within a transaction without having to worry about the commit/abort actions depending on the operations outcome. + +Using the plain MongoDB reactive driver API a `delete within a transactional flow may look like this. + +.Native driver support +==== +[source,java] +---- +Mono result = Mono + .from(client.startSession()) <1> + .flatMap(session -> { + session.startTransaction(); <2> + return Mono.from(collection.deleteMany(session, ...)) <3> + .onErrorResume(e -> Mono.from(session.abortTransaction()).then(Mono.error(e))) <4> + .flatMap(val -> Mono.from(session.commitTransaction()).then(Mono.just(val))) <5> + .doFinally(signal -> session.close()); <6> + }); +---- +<1> Ok, first we obvoiusly need to initiate the session. +<2> Once we've the `ClientSession` at hand, start the transaction. +<3> Operate within the transaction by passing on the `ClientSession` to the operation. +<4> If the operations errors, we need to abort the transaction and preserve the error. +<5> Or of course, commit the changes in case of success. Still preserving the operations result. +<6> Last, we need to make sure to close the session. +====` + +The culprit of the above operation is in keeping the main flows `DeleteResult` instead of the transaction outcome +published via either `commitTransaction()` or `abortTransaction()`, which leads to a rather complicated setup. + +`MongoOperations.inTransaction()` allows you to utilize the callback from for the <> to actually preserve the flows outcome but also perform commit and abort actions +accordingly. This allows you to express the above flow simply as the following: + +.ReactiveMongoTemplate transactions +==== +[source,java] +---- +Mono result = template.inTransaction() <1> + .execute(action -> action.remove(query(where("id").is("step-1")), Step.class)); <2> +---- +<1> Initiate the transaction. +<2> Operate within the `ClientSession`. +==== + +NOTE: In case you need access to the `ClientSession` within the flow, you can use `ReactiveMongoContext.getSession()` + to obtain in from the Reactor `Context`. + +Everything happening inside the transactional callback is executed within a transaction. Errors subsequent in the +reactive flow do not influence the operations within the transaction. + +==== +[source,java] +---- +template.inTransaction() <1> + .execute(action -> action.find(query(where("state").is("active")), Step.class).flatMap(step -> + action.update(Step.class).matching(query(where("id").is(step.id))).apply(update("state", "paused")).all())) <2> + .flatMap(deleted -> { + // errors here <3> + }).subscribe(); +---- +<1> Initiate the transaction. +<2> Operate within the `ClientSession`. The transaction is committed after when this is done or rolled back if an +error occurs here. +<3> An error outside the transaction flow has no affect on the previous transactional execution. +====