Conflicts resolution: how to work with tombstones

I’m trying to create a conflict resolution system but I’m having troubles with conflicts that deals with tombstone entries. For example, one user edit one register and another one removes the same register from the system. I’m not sure how to deal with that conflict resolution and how to detect them?

I have been playing with the change listener of the database that it’s detecting all the things that are happening there, but the problem is that I’m not sure how to detect only when there are conflicts with some entry that has conflict because has been removed locally or remotely.

Anyone can give me some hint with that?

Thanks in advance.

Which version of CBL / SGW platform are you using? This is important because conflict resolution process is different between 1.x and 2.0.

Also, what do you mean by “register” ? Is this a CB document.

If you are on 1.x of platform, then this blog may help you with some relevant background.

Note that in 2.0, we have automatic conflict resolution and that should be available in Beta2 release (DB023) . In 1.x, you can check out this link which provides an example of resolving conflicts on CBL.
Hence the above question.

Thanks for your answer @priya.rajagopal !

I’m working with 1.5 version. And yes, by register I meant CB document.

I have checked the links that you wrote in your comment, but I haven’t found a solution for my problem. I’m going to try to explain it better because I don’t know how manage with it.

The thing is that I’m creating a conflict resolution system like a Control Version System that the user can decide when there is some conflict between a local change and a remote one with the same document which one change has to be applied.

The different cases that I think that could create a conflict are:

  • Local User and Remote User makes a change in the same document.
  • Local User removes a document that Remote User has made a change on it.
  • Remote User removes a document that Local User has made a change on it.

I have the system implemented and partially working because the problem that I’m having is about conflict detection.

Here is a resume of my code:

void init()
{
	...

	database.addChangeListener(new Database.ChangeListener()
	{
	    @Override
	    public void changed(Database.ChangeEvent changeEvent)
	    {
	        checkConflicts(changeEvent.getChanges());
	    }
	});
}

Map<String, Object> mergeRevisions(List<SavedRevision> conflicts)
{
    Map<String, Object> data = new HashMap<>();
    
    ...

    //CODE ASKING FOR FIELDS CHANGE BETWEEN REVISIONS TO THE USER AND MERGING
    //WHAT USER DECIDED INTO data MAP

    ...

    return data;
}

void checkConflicts(List<DocumentChange> result)
{
	try
	{
	    for (DocumentChange dChange : result)
	    {
	        final Document document = database.getDocument(dChange.getDocumentId());
	        final List<SavedRevision> conflicts = document.getConflictingRevisions();

	        if (dChange.isConflict())
	        {
	            Map<String, Object> userChoosenProps = new HashMap<>();

	            // If conflicts size is 1, it's because it's related to a delete revision
	            if(conflicts.size() == 1)
	            {
	                String kMaintainStr = "Maintain";
	                String kRemoveStr = "Remove";

	                List<String> differentOptions = new ArrayList<>();
	                differentOptions.add(kMaintainStr);
	                differentOptions.add(kRemoveStr);

	                ChoiceDialog<String> dialog = new ChoiceDialog<>(kMaintainStr, differentOptions);
	                dialog.setTitle("Conflict with register deleted");
	                dialog.setHeaderText(String.format("%s", conflicts.get(0).getUserProperties().values().toString()));
	                dialog.setContentText("Do you want to remove this entry or maintain the showed version?");
	                dialog.initStyle(StageStyle.UNDECORATED);

	                // Traditional way to get the response value.
	                Optional<String> resultDialog = dialog.showAndWait();
	                if (resultDialog.isPresent())
	                {
	                    if(resultDialog.get().compareTo(kMaintainStr) == 0)
	                    {
	                    	//USER WANTS TO KEEP THE MODIFIED VERSION

	                        userChoosenProps = conflicts.get(0).getUserProperties();
	                    }
	                    else
	                    {
	                    	//USER WANTS TO KEEP THE REMOVED VERSION

	                        UnsavedRevision rev = conflicts.get(0).createRevision();
	                        rev.setIsDeletion(true);
	                        rev.save(true);

	                        return;
	                    }
	                }
	            }
	            else
	            {
	                userChoosenProps = mergeRevisions(conflicts);
	            }

	            final Map<String, Object> mergedProps = userChoosenProps;

	            database.runInTransaction(new TransactionalTask()
	            {
	                @Override
	                public boolean run()
	                {
	                    try
	                    {
	                        SavedRevision current = document.getCurrentRevision();
	                        for (SavedRevision rev : conflicts)
	                        {
	                            UnsavedRevision newRev = rev.createRevision();

	                            if (rev.getId().equals(current.getId()))
	                            {
	                                newRev.setProperties(mergedProps);
	                            }
	                            else
	                            {
	                                newRev.setIsDeletion(true);
	                            }

	                            newRev.save(true);

	                            revisionConflictsAlreadyResolved.add(newRev.getId());
	                        }
	                    }
	                    catch (Exception e)
	                    {
	                        e.printStackTrace();
	                        return false;
	                    }

	                    return true;
	                }
	            });
	        }
	    }
	}
    catch (Exception e)
    {
        e.printStackTrace();
    }
}

I’m listening for changes directly to the database to try to figure out when a conflict occurs and be able to show a UI to the user to solve manually the problem. The thing is with the detection of a conflict when there is a change on the database because when I’m solving a local - remote change for the same document that I need to mark for deletion one of the revision, automatically is calling that there is a conflict with the revisions that I have just solved.

When I’m resolving with the merged properties by the user, then a change on the database is thrown saying that there is a conflict with the new revision and I don’t understand why… I suppose that I’m doing something wrong, but I don’t have any idea what it is…

I don’t know if I have explained well, if not tell me and I’ll try to be more clear.

Thanks.

It looks like the conflicting revisions are perhaps not getting tombstoned as part of your resolution method. Please check out this example .

Thanks @priya.rajagopal!

If I follow that example it works like a charm when there are changes in local and remote with the same document. The problem then is to know how to detect and solve the other two cases of conflicts:

-Local User removes a document that Remote User has made a change on it.
-Remote User removes a document that Local User has made a change on it.

Because the tombstones seems that are not treated as conflicts … Or am I missing something?

This is a limitation of the revision-tracking mechanism used by Couchbase Mobile (which originates in CouchDB.) Because tombstone revisions are used both for regular document deletion and to close off a conflicting branch, a deletion is never seen as part of a conflict; it automatically ‘loses’.

If this is a critical need for your app, there are probably workarounds. For example, you can add custom properties to a tombstone (by creating a new MutableRevision and setting its deleted property to true, along with your other properties), which can provide an informal schema for letting you distinguish the two types of tombstones. You’d still need to scan the revision tree yourself to detect a situation where two tombstones were in conflict.

Thanks @jens!

I though that it was my fault because I was missing something… I will try with your approach!

Hi jens,

You stated that “a deletion is never seen as part of a conflict; it automatically ‘loses’” I guess we are encountering a similar issue, I’m asking to be certain.

We have a document whose conflicts weren’t resolved in the past. When we try to delete latest revision, it increments its revision, set as deleted but after that one of the previous revisions (most probably the one with conflict occurred) promoted as winning revision. I got these information from sync gateway logs.

Is my assumption correct or some different reasons related with this issue?

Thanks.

Yes, that’s correct. To resolve the conflict you need to delete all but one of the conflicting revisions.

Yes. When you tombstone a revision/ delete it, one of the previous conflicting revisions gets promoted to be the winning revision. That’s how it is on the SGW but pretty sure its on CBL as well but @Jens can confirm.