CBL-7841: Handle conflict when revision history appears broken#2489
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a fallback in putExistingRevision to recover from cases where a pulled revision conflicts with the local revision because the remote's history appears to have been reset (e.g. a deleted doc being resurrected in Couchbase Server with the same ID). When the general VV comparison yields kConflicting, the code loads the last revision known to be shared with that remote and, if that landmark is the same as or newer than the current local revision, treats the incoming revision as kNewer instead of a conflict.
Changes:
- Detect the "broken history" case after a
kConflictingresult fromVersionVecWithLegacy::compare. - Use
_doc.loadRemoteRevision(remote)as a landmark; promoteordertokNewerwhen the landmark is same/newer thancurVers. - Add an extensive comment block documenting the reasoning behind the fallback.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
This is a fallback for cases where the general comparison algorithm yields a Conflict while applying a remote revision to the local document. The target scenario is a deleted document being resurrected in CBS -- when that happens, the resurrected document has no history prior to the deletion.
|
Code Coverage Results:
|
| // CBL-7841: Handle the case where revision history may appear broken. | ||
| // |
There was a problem hiding this comment.
Having a detailed explanation is good, but this is already quite a long method. One thing I've done before is to make the explanation a "footnote" at the end of the file, with a one-line reference to it inline.
There was a problem hiding this comment.
I will put the comments in the CBL ticket, and leave a short note in the source.
| if ( orderWithLandmark == kSame || orderWithLandmark == kNewer ) order = kNewer; | ||
| } |
There was a problem hiding this comment.
As I understand it, this says "if the last known rev at the remote was the same or newer than the local rev, treat the remote's new rev as also newer than the local rev, instead of as a conflict."
I think this is too general. This situation isn't the same as the rev-tree "branch switch". It's specifically that the remote has an entirely new history, because Couchbase Server recreated the doc from scratch after it was deleted. We can detect that by checking whether rev and newVers have no Versions in common, and also the local revision is a tombstone. What do you think?
There was a problem hiding this comment.
I did not find specific check of "the local revision is a tombstone". Did I miss the check somewhere?
There was a problem hiding this comment.
I think whether we accept the newVers only if the curVers is a tombstone is a decision by its own right, provided we already decided that "Couchbase Server recreated the doc from scratch." I tried to create the the doc in Couchbase Server after I deleted in Lite and syned to the SGW. There is no friction in the way. I also tried to create the doc in CB after the doc is syned to SGW before deleted. In this case CB won't let me do it because the doc already exists. I did not try to create the doc in CB first before CB sees it. This would simulate the case where a doc with same docID is created in Lite and CB concurrently, and we will encounter the same situation where the latestSynedInRemote shares no versions at at all. The only difference is whether curVers is a tombstone or not.
| // 6. curVers.legacy[0] is not in newVers.legacy (RevTree parent) | ||
| auto isCurVersLegacyParentOfNewVers = [&]() { | ||
| if ( curVers.legacy.empty() ) return false; | ||
| return std::ranges::find(newVers.legacy, curVers.legacy[0]) != curVers.legacy.end(); |
| // CBL-7841: Handle the case where revision history may appear broken. | ||
| // | ||
| // Concrete scenario for a single document ID: | ||
| // 1. Local DB deletes the doc. | ||
| // 2. Local pushes the tombstone to a remote SGW. | ||
| // 3. In the target CB database, a new doc is created with the same ID. | ||
| // (In CBS, a deleted doc is effectively purged, so the ID is free | ||
| // for reuse by an unrelated document.) | ||
| // 4. Local DB performs a pull. | ||
| // | ||
| // At step 4 the BLIP message carries a document that starts at a new | ||
| // revision with no history. In this case, this new revision will be in | ||
| // conflict with any revisions that exist before its birth. However, we | ||
| // would like to make it new current, replacing the already deleted Local. | ||
|
|
The criteria of this comparion is curVers.legacy[0] is-not-contained-in newVers.legacy meaning to assert that curVers is not a parent of newVers.
| // 5. curVers.vector shares no version with newVers.vector | ||
| // 6. curVers.legacy[0] is not in newVers.legacy (RevTree parent) | ||
|
|
||
| for ( bool goOn = order == kConflicting && remote != RemoteID::Local; goOn; goOn = false ) { |
There was a problem hiding this comment.
This is confusing. It is effectively an if, since it never runs more than once, but I'm guessing you made it a for so you could use break to exit it.
In my experience, when you have a block with multiple exits like this, it's time to turn it into a function. Or if that would add too many parameters to pass, use a do{...}while(false) statement along with a comment that this is just to allowbreak.
| VersionVecWithLegacy lastSynced{rev->revID}; | ||
| if ( curVers.legacy != lastSynced.legacy || curVers.vector != lastSynced.vector ) break; | ||
|
|
||
| // 5. curVers.vector shares no version with newVers.vector |
There was a problem hiding this comment.
This whole thing through line 488 should be a method on VersionVector, not inlined here.
|
|
||
| // 5. curVers.vector shares no version with newVers.vector | ||
| unordered_map<SourceID, logicalTime> newVersions; | ||
| optional<std::pair<SourceID, logicalTime>> extra; |
There was a problem hiding this comment.
This pair is identical to Version, so use that instead.
| auto isCurVersLegacyParentOfNewVers = [&]() { | ||
| if ( curVers.legacy.empty() ) return false; | ||
| return std::ranges::find(newVers.legacy, curVers.legacy[0]) != newVers.legacy.end(); | ||
| }; | ||
| if ( isCurVersLegacyParentOfNewVers() ) break; |
There was a problem hiding this comment.
Why is this a lambda and not just a plain if statement?
CBL-8057: Better stack trace logging and crash handling (#2490) CBL-7841: Handle conflict when revision history appears broken (#2489) CBL-8229: Upgrade SQLite to 3.53.0 (#2473) CBL-8180: Upgrade mbedTLS to 3.6.6 (#2469) EE: c32b01c : Fix build of peer-o-matic tool (#143) CBL-8326: Remove temp error log in TLSCodec auth callback (#142) CBL-8229: Upgrade SQLite to 3.53.0 (#138)
This is a fallback for cases where the general comparison algorithm yields a Conflict while applying a remote revision to the local document. The target scenario is a deleted document being resurrected in CBS -- when that happens, the resurrected document has no history prior to the deletion.