Skip to content

Document how reactive transactions work for cancellation in 5.2 and how it will work in 5.3 #25091

Closed
@rpuch

Description

@rpuch

Affects: 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

Metadata

Metadata

Assignees

Labels

in: dataIssues in data modules (jdbc, orm, oxm, tx)type: documentationA documentation task

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions