cas of document replaced in transaction changes again on completion of transaction. @Transactional method (5.0.0-M6)
See original GitHub issueHi,
We catched a situation in which the @Version field is being updated more than once when we call a save
inside a @Transactional method.
If we have a @Transactional method like the following:
@Transactional
public Account create(Account account) {
// ... any logic
return accountRepository.save(account);
}
We noticed that when the caller try to perform any update in the returned object we were receiving an OptimisticLockingFailureException
.
After some investigation we saw that the @Version field was being updated more than once, the first is when we call the save
method, and a second is possibly happening after the finish of the @Transactional method execution.
We can reproduce this situation by logging the version after the save
and logging again on the caller after query the same object.
Something like:
@Transactional
public Account create(Account account) {
// ... any logic
Account saved = accountRepository.save(account);
log.info("Version of saved object {}", saved.getVersion());
return saved;
}
public void callerMethod() {
// ... any logic
Account saved = accountService.create(new Account());
Account queried = accountService.findById(saved.getUuid());
log.info("Versions of saved {} and queried {}", saved.getVersion(), queried.getVersion());
}
Important to reinforce that it only happens if the create
is @Transactional, if we remove the @Transactional annotation the @Version field is updated only once and the caller can perform updates over the returned object without any problem.
Issue Analytics
- State:
- Created a year ago
- Comments:6 (1 by maintainers)
Your analysis is correct. The cas of the document after the transaction is complete is different than the cas used to update the @Version in the save() call inside the @transactional method, therefore using the object returned from the save() in the transaction cannot be used for subsequent save() or replace() operations. A new object will need to be read. If I dig a little deeper, I can give you a better answer.
The short explanation is that replace()/remove() of an entity object inside a transaction that was obtained outside that transaction is susceptible to write-write conflict from an update outside of that transaction (without cas, this would be undetectable[^1]). And therefore transactions can(should[^2]) only replace()/remove() entity objects which have been obtained inside the transaction. A transaction using the entity object obtained outside that transaction should indeed fail as obtaining it was not part of the transaction. This is not specific to couchbase.
[^1]:If only optimistic locking via cas is required, then a transaction is not necessary, but the application would need to handle cas-mismatch. [^2]:The behavior that a spring-data-couchbase transaction can use an object obtained outside any transaction is a misbehavior - although it does succeed now and does not cause any inconsistency - it should not be used because in the future it may no longer work.
Here is the explanation of why using an entity object obtained outside a transaction works inside a spring-data-couchbase transaction when it really shouldn’t.
The java sdk couchbase transactions implementation requires an AttemptContext and a TransactionGetResult (as an input for replace and remove operations). i.e. ctx.replace(txGetResult, replDoc) and ctx.remove(txGetResult). The attemptContext is available inside the transaction lambda and the calling application is responsible for obtaining the transactionGetResult object from one call in a transaction and passing it to a subsequent call in the same transaction. Spring data entities have no place to hold that TransactionGetResult. I proposed to hold TransactionGetResults in a map in either ThreadLocal (for synchronous transactions) or in the reactive context (for reactive transactions). This would work fine when (a) the TransactionGetResult had been populated (by an insert or a find); and (b) was consumed in the same transaction by a replace() or remove(). Although I liked this idea, it was rejected. The mechanism that was implemented was that whenever a TransactionGetResult was needed in a replace() or remove(), the document would be get()-ed (in the spring-data-couchbase code), and that TransactionGetResult would be used. Although this works, it results in a somewhat duplicate get() if the document was already get()-ed in the transaction by the application. This internal get() gives the illusion that a document can be replace()-ed or remove()-ed in a transaction where it has not been get()-ed. It cannot. (spring-data-couchbase is doing the get() internally). If this implementation was replaced by my (favored) implementation of storing the TransactionGetResult from an application’s insert() or get() in a transaction in a map in ThreadLocal or the reactive context - any code written to replace() or update() an entity object in a transaction that was obtained outside the transaction - would fail, as it does not have a TransactionGetResult from the current transaction.
Regarding the two cas values. The first cas, from inside the transaction, is only valid inside the transaction, before it is committed. After the transaction has been committed, there is a new cas value. While that new cas value is what a subsequent transaction would see (by doing a get(), if the document has not been modified, the subsequent transaction should not rely on it, instead it should do it’s own get() (see [^2]). One can still argue that it would be nice to have the post-commit cas instead of the in-transaction cas returned in the entity object - but it is not available during the save() operation as that is still inside the transaction. And it cannot be populated on completion of the couchbase-transaction, as couchbase-transactions are unaware of @Version. @Version is a spring-data-couchbase mechanism.