Skip to content

Commit 714811b

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 945dc50 commit 714811b

File tree

5 files changed

+77
-0
lines changed

5 files changed

+77
-0
lines changed

lib/agent.js

+1
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,7 @@ Agent.prototype._submit = function(collection, id, op, callback) {
768768
// part of normal operation, since inflight ops need to be resent after
769769
// disconnect. In this case, ack the op so the client can proceed
770770
if (err.code === ERROR_CODE.ERR_OP_ALREADY_SUBMITTED) return callback(null, ack);
771+
if (err.code === ERROR_CODE.ERR_NO_OP) return callback(err, ack);
771772
return callback(err);
772773
}
773774

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/submit-request.js

+13
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,13 @@ SubmitRequest.prototype.commit = function(callback) {
204204
};
205205
}
206206

207+
if (request.op.op && request.op.op.length === 0 && request._fixupOps.length === 0) {
208+
// The op is a no-op, either because it was submitted as such, or - more
209+
// likely - because it was transformed into one. Let's avoid committing it
210+
// and tell the client.
211+
return callback(request.noOpError());
212+
}
213+
207214
// Try committing the operation and snapshot to the database atomically
208215
backend.db.commit(
209216
request.collection,
@@ -361,3 +368,9 @@ SubmitRequest.prototype.maxRetriesError = function() {
361368
'Op submit failed. Exceeded max submit retries of ' + this.maxRetries
362369
);
363370
};
371+
SubmitRequest.prototype.noOpError = function() {
372+
return new ShareDBError(
373+
ERROR_CODE.ERR_NO_OP,
374+
'Op is a no-op. Skipping commit.'
375+
);
376+
};

test/client/submit.js

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

1293+
it('only commits one of two identical ops submitted from different clients', function(done) {
1294+
var backend = this.backend;
1295+
var doc1 = backend.connect().get('dogs', id);
1296+
var doc2 = backend.connect().get('dogs', id);
1297+
var op = [{p: ['tricks', 0], ld: 'fetch'}];
1298+
1299+
async.series([
1300+
doc1.create.bind(doc1, {tricks: ['fetch', 'sit']}),
1301+
doc2.fetch.bind(doc2),
1302+
async.parallel.bind(async.parallel, [
1303+
doc1.submitOp.bind(doc1, op),
1304+
doc2.submitOp.bind(doc2, op)
1305+
]),
1306+
function(next) {
1307+
expect(doc1.data).to.eql({tricks: ['sit']});
1308+
expect(doc2.data).to.eql({tricks: ['sit']});
1309+
expect(doc1.version).to.equal(2);
1310+
expect(doc2.version).to.equal(2);
1311+
next();
1312+
},
1313+
function(next) {
1314+
backend.db.getOps('dogs', id, 0, null, {}, function(error, ops) {
1315+
if (error) return next(error);
1316+
// Expect:
1317+
// v0: create
1318+
// v1: update
1319+
// no duplicate update
1320+
expect(ops).to.have.length(2);
1321+
next();
1322+
});
1323+
}
1324+
], done);
1325+
});
1326+
1327+
it('fixes up even if an op is fixed up to become a no-op', function(done) {
1328+
var backend = this.backend;
1329+
var doc = backend.connect().get('dogs', id);
1330+
1331+
backend.use('apply', function(req, next) {
1332+
req.$fixup([{p: ['fixme'], od: true}]);
1333+
next();
1334+
});
1335+
1336+
async.series([
1337+
doc.create.bind(doc, {}),
1338+
doc.submitOp.bind(doc, [{p: ['fixme'], oi: true}]),
1339+
function(next) {
1340+
expect(doc.data).to.eql({});
1341+
next();
1342+
}
1343+
], done);
1344+
});
1345+
12931346
describe('type.deserialize', function() {
12941347
it('can create a new doc', function(done) {
12951348
var doc = this.backend.connect().get('dogs', id);

0 commit comments

Comments
 (0)