Document how reactive transactions work for cancellation in 5.2 and how it will work in 5.3
See original GitHub issueAffects: 5.2.6, current master
Briefly: according to my observations, Spring commits a transaction when a ‘transactional reactive pipeline’ gets cancelled. To the moment of such a cancellation, it could happen that only part of the work inside the transaction has been done, so a commit at that point produces partially-committed results violating the ‘atomicity’ transactional property.
I’ve created a project demonstrating the problem: https://github.com/rpuch/spring-commit-on-cancel-problems
@Test
void cancelShouldNotLeadToPartialCommit() throws InterruptedException {
// latch is used to make sure that we cancel the subscription only after the first insert has been done
CountDownLatch latch = new CountDownLatch(1);
Disposable disposable = bootService.savePair(collection, latch).subscribe();
// wait for the first insert to be executed
latch.await();
// now cancel the reactive pipeline
disposable.dispose();
// Now see what we have in the DB. Atomicity requires that we either see 0 or 2 documents.
List<Boot> boots = mongoOperations.findAll(Boot.class, collection).collectList().block();
assertEquals(0, boots.size());
}
The main (and only) test, PartialCommitsOnCancelTest#cancelShouldNotLeadToPartialCommit()
, does the following: it initiates a reactive pipeline having 2 inserts. Both inserts are wrapped in a (declarative) transaction. The code is crafted to make a cancellation exactly between the inserts possible:
@Transactional
public Mono<Void> savePair(String collection, CountDownLatch latch) {
return Mono.defer(() -> {
Boot left = new Boot();
left.setKind("left");
Boot right = new Boot();
right.setKind("right");
return mongoOperations.insert(left, collection)
// signaling to the test that the first insert has been done and the subscription can be cancelled
.then(Mono.fromRunnable(latch::countDown))
// do not proceed to the second insert ever
.then(Mono.fromRunnable(this::blockForever))
.then(mongoOperations.insert(right, collection))
.then();
});
}
The pipeline is initiated, and after the first insert is done (but before the second one is initiated), the test cancels the pipeline. It then inspects the collection and finds that there is exactly 1 document, which means that the transaction was committed partially.
In the log, I see the following:
2020-05-16 22:13:02.643 DEBUG 1988 --- [ main] o.s.d.m.ReactiveMongoTransactionManager : Creating new transaction with name [com.example.commitoncancelproblems.BootService.savePair]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2020-05-16 22:13:02.667 DEBUG 1988 --- [ main] o.s.d.m.ReactiveMongoTransactionManager : About to start transaction for session [ClientSessionImpl@5792c08c id = {"id": {"$binary": "1TaU0xPNQpKaJTHdPLS05w==", "$type": "04"}}, causallyConsistent = true, txActive = false, txNumber = 0, error = d != java.lang.Boolean].
2020-05-16 22:13:02.668 DEBUG 1988 --- [ main] o.s.d.m.ReactiveMongoTransactionManager : Started transaction for session [ClientSessionImpl@5792c08c id = {"id": {"$binary": "1TaU0xPNQpKaJTHdPLS05w==", "$type": "04"}}, causallyConsistent = true, txActive = true, txNumber = 1, error = d != java.lang.Boolean].
2020-05-16 22:13:02.703 DEBUG 1988 --- [ main] o.s.d.m.core.ReactiveMongoTemplate : Inserting Document containing fields: [kind, _class] in collection: e81205fa_eb5f_4492_8921_ffb3fccd2b76
2020-05-16 22:13:02.757 DEBUG 1988 --- [ main] o.s.d.m.ReactiveMongoTransactionManager : Initiating transaction commit
2020-05-16 22:13:02.758 DEBUG 1988 --- [ main] o.s.d.m.ReactiveMongoTransactionManager : About to commit transaction for session [ClientSessionImpl@5792c08c id = {"id": {"$binary": "1TaU0xPNQpKaJTHdPLS05w==", "$type": "04"}}, causallyConsistent = true, txActive = true, txNumber = 1, error = d != java.lang.Boolean].
2020-05-16 22:13:02.767 DEBUG 1988 --- [ Thread-6] o.s.d.m.ReactiveMongoTransactionManager : About to release Session [ClientSessionImpl@5792c08c id = {"id": {"$binary": "1TaU0xPNQpKaJTHdPLS05w==", "$type": "04"}}, causallyConsistent = true, txActive = false, txNumber = 1, error = d != java.lang.Boolean] after transaction.
So the code is actually run in a transaction. The savePair()
pipeline never gets completed successfully, but the transaction gets committed producing the ‘partial’ result.
Looking at TransactionAspectSupport.ReactiveTransactionSupport#invokeWithinTransaction()
, I see the following code:
return Mono.<Object, ReactiveTransactionInfo>usingWhen(
Mono.just(it),
txInfo -> {
try {
return (Mono<?>) invocation.proceedWithInvocation();
}
catch (Throwable ex) {
return Mono.error(ex);
}
},
this::commitTransactionAfterReturning,
(txInfo, err) -> Mono.empty(),
this::commitTransactionAfterReturning)
The last parameter is onCancel
. So it actually commits on cancel. (The same behavior is in TransactionalOperatorImpl
; also, spring-data-mongodb
’s ReactiveMongoTemplate#inTransaction()
does the same, but it’s a different Spring project).
This looks like a bug to me, but I can hardly believe that this behavior was implemented by mistake. Is it possible that I misunderstood something?
PS. There is an SO question with details and some context: https://stackoverflow.com/questions/61822249/spring-reactive-transaction-gets-committed-on-cancel-producing-partial-commits?noredirect=1#comment109381171_61822249
Issue Analytics
- State:
- Created 3 years ago
- Reactions:1
- Comments:7 (4 by maintainers)
Top GitHub Comments
The code works as designed. Whether commit on cancel is appropriate is an entirely different topic really and depends on the perspective.
Cancellation is a pretty standard signal for operators like
Flux.next()
,Flux.take()
, evenMono.from(Publisher)
. These operators cancel the upstream subscription after consuming the desired number of items.Operators like
timeout()
send the exact same cancellation signal (Subscription.cancel()
) if the timeout exceeds. To make it worse, a connection reset (as viewed from a HTTP connection perspective) issues as well a cancellation signal.From a
Subscription
perspective, there’s no way of distinguishing between these cases, whether cancellation should result in limiting the number of emitted elements or whether the subscription should be canceled because of a failure.So what’s required is two things:
Cancelation works as per the definition in the Reactive Streams specification. We would require either a Reactor-specific extension or ideally an approach on the Reactive Streams level.
Adding @bsideup and @simonbasle to this thread.
Team decision:
The current behavior (commit on cancel) was added to not interfere with accidental cancellation due to operators such as
Flux.take()
,Flux.next()
,Flux.single()
.We figured also that there are several use-cases where commit on cancel is not appropriate:
Mono
from a transaction: Cancellation would let one expect that the transaction rolls back since no element/completion was emitted yet.Flux.timeout()
: A timeout protects the application and a cancellation in flight might lead to partial commits. Timeout operators used together withrepeat(…)
might lead to duplicate work being applies.For Spring Framework 5.2, we’re going to document the current behavior to explain semantics.
For 5.3, we’re going to change the behavior to rollback on cancel. By flipping semantics of cancel signals we create a reliable and deterministic outcome. Cancellations by protective means or as consequence of an error lead to a rollback. These arrangements are typically expensive to test. Although cancellations caused by operators such as
Flux.take()
andFlux.next()
cannot be distinguished from errorneous cancellations and lead as well to a rollback, these arrangements are easily testable with unit or integration tests as the transaction is expected to be rolled back.We might consider adding an operator to enable commit on cancel semantics if these are cases worth supporting.