mirror of
https://github.com/overleaf/overleaf.git
synced 2024-12-28 12:23:31 +00:00
Merge pull request #177 from overleaf/jpa-bg-issue-3291
clean up join/leave handling
This commit is contained in:
commit
f036a7098b
5 changed files with 204 additions and 3 deletions
|
@ -12,7 +12,7 @@ const rclient = require('redis-sharelatex').createClient(
|
||||||
)
|
)
|
||||||
const Keys = settings.redis.documentupdater.key_schema
|
const Keys = settings.redis.documentupdater.key_schema
|
||||||
|
|
||||||
module.exports = {
|
const DocumentUpdaterManager = {
|
||||||
getDocument(project_id, doc_id, fromVersion, callback) {
|
getDocument(project_id, doc_id, fromVersion, callback) {
|
||||||
const timer = new metrics.Timer('get-document')
|
const timer = new metrics.Timer('get-document')
|
||||||
const url = `${settings.apis.documentupdater.url}/project/${project_id}/doc/${doc_id}?fromVersion=${fromVersion}`
|
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) {
|
flushProjectToMongoAndDelete(project_id, callback) {
|
||||||
// this method is called when the last connected user leaves the project
|
// this method is called when the last connected user leaves the project
|
||||||
logger.log({ project_id }, 'deleting project from document updater')
|
logger.log({ project_id }, 'deleting project from document updater')
|
||||||
|
@ -141,3 +146,5 @@ module.exports = {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports = DocumentUpdaterManager
|
||||||
|
|
|
@ -45,6 +45,7 @@ module.exports = Router = {
|
||||||
} else if (
|
} else if (
|
||||||
[
|
[
|
||||||
'not authorized',
|
'not authorized',
|
||||||
|
'joinLeaveEpoch mismatch',
|
||||||
'doc updater could not load requested ops',
|
'doc updater could not load requested ops',
|
||||||
'no project_id found on client'
|
'no project_id found on client'
|
||||||
].includes(error.message)
|
].includes(error.message)
|
||||||
|
@ -95,6 +96,8 @@ module.exports = Router = {
|
||||||
// init client context, we may access it in Router._handleError before
|
// init client context, we may access it in Router._handleError before
|
||||||
// setting any values
|
// setting any values
|
||||||
client.ol_context = {}
|
client.ol_context = {}
|
||||||
|
// bail out from joinDoc when a parallel joinDoc or leaveDoc is running
|
||||||
|
client.joinLeaveEpoch = 0
|
||||||
|
|
||||||
if (client) {
|
if (client) {
|
||||||
client.on('error', function (err) {
|
client.on('error', function (err) {
|
||||||
|
|
|
@ -158,6 +158,7 @@ module.exports = WebsocketController = {
|
||||||
return callback()
|
return callback()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const joinLeaveEpoch = ++client.joinLeaveEpoch
|
||||||
metrics.inc('editor.join-doc')
|
metrics.inc('editor.join-doc')
|
||||||
const { project_id, user_id, is_restricted_user } = client.ol_context
|
const { project_id, user_id, is_restricted_user } = client.ol_context
|
||||||
if (!project_id) {
|
if (!project_id) {
|
||||||
|
@ -168,10 +169,23 @@ module.exports = WebsocketController = {
|
||||||
'client joining doc'
|
'client joining doc'
|
||||||
)
|
)
|
||||||
|
|
||||||
AuthorizationManager.assertClientCanViewProject(client, function (error) {
|
WebsocketController._assertClientAuthorization(client, doc_id, function (
|
||||||
|
error
|
||||||
|
) {
|
||||||
if (error) {
|
if (error) {
|
||||||
return callback(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
|
// ensure the per-doc applied-ops channel is subscribed before sending the
|
||||||
// doc to the client, so that no events are missed.
|
// doc to the client, so that no events are missed.
|
||||||
RoomManager.joinDoc(client, doc_id, function (error) {
|
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) {
|
leaveDoc(client, doc_id, callback) {
|
||||||
// client may have disconnected, but we have to cleanup internal state.
|
// client may have disconnected, but we have to cleanup internal state.
|
||||||
|
client.joinLeaveEpoch++
|
||||||
metrics.inc('editor.leave-doc')
|
metrics.inc('editor.leave-doc')
|
||||||
const { project_id, user_id } = client.ol_context
|
const { project_id, user_id } = client.ol_context
|
||||||
logger.log(
|
logger.log(
|
||||||
|
|
|
@ -38,6 +38,7 @@ describe('WebsocketController', function () {
|
||||||
id: (this.client_id = 'mock-client-id-123'),
|
id: (this.client_id = 'mock-client-id-123'),
|
||||||
publicId: `other-id-${Math.random()}`,
|
publicId: `other-id-${Math.random()}`,
|
||||||
ol_context: {},
|
ol_context: {},
|
||||||
|
joinLeaveEpoch: 0,
|
||||||
join: sinon.stub(),
|
join: sinon.stub(),
|
||||||
leave: 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.project_id = this.project_id
|
||||||
this.client.ol_context.is_restricted_user = false
|
this.client.ol_context.is_restricted_user = false
|
||||||
this.AuthorizationManager.addAccessToDoc = sinon.stub()
|
this.AuthorizationManager.addAccessToDoc = sinon.stub().yields()
|
||||||
this.AuthorizationManager.assertClientCanViewProject = sinon
|
this.AuthorizationManager.assertClientCanViewProject = sinon
|
||||||
.stub()
|
.stub()
|
||||||
.callsArgWith(1, null)
|
.callsArgWith(1, null)
|
||||||
|
this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon
|
||||||
|
.stub()
|
||||||
|
.callsArgWith(2, null)
|
||||||
this.DocumentUpdaterManager.getDocument = sinon
|
this.DocumentUpdaterManager.getDocument = sinon
|
||||||
.stub()
|
.stub()
|
||||||
.callsArgWith(
|
.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 () {
|
it('should check that the client is authorized to view the project', function () {
|
||||||
return this.AuthorizationManager.assertClientCanViewProject
|
return this.AuthorizationManager.assertClientCanViewProject
|
||||||
.calledWith(this.client)
|
.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 () {
|
describe('when the client disconnects while RoomManager.joinDoc is running', function () {
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
this.RoomManager.joinDoc = (client, doc_id, cb) => {
|
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 () {
|
it('should remove the client from the doc_id room', function () {
|
||||||
return this.RoomManager.leaveDoc
|
return this.RoomManager.leaveDoc
|
||||||
.calledWith(this.client, this.doc_id)
|
.calledWith(this.client, this.doc_id)
|
||||||
|
|
|
@ -16,6 +16,7 @@ module.exports = MockClient = class MockClient {
|
||||||
this.disconnect = sinon.stub()
|
this.disconnect = sinon.stub()
|
||||||
this.id = idCounter++
|
this.id = idCounter++
|
||||||
this.publicId = idCounter++
|
this.publicId = idCounter++
|
||||||
|
this.joinLeaveEpoch = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {}
|
disconnect() {}
|
||||||
|
|
Loading…
Reference in a new issue