Skip to content

Instantly share code, notes, and snippets.

@muhqu
Last active August 29, 2015 14:16
Show Gist options
  • Save muhqu/b633de9a3ff3e3f87bcf to your computer and use it in GitHub Desktop.
Save muhqu/b633de9a3ff3e3f87bcf to your computer and use it in GitHub Desktop.
CouchDB Replication Conflict Management

I had a misconception about couchdb's conflict management. I was under the impression that couchdb handles document deletes in the same way as document updates. In fact a delete creates just another revison that marks the document as deleted, also known as the tombstone revision.

When a document gets modified after replication, on both databases (source and target), and the document gets replicated again from source to target, it will be in conflict state. This is also true for a document that has been updated on the target and deleted from source. The document's tombstone revision gets replicated, but as couchdb's way of resolving conflicts is to 'delete' the unwanted revision, the just replicated tombstone revison is implicitly considered as the unwanted (or loosing) revision in this conflict. So the conflict is implicitly resolved by picking the revision that has not been deleted. The document in the target db doesn't show any _conflicts field but it shows the replicated tombstone rev in the _deleted_conflicts field.

The problem here is that you don't know whether the revs in _deleted_conflicts are from this very scenario or from app specific conflict resolving. Please not that _deleted_conflicts can even appear when a new document gets created with the id of another deleted document.

Feature: CouchDB Replication
Background:
Given an empty CouchDB "a"
And an empty CouchDB "b"
Scenario: Delete replicates
When document "bar" with field "name" set to "bar" is added to CouchDB "a"
And CouchDB "a" is replicated to CouchDB "b"
Then CouchDB "b" should have document "bar" with field "name" set to "bar"
When document "bar" gets deleted from CouchDB "a"
And CouchDB "a" is replicated to CouchDB "b"
Then CouchDB "b" should have no document "bar"
Scenario: Updates on source and target cause conflict
When document "foobar" with field "name" set to "Foobar" is added to CouchDB "a"
And CouchDB "a" is replicated to CouchDB "b"
Then CouchDB "b" should have document "foobar" with field "name" set to "Foobar"
When document "foobar" in CouchDB "b" is updated with field "name" set to "Foo Bar"
And document "foobar" in CouchDB "a" is updated with field "name" set to "Bar Foo"
And CouchDB "a" is replicated to CouchDB "b"
Then document "foobar" in CouchDB "b" should indicate a conflict
Scenario: Delete on source causes implicit conflict resolution
When document "foobar" with field "name" set to "Foobar" is added to CouchDB "a"
And CouchDB "a" is replicated to CouchDB "b"
Then CouchDB "b" should have document "foobar" with field "name" set to "Foobar"
When document "foobar" in CouchDB "b" is updated with field "name" set to "Foo Bar"
And document "foobar" gets deleted from CouchDB "a"
And CouchDB "a" is replicated to CouchDB "b"
Then document "foobar" in CouchDB "b" does NOT indicate a conflict
But document "foobar" in CouchDB "b" does indicate a deleted conflict
Scenario: Update on source with delete on target causes resurection
When document "foobar" with field "name" set to "Foobar" is added to CouchDB "a"
And CouchDB "a" is replicated to CouchDB "b"
Then CouchDB "b" should have document "foobar" with field "name" set to "Foobar"
When document "foobar" gets deleted from CouchDB "b"
And document "foobar" in CouchDB "a" is updated with field "name" set to "Foo Bar"
And CouchDB "a" is replicated to CouchDB "b"
Then CouchDB "b" does have document "foobar" with field "name" set to "Foo Bar"
And document "foobar" in CouchDB "b" does NOT indicate a conflict
But document "foobar" in CouchDB "b" does indicate a deleted conflict
Scenario: Deleted Conflict without replication
When document "foobar" with field "name" set to "Foobar" is added to CouchDB "a"
And document "foobar" gets deleted from CouchDB "a"
Then CouchDB "a" should have no document "foobar"
When document "foobar" with field "name" set to "Foo Bar" is added to CouchDB "a"
Then document "foobar" in CouchDB "a" does NOT indicate a conflict
But document "foobar" in CouchDB "a" does indicate a deleted conflict
var request = require('request');
var myStepDefinitionsWrapper = function () {
var dbName = function(str){
return "deletion_conflicts_test_"+str;
}
var baseUrl = process.env['COUCH_DB'] || 'http://127.0.0.1:5984'
this.Given(/^an empty CouchDB "([^"]*)"$/, function(arg1, callback) {
request({
method: 'DELETE',
uri: baseUrl+"/"+dbName(arg1),
json: true
}, function (error, response, json) {
if (error) callback.fail(error)
else {
request({
method: 'PUT',
uri: baseUrl+"/"+dbName(arg1),
json: true
}, function (error, response, json) {
if (error) callback.fail(error)
else if (json && !json.ok) callback.fail("unexpected response: "+ JSON.stringify(json));
else callback()
});
}
});
});
this.When(/^document "([^"]*)" with field "([^"]*)" set to "([^"]*)" is added to CouchDB "([^"]*)"$/, function(arg1, arg2, arg3, arg4, callback) {
var data = { _id: arg1 }
data[arg2] = arg3;
request({
method: 'PUT',
uri: baseUrl+"/"+dbName(arg4)+"/"+arg1,
json: data
}, function (error, response, json) {
if (error) callback.fail(error)
else if (json && !json.ok) callback.fail("unexpected response: "+ JSON.stringify(json));
else callback()
});
});
this.When(/^CouchDB "([^"]*)" is replicated to CouchDB "([^"]*)"$/, function(arg1, arg2, callback) {
// express the regexp above with the code you wish you had
request({
method: 'POST',
uri: baseUrl+"/_replicate",
json: {
source: dbName(arg1),
target: dbName(arg2)
}
}, function (error, response, json) {
if (error) callback.fail(error)
else if (json && !json.ok) callback.fail("unexpected response: "+ JSON.stringify(json));
else callback()
});
});
this.Then(/^CouchDB "([^"]*)" (?:should|does) have document "([^"]*)" with field "([^"]*)" set to "([^"]*)"$/, function(arg1, arg2, arg3, arg4, callback) {
request({
method: 'GET',
uri: baseUrl+"/"+dbName(arg1)+"/"+arg2,
qs: { conflicts: "true" },
json: true
}, function (error, response, json) {
if (error) callback.fail(error)
else if (!json) callback.fail("unexpected response: "+ JSON.stringify(response));
else if (json._id != arg2) callback.fail("invalid _id in doc: "+ JSON.stringify(json));
else if (json[arg3] != arg4) callback.fail("field "+arg3+" set to "+JSON.stringify(json[arg3])+". expected "+JSON.stringify(arg4));
else callback();
});
});
this.When(/^document "([^"]*)" gets deleted from CouchDB "([^"]*)"$/, function(arg1, arg2, callback) {
request({
method: 'GET',
uri: baseUrl+"/"+dbName(arg2)+"/"+arg1,
qs: { conflicts: "true" },
json: true
}, function (error, response, json) {
if (error) callback.fail(error)
else if (!json) callback.fail("unexpected response: "+ JSON.stringify(response));
else if (json._id != arg1) callback.fail("invalid _id in doc: "+ JSON.stringify(json));
else if (json._conflicts) callback.fail("unexpected _conflicts in doc: "+ JSON.stringify(json));
else {
request({
method: 'DELETE',
uri: baseUrl+"/"+dbName(arg2)+"/"+arg1,
qs: { rev: json._rev },
json: true
}, function (error, response, json) {
if (error) callback.fail(error)
else if (!json || !json.ok) callback.fail("unexpected response: "+ JSON.stringify(response));
else callback();
});
}
});
});
this.Then(/^CouchDB "([^"]*)" (?:should|does) have no document "([^"]*)"$/, function(arg1, arg2, callback) {
request({
method: 'GET',
uri: baseUrl+"/"+dbName(arg2)+"/"+arg1,
qs: { conflicts: "true" },
json: true
}, function (error, response, json) {
if (error) callback.fail(error)
else if (!json || json.error != "not_found") callback.fail("unexpected response: "+ JSON.stringify(response));
else callback();
});
});
this.When(/^document "([^"]*)" in CouchDB "([^"]*)" is updated with field "([^"]*)" set to "([^"]*)"$/, function(arg1, arg2, arg3, arg4, callback) {
request({
method: 'GET',
uri: baseUrl+"/"+dbName(arg2)+"/"+arg1,
qs: { conflicts: "true" },
json: true
}, function (error, response, json) {
if (error) callback.fail(error)
else if (!json) callback.fail("unexpected response: "+ JSON.stringify(response));
else if (json._id != arg1) callback.fail("invalid _id in doc: "+ JSON.stringify(json));
else if (json._conflicts) callback.fail("unexpected _conflicts in doc: "+ JSON.stringify(json));
else {
var updatedJson = json;
updatedJson[arg3] = arg4
request({
method: 'PUT',
uri: baseUrl+"/"+dbName(arg2)+"/"+arg1,
json: updatedJson
}, function (error, response, json) {
if (error) callback.fail(error)
else if (!json || !json.ok) callback.fail("unexpected response: "+ JSON.stringify(response));
else callback();
});
}
});
});
this.Then(/^document "([^"]*)" in CouchDB "([^"]*)" does indicate a deleted conflict$/, function(arg1, arg2, callback) {
request({
method: 'GET',
uri: baseUrl+"/"+dbName(arg2)+"/"+arg1,
qs: { deleted_conflicts: "true", conflicts: "true" },
json: true
}, function (error, response, json) {
if (error) callback.fail(error)
else if (!json) callback.fail("unexpected response: "+ JSON.stringify(response));
else if (json._id != arg1) callback.fail("invalid _id in doc: "+ JSON.stringify(json));
else if (!json._deleted_conflicts) callback.fail("expected _deleted_conflicts not found in doc: "+ JSON.stringify(json));
else callback();
});
});
this.Then(/^document "([^"]*)" in CouchDB "([^"]*)" (?:should|does)( NOT)? indicate a conflict/, function(arg1, arg2, arg3, callback) {
not = !!arg3
request({
method: 'GET',
uri: baseUrl+"/"+dbName(arg2)+"/"+arg1,
qs: { conflicts: "true" },
json: true
}, function (error, response, json) {
if (error) callback.fail(error)
else if (!json) callback.fail("unexpected response: "+ JSON.stringify(response));
else if (json._id != arg1) callback.fail("invalid _id in doc: "+ JSON.stringify(json));
else if (not && json._conflicts) callback.fail("expected NO _conflicts, but found in doc: "+ JSON.stringify(json));
else if (!not && !json._conflicts) callback.fail("expected _conflicts not found in doc: "+ JSON.stringify(json));
else callback();
});
});
}
module.exports = myStepDefinitionsWrapper;
#!/bin/sh
# npm install cucumber@latest
# npm install request@latest
COUCH_DB=http://127.0.0.1:15984 cucumber.js 01_deletion_conflicts_test.feature --require 02_step_definitions.js
..............................................
5 scenarios (5 passed)
46 steps (46 passed)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment