Skip to content

Commit 6e08880

Browse files
committed
⚡️ Avoid committing no-ops to the database
This change checks for no-ops just before committing, and avoids writing them to the database. Motivation ---------- Sometimes we have situations where multiple clients try to make an identical change to the same document at the same time (if multiple clients are trying to keep two documents in sync, for example). When this happens, this can result in a `O(n^2)` retry cascade, since the requests will all keep retrying, with only a single request succeeding at a time, even if those ops are all no-ops. Solution -------- This change adds a new special `ERR_NO_OP` error code. If the server detects a no-op, it will skip committing, and early-return this error code, which takes the request out of the retry queue. The client then has some special handling for this case, where it will: 1. Fetch from remote to get up-to-date with any potential conflicting ops 2. Then acknowledge the in-flight op, **without** bumping the version (since the op wasn't committed) Note that if `$fixup()` has been used, we **do not** early-return a no-op error, even if the fixup results in a no-op, since the client would be left in an inconsistent state without the fixup ops being returned and applied.
1 parent cc58b8a commit 6e08880

File tree

6 files changed

+118
-1
lines changed

6 files changed

+118
-1
lines changed

lib/backend.js

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function Backend(options) {
4343
'new Backend({doNotForwardSendPresenceErrorsToClient: true})\n\n'
4444
);
4545
}
46+
this.doNotCommitNoOps = !!options.doNotCommitNoOps;
4647

4748
// Map from event name to a list of middleware
4849
this.middleware = Object.create(null);

lib/client/doc.js

+9
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,15 @@ Doc.prototype._handleSubscribe = function(error, snapshot) {
328328

329329
Doc.prototype._handleOp = function(err, message) {
330330
if (err) {
331+
if (err.code === ERROR_CODE.ERR_NO_OP && message.seq === this.inflightOp.seq) {
332+
// Our op was a no-op, either because we submitted a no-op, or - more
333+
// likely - because our op was transformed into a no-op by the server
334+
// because of a similar remote op. In this case, the server has avoided
335+
// committing the op to the database, and we should just clear the in-flight
336+
// op and call the callbacks. However, let's first catch ourselves up to
337+
// the remote, so that we're in a nice consistent state
338+
return this.fetch(this._clearInflightOp.bind(this));
339+
}
331340
if (this.inflightOp) {
332341
return this._rollback(err);
333342
}

lib/error.js

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ ShareDBError.CODES = {
3535
ERR_MAX_SUBMIT_RETRIES_EXCEEDED: 'ERR_MAX_SUBMIT_RETRIES_EXCEEDED',
3636
ERR_MESSAGE_BADLY_FORMED: 'ERR_MESSAGE_BADLY_FORMED',
3737
ERR_MILESTONE_ARGUMENT_INVALID: 'ERR_MILESTONE_ARGUMENT_INVALID',
38+
ERR_NO_OP: 'ERR_NO_OP',
3839
ERR_OP_ALREADY_SUBMITTED: 'ERR_OP_ALREADY_SUBMITTED',
3940
ERR_OP_NOT_ALLOWED_IN_PROJECTION: 'ERR_OP_NOT_ALLOWED_IN_PROJECTION',
4041
ERR_OP_SUBMIT_REJECTED: 'ERR_OP_SUBMIT_REJECTED',

lib/protocol.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module.exports = {
22
major: 1,
3-
minor: 1,
3+
minor: 2,
44
checkAtLeast: checkAtLeast
55
};
66

lib/submit-request.js

+20
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ var ot = require('./ot');
22
var projections = require('./projections');
33
var ShareDBError = require('./error');
44
var types = require('./types');
5+
const protocol = require('./protocol');
56

67
var ERROR_CODE = ShareDBError.CODES;
78

@@ -204,6 +205,19 @@ SubmitRequest.prototype.commit = function(callback) {
204205
};
205206
}
206207

208+
var skipNoOp = backend.doNotCommitNoOps &&
209+
protocol.checkAtLeast(request.agent.protocol, '1.2') &&
210+
request.op.op &&
211+
request.op.op.length === 0 &&
212+
request._fixupOps.length === 0;
213+
214+
if (skipNoOp) {
215+
// The op is a no-op, either because it was submitted as such, or - more
216+
// likely - because it was transformed into one. Let's avoid committing it
217+
// and tell the client.
218+
return callback(request.noOpError());
219+
}
220+
207221
// Try committing the operation and snapshot to the database atomically
208222
backend.db.commit(
209223
request.collection,
@@ -361,3 +375,9 @@ SubmitRequest.prototype.maxRetriesError = function() {
361375
'Op submit failed. Exceeded max submit retries of ' + this.maxRetries
362376
);
363377
};
378+
SubmitRequest.prototype.noOpError = function() {
379+
return new ShareDBError(
380+
ERROR_CODE.ERR_NO_OP,
381+
'Op is a no-op. Skipping commit.'
382+
);
383+
};

test/client/submit.js

+86
Original file line numberDiff line numberDiff line change
@@ -1290,6 +1290,92 @@ module.exports = function() {
12901290
});
12911291
});
12921292

1293+
it('commits both of two identical ops submitted from different clients by default', function(done) {
1294+
var backend = this.backend;
1295+
backend.doNotCommitNoOps = false;
1296+
var doc1 = backend.connect().get('dogs', id);
1297+
var doc2 = backend.connect().get('dogs', id);
1298+
var op = [{p: ['tricks', 0], ld: 'fetch'}];
1299+
1300+
async.series([
1301+
doc1.create.bind(doc1, {tricks: ['fetch', 'sit']}),
1302+
doc2.fetch.bind(doc2),
1303+
async.parallel.bind(async.parallel, [
1304+
doc1.submitOp.bind(doc1, op),
1305+
doc2.submitOp.bind(doc2, op)
1306+
]),
1307+
function(next) {
1308+
expect(doc1.data).to.eql({tricks: ['sit']});
1309+
expect(doc2.data).to.eql({tricks: ['sit']});
1310+
next();
1311+
},
1312+
function(next) {
1313+
backend.db.getOps('dogs', id, 0, null, {}, function(error, ops) {
1314+
if (error) return next(error);
1315+
// Expect:
1316+
// v0: create
1317+
// v1: update
1318+
// v2: duplicate update
1319+
expect(ops).to.have.length(3);
1320+
next();
1321+
});
1322+
}
1323+
], done);
1324+
});
1325+
1326+
it('only commits one of two identical ops submitted from different clients', function(done) {
1327+
var backend = this.backend;
1328+
backend.doNotCommitNoOps = true;
1329+
var doc1 = backend.connect().get('dogs', id);
1330+
var doc2 = backend.connect().get('dogs', id);
1331+
var op = [{p: ['tricks', 0], ld: 'fetch'}];
1332+
1333+
async.series([
1334+
doc1.create.bind(doc1, {tricks: ['fetch', 'sit']}),
1335+
doc2.fetch.bind(doc2),
1336+
async.parallel.bind(async.parallel, [
1337+
doc1.submitOp.bind(doc1, op),
1338+
doc2.submitOp.bind(doc2, op)
1339+
]),
1340+
function(next) {
1341+
expect(doc1.data).to.eql({tricks: ['sit']});
1342+
expect(doc2.data).to.eql({tricks: ['sit']});
1343+
next();
1344+
},
1345+
function(next) {
1346+
backend.db.getOps('dogs', id, 0, null, {}, function(error, ops) {
1347+
if (error) return next(error);
1348+
// Expect:
1349+
// v0: create
1350+
// v1: update
1351+
// no duplicate update
1352+
expect(ops).to.have.length(2);
1353+
next();
1354+
});
1355+
}
1356+
], done);
1357+
});
1358+
1359+
it('fixes up even if an op is fixed up to become a no-op', function(done) {
1360+
var backend = this.backend;
1361+
backend.doNotCommitNoOps = true;
1362+
var doc = backend.connect().get('dogs', id);
1363+
1364+
backend.use('apply', function(req, next) {
1365+
req.$fixup([{p: ['fixme'], od: true}]);
1366+
next();
1367+
});
1368+
1369+
async.series([
1370+
doc.create.bind(doc, {}),
1371+
doc.submitOp.bind(doc, [{p: ['fixme'], oi: true}]),
1372+
function(next) {
1373+
expect(doc.data).to.eql({});
1374+
next();
1375+
}
1376+
], done);
1377+
});
1378+
12931379
describe('type.deserialize', function() {
12941380
it('can create a new doc', function(done) {
12951381
var doc = this.backend.connect().get('dogs', id);

0 commit comments

Comments
 (0)