Skip to content

Commit 50b40bb

Browse files
authored
Merge pull request #682 from share/avoid-committing-no-ops
⚡️ Avoid committing no-ops to the database
2 parents 6a2c194 + f91d42c commit 50b40bb

File tree

8 files changed

+163
-1
lines changed

8 files changed

+163
-1
lines changed

docs/api/backend.md

+13
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,19 @@ Optional
113113
114114
{: .d-inline-block }
115115

116+
`doNotCommitNoOps` -- boolean
117+
118+
Optional
119+
{: .label .label-grey }
120+
121+
> Default: `false`
122+
123+
> If set to `true`, will avoid committing no-ops to the database. Clients that submit no-ops
124+
> (or ops that are transformed to no-ops) will have their ops acknowledged as if they were
125+
> committed, but the doc version will not increment.
126+
127+
{: .d-inline-block }
128+
116129
`errorHandler` -- Function
117130

118131
Optional

docs/api/sharedb-error.md

+6
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,9 @@ Representation of an error, with a machine-parsable [code](#error-codes).
100100
### `ERR_TYPE_CANNOT_BE_PROJECTED`
101101
102102
> The document's type cannot be projected. [`json0`]({{ site.baseurl }}{% link types/json0.md %}) is currently the only type that supports projections.
103+
104+
### `ERR_NO_OP`
105+
106+
> The submitted op resulted in a no-op, possibly after transformation by a remote op.
107+
108+
> This is normal behavior and the client should swallow this error without bumping doc version.

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

+19
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+
var protocol = require('./protocol');
56

67
var ERROR_CODE = ShareDBError.CODES;
78

@@ -152,6 +153,18 @@ SubmitRequest.prototype.submit = function(callback) {
152153
err = request._transformOp(ops);
153154
if (err) return callback(err);
154155

156+
var skipNoOp = backend.doNotCommitNoOps &&
157+
protocol.checkAtLeast(request.agent.protocol, '1.2') &&
158+
request.op.op &&
159+
request.op.op.length === 0;
160+
161+
if (skipNoOp) {
162+
// The op is a no-op, either because it was submitted as such, or - more
163+
// likely - because it was transformed into one. Let's avoid committing it
164+
// and tell the client.
165+
return callback(request.noOpError());
166+
}
167+
155168
if (op.v !== snapshot.v) {
156169
// This shouldn't happen, but is just a final sanity check to make
157170
// sure we have transformed the op to the current snapshot version
@@ -361,3 +374,9 @@ SubmitRequest.prototype.maxRetriesError = function() {
361374
'Op submit failed. Exceeded max submit retries of ' + this.maxRetries
362375
);
363376
};
377+
SubmitRequest.prototype.noOpError = function() {
378+
return new ShareDBError(
379+
ERROR_CODE.ERR_NO_OP,
380+
'Op is a no-op. Skipping apply.'
381+
);
382+
};

test/client/submit.js

+113
Original file line numberDiff line numberDiff line change
@@ -1290,6 +1290,119 @@ 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('can submit a new op after getting a no-op', function(done) {
1360+
var backend = this.backend;
1361+
backend.doNotCommitNoOps = true;
1362+
var doc1 = backend.connect().get('dogs', id);
1363+
var doc2 = backend.connect().get('dogs', id);
1364+
var op = [{p: ['tricks', 0], ld: 'fetch'}];
1365+
1366+
async.series([
1367+
doc1.create.bind(doc1, {tricks: ['fetch', 'sit']}),
1368+
doc2.fetch.bind(doc2),
1369+
async.parallel.bind(async.parallel, [
1370+
doc1.submitOp.bind(doc1, op),
1371+
doc2.submitOp.bind(doc2, op)
1372+
]),
1373+
function(next) {
1374+
expect(doc1.data).to.eql({tricks: ['sit']});
1375+
expect(doc2.data).to.eql({tricks: ['sit']});
1376+
next();
1377+
},
1378+
doc1.submitOp.bind(doc1, [{p: ['tricks', 0], li: 'play dead'}]),
1379+
function(next) {
1380+
expect(doc1.data.tricks).to.eql(['play dead', 'sit']);
1381+
next();
1382+
}
1383+
], done);
1384+
});
1385+
1386+
it('fixes up even if an op is fixed up to become a no-op', function(done) {
1387+
var backend = this.backend;
1388+
backend.doNotCommitNoOps = true;
1389+
var doc = backend.connect().get('dogs', id);
1390+
1391+
backend.use('apply', function(req, next) {
1392+
req.$fixup([{p: ['fixme'], od: true}]);
1393+
next();
1394+
});
1395+
1396+
async.series([
1397+
doc.create.bind(doc, {}),
1398+
doc.submitOp.bind(doc, [{p: ['fixme'], oi: true}]),
1399+
function(next) {
1400+
expect(doc.data).to.eql({});
1401+
next();
1402+
}
1403+
], done);
1404+
});
1405+
12931406
describe('type.deserialize', function() {
12941407
it('can create a new doc', function(done) {
12951408
var doc = this.backend.connect().get('dogs', id);

0 commit comments

Comments
 (0)