Merge pull request #177 from overleaf/jpa-bg-issue-3291

clean up join/leave handling
This commit is contained in:
Jakob Ackermann 2020-08-12 13:04:29 +02:00 committed by GitHub
commit f036a7098b
5 changed files with 204 additions and 3 deletions

View file

@ -12,7 +12,7 @@ const rclient = require('redis-sharelatex').createClient(
)
const Keys = settings.redis.documentupdater.key_schema
module.exports = {
const DocumentUpdaterManager = {
getDocument(project_id, doc_id, fromVersion, callback) {
const timer = new metrics.Timer('get-document')
const url = `${settings.apis.documentupdater.url}/project/${project_id}/doc/${doc_id}?fromVersion=${fromVersion}`
@ -63,6 +63,11 @@ module.exports = {
})
},
checkDocument(project_id, doc_id, callback) {
// in this call fromVersion = -1 means get document without docOps
DocumentUpdaterManager.getDocument(project_id, doc_id, -1, callback)
},
flushProjectToMongoAndDelete(project_id, callback) {
// this method is called when the last connected user leaves the project
logger.log({ project_id }, 'deleting project from document updater')
@ -141,3 +146,5 @@ module.exports = {
})
}
}
module.exports = DocumentUpdaterManager

View file

@ -45,6 +45,7 @@ module.exports = Router = {
} else if (
[
'not authorized',
'joinLeaveEpoch mismatch',
'doc updater could not load requested ops',
'no project_id found on client'
].includes(error.message)
@ -95,6 +96,8 @@ module.exports = Router = {
// init client context, we may access it in Router._handleError before
// setting any values
client.ol_context = {}
// bail out from joinDoc when a parallel joinDoc or leaveDoc is running
client.joinLeaveEpoch = 0
if (client) {
client.on('error', function (err) {

View file

@ -158,6 +158,7 @@ module.exports = WebsocketController = {
return callback()
}
const joinLeaveEpoch = ++client.joinLeaveEpoch
metrics.inc('editor.join-doc')
const { project_id, user_id, is_restricted_user } = client.ol_context
if (!project_id) {
@ -168,10 +169,23 @@ module.exports = WebsocketController = {
'client joining doc'
)
AuthorizationManager.assertClientCanViewProject(client, function (error) {
WebsocketController._assertClientAuthorization(client, doc_id, function (
error
) {
if (error) {
return callback(error)
}
if (client.disconnected) {
metrics.inc('editor.join-doc.disconnected', 1, {
status: 'after-client-auth-check'
})
// the client will not read the response anyways
return callback()
}
if (joinLeaveEpoch !== client.joinLeaveEpoch) {
// another joinDoc or leaveDoc rpc overtook us
return callback(new Error('joinLeaveEpoch mismatch'))
}
// ensure the per-doc applied-ops channel is subscribed before sending the
// doc to the client, so that no events are missed.
RoomManager.joinDoc(client, doc_id, function (error) {
@ -279,8 +293,42 @@ module.exports = WebsocketController = {
})
},
_assertClientAuthorization(client, doc_id, callback) {
// Check for project-level access first
AuthorizationManager.assertClientCanViewProject(client, function (error) {
if (error) {
return callback(error)
}
// Check for doc-level access next
AuthorizationManager.assertClientCanViewProjectAndDoc(
client,
doc_id,
function (error) {
if (error) {
// No cached access, check docupdater
const { project_id } = client.ol_context
DocumentUpdaterManager.checkDocument(project_id, doc_id, function (
error
) {
if (error) {
return callback(error)
} else {
// Success
AuthorizationManager.addAccessToDoc(client, doc_id, callback)
}
})
} else {
// Access already cached
callback()
}
}
)
})
},
leaveDoc(client, doc_id, callback) {
// client may have disconnected, but we have to cleanup internal state.
client.joinLeaveEpoch++
metrics.inc('editor.leave-doc')
const { project_id, user_id } = client.ol_context
logger.log(

View file

@ -38,6 +38,7 @@ describe('WebsocketController', function () {
id: (this.client_id = 'mock-client-id-123'),
publicId: `other-id-${Math.random()}`,
ol_context: {},
joinLeaveEpoch: 0,
join: sinon.stub(),
leave: sinon.stub()
}
@ -503,10 +504,13 @@ describe('WebsocketController', function () {
this.client.ol_context.project_id = this.project_id
this.client.ol_context.is_restricted_user = false
this.AuthorizationManager.addAccessToDoc = sinon.stub()
this.AuthorizationManager.addAccessToDoc = sinon.stub().yields()
this.AuthorizationManager.assertClientCanViewProject = sinon
.stub()
.callsArgWith(1, null)
this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon
.stub()
.callsArgWith(2, null)
this.DocumentUpdaterManager.getDocument = sinon
.stub()
.callsArgWith(
@ -531,6 +535,10 @@ describe('WebsocketController', function () {
)
})
it('should inc the joinLeaveEpoch', function () {
expect(this.client.joinLeaveEpoch).to.equal(1)
})
it('should check that the client is authorized to view the project', function () {
return this.AuthorizationManager.assertClientCanViewProject
.calledWith(this.client)
@ -739,6 +747,136 @@ describe('WebsocketController', function () {
})
})
describe('when the client disconnects while auth checks are running', function () {
beforeEach(function (done) {
this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
new Error()
)
this.DocumentUpdaterManager.checkDocument = (
project_id,
doc_id,
cb
) => {
this.client.disconnected = true
cb()
}
this.WebsocketController.joinDoc(
this.client,
this.doc_id,
-1,
this.options,
(...args) => {
this.callback(...args)
done()
}
)
})
it('should call the callback with no details', function () {
expect(this.callback.called).to.equal(true)
expect(this.callback.args[0]).to.deep.equal([])
})
it('should increment the editor.join-doc.disconnected metric with a status', function () {
expect(
this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
status: 'after-client-auth-check'
})
).to.equal(true)
})
it('should not get the document', function () {
expect(this.DocumentUpdaterManager.getDocument.called).to.equal(false)
})
})
describe('when the client starts a parallel joinDoc request', function () {
beforeEach(function (done) {
this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
new Error()
)
this.DocumentUpdaterManager.checkDocument = (
project_id,
doc_id,
cb
) => {
this.DocumentUpdaterManager.checkDocument = sinon.stub().yields()
this.WebsocketController.joinDoc(
this.client,
this.doc_id,
-1,
{},
() => {}
)
cb()
}
this.WebsocketController.joinDoc(
this.client,
this.doc_id,
-1,
this.options,
(...args) => {
this.callback(...args)
// make sure the other joinDoc request completed
setTimeout(done, 5)
}
)
})
it('should call the callback with an error', function () {
expect(this.callback.called).to.equal(true)
expect(this.callback.args[0][0].message).to.equal(
'joinLeaveEpoch mismatch'
)
})
it('should get the document once (the parallel request wins)', function () {
expect(this.DocumentUpdaterManager.getDocument.callCount).to.equal(1)
})
})
describe('when the client starts a parallel leaveDoc request', function () {
beforeEach(function (done) {
this.RoomManager.leaveDoc = sinon.stub()
this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
new Error()
)
this.DocumentUpdaterManager.checkDocument = (
project_id,
doc_id,
cb
) => {
this.WebsocketController.leaveDoc(this.client, this.doc_id, () => {})
cb()
}
this.WebsocketController.joinDoc(
this.client,
this.doc_id,
-1,
this.options,
(...args) => {
this.callback(...args)
done()
}
)
})
it('should call the callback with an error', function () {
expect(this.callback.called).to.equal(true)
expect(this.callback.args[0][0].message).to.equal(
'joinLeaveEpoch mismatch'
)
})
it('should not get the document', function () {
expect(this.DocumentUpdaterManager.getDocument.called).to.equal(false)
})
})
describe('when the client disconnects while RoomManager.joinDoc is running', function () {
beforeEach(function () {
this.RoomManager.joinDoc = (client, doc_id, cb) => {
@ -827,6 +965,10 @@ describe('WebsocketController', function () {
)
})
it('should inc the joinLeaveEpoch', function () {
expect(this.client.joinLeaveEpoch).to.equal(1)
})
it('should remove the client from the doc_id room', function () {
return this.RoomManager.leaveDoc
.calledWith(this.client, this.doc_id)

View file

@ -16,6 +16,7 @@ module.exports = MockClient = class MockClient {
this.disconnect = sinon.stub()
this.id = idCounter++
this.publicId = idCounter++
this.joinLeaveEpoch = 0
}
disconnect() {}