diff --git a/services/document-updater/app.coffee b/services/document-updater/app.coffee index 41cab59680..b4188292da 100644 --- a/services/document-updater/app.coffee +++ b/services/document-updater/app.coffee @@ -47,6 +47,7 @@ app.post '/project/:project_id/doc/:doc_id', HttpCont app.post '/project/:project_id/doc/:doc_id/flush', HttpController.flushDocIfLoaded app.delete '/project/:project_id/doc/:doc_id', HttpController.flushAndDeleteDoc app.delete '/project/:project_id', HttpController.deleteProject +app.post '/project/:project_id', HttpController.updateProject app.post '/project/:project_id/flush', HttpController.flushProject app.post '/project/:project_id/doc/:doc_id/change/:change_id/accept', HttpController.acceptChanges app.post '/project/:project_id/doc/:doc_id/change/accept', HttpController.acceptChanges diff --git a/services/document-updater/app/coffee/DocumentManager.coffee b/services/document-updater/app/coffee/DocumentManager.coffee index 5ddca2e6a8..c557db3f54 100644 --- a/services/document-updater/app/coffee/DocumentManager.coffee +++ b/services/document-updater/app/coffee/DocumentManager.coffee @@ -7,6 +7,7 @@ HistoryManager = require "./HistoryManager" RealTimeRedisManager = require "./RealTimeRedisManager" Errors = require "./Errors" RangesManager = require "./RangesManager" +async = require "async" MAX_UNFLUSHED_AGE = 300 * 1000 # 5 mins, document should be flushed to mongo this time after a change @@ -155,6 +156,14 @@ module.exports = DocumentManager = return callback(error) if error? callback() + renameDoc: (project_id, doc_id, user_id, update, _callback = (error) ->) -> + timer = new Metrics.Timer("docManager.updateProject") + callback = (args...) -> + timer.done() + _callback(args...) + + RedisManager.renameDoc project_id, doc_id, user_id, update, callback + getDocAndFlushIfOld: (project_id, doc_id, callback = (error, doc) ->) -> DocumentManager.getDoc project_id, doc_id, (error, lines, version, ranges, pathname, unflushedTime, alreadyLoaded) -> return callback(error) if error? @@ -197,3 +206,7 @@ module.exports = DocumentManager = deleteCommentWithLock: (project_id, doc_id, thread_id, callback = (error) ->) -> UpdateManager = require "./UpdateManager" UpdateManager.lockUpdatesAndDo DocumentManager.deleteComment, project_id, doc_id, thread_id, callback + + renameDocWithLock: (project_id, doc_id, user_id, update, callback = (error) ->) -> + UpdateManager = require "./UpdateManager" + UpdateManager.lockUpdatesAndDo DocumentManager.renameDoc, project_id, doc_id, user_id, update, callback diff --git a/services/document-updater/app/coffee/HttpController.coffee b/services/document-updater/app/coffee/HttpController.coffee index 0c03a4f7bd..78de5fb765 100644 --- a/services/document-updater/app/coffee/HttpController.coffee +++ b/services/document-updater/app/coffee/HttpController.coffee @@ -141,7 +141,7 @@ module.exports = HttpController = return next(error) if error? logger.log {project_id, doc_id}, "accepted #{ change_ids.length } changes via http" res.send 204 # No Content - + deleteComment: (req, res, next = (error) ->) -> {project_id, doc_id, comment_id} = req.params logger.log {project_id, doc_id, comment_id}, "deleting comment via http" @@ -151,5 +151,15 @@ module.exports = HttpController = return next(error) if error? logger.log {project_id, doc_id, comment_id}, "deleted comment via http" res.send 204 # No Content - + updateProject: (req, res, next = (error) ->) -> + timer = new Metrics.Timer("http.updateProject") + project_id = req.params.project_id + {userId, docUpdates} = req.body + logger.log {project_id, docUpdates}, "updating project via http" + + ProjectManager.updateProjectWithLocks project_id, userId, docUpdates, (error) -> + timer.done() + return next(error) if error? + logger.log project_id: project_id, "updated project via http" + res.send 204 # No Content diff --git a/services/document-updater/app/coffee/ProjectManager.coffee b/services/document-updater/app/coffee/ProjectManager.coffee index 26b6e79b0d..6b320e6e28 100644 --- a/services/document-updater/app/coffee/ProjectManager.coffee +++ b/services/document-updater/app/coffee/ProjectManager.coffee @@ -93,3 +93,15 @@ module.exports = ProjectManager = clearProjectState: (project_id, callback = (error) ->) -> RedisManager.clearProjectState project_id, callback + + updateProjectWithLocks: (project_id, user_id, updates, _callback = (error) ->) -> + timer = new Metrics.Timer("projectManager.updateProject") + callback = (args...) -> + timer.done() + _callback(args...) + + handleUpdate = (update, cb) -> + doc_id = update.id + DocumentManager.renameDocWithLock project_id, doc_id, user_id, update, cb + + async.each updates, handleUpdate, callback diff --git a/services/document-updater/app/coffee/RedisManager.coffee b/services/document-updater/app/coffee/RedisManager.coffee index d2ab35a3ee..cde2ccddc9 100644 --- a/services/document-updater/app/coffee/RedisManager.coffee +++ b/services/document-updater/app/coffee/RedisManager.coffee @@ -272,6 +272,22 @@ module.exports = RedisManager = else callback null, docUpdateCount + renameDoc: (project_id, doc_id, user_id, update, callback = (error) ->) -> + update = + doc: doc_id + pathname: update.pathname + new_pathname: update.newPathname + meta: + user_id: user_id + ts: new Date() + jsonUpdate = JSON.stringify(update) + + RedisManager.getDoc project_id, doc_id, (error, lines, version) -> + return callback(error) if error? + if lines? and version? + rclient.set keys.pathname(doc_id:doc_id), update.new_pathname + + rclient.rpush projectHistoryKeys.projectHistoryOps({project_id}), jsonUpdate, callback clearUnflushedTime: (doc_id, callback = (error) ->) -> rclient.del keys.unflushedTime(doc_id:doc_id), callback diff --git a/services/document-updater/test/unit/coffee/DocumentManager/DocumentManagerTests.coffee b/services/document-updater/test/unit/coffee/DocumentManager/DocumentManagerTests.coffee index ac0601b34b..6c7b051f7e 100644 --- a/services/document-updater/test/unit/coffee/DocumentManager/DocumentManagerTests.coffee +++ b/services/document-updater/test/unit/coffee/DocumentManager/DocumentManagerTests.coffee @@ -23,6 +23,7 @@ describe "DocumentManager", -> "./RangesManager": @RangesManager = {} @project_id = "project-id-123" @doc_id = "doc-id-123" + @user_id = 1234 @callback = sinon.stub() @lines = ["one", "two", "three"] @version = 42 @@ -439,3 +440,20 @@ describe "DocumentManager", -> it "should call the callback with the lines and versions", -> @callback.calledWith(null, @lines, @version).should.equal true + + describe "renameDoc", -> + beforeEach -> + @update = 'some-update' + @RedisManager.renameDoc = sinon.stub().yields() + + describe "successfully", -> + beforeEach -> + @DocumentManager.renameDoc @project_id, @doc_id, @user_id, @update, @callback + + it "should rename the document", -> + @RedisManager.renameDoc + .calledWith(@project_id, @doc_id, @user_id, @update) + .should.equal true + + it "should call the callback", -> + @callback.called.should.equal true diff --git a/services/document-updater/test/unit/coffee/HttpController/HttpControllerTests.coffee b/services/document-updater/test/unit/coffee/HttpController/HttpControllerTests.coffee index 17b5d10304..5ddefefaa3 100644 --- a/services/document-updater/test/unit/coffee/HttpController/HttpControllerTests.coffee +++ b/services/document-updater/test/unit/coffee/HttpController/HttpControllerTests.coffee @@ -492,3 +492,40 @@ describe "HttpController", -> @next .calledWith(new Error("oops")) .should.equal true + + describe "updateProject", -> + beforeEach -> + @userId = "user-id-123" + @docUpdates = sinon.stub() + @req = + body: {@userId, @docUpdates} + params: + project_id: @project_id + + describe "successfully", -> + beforeEach -> + @ProjectManager.updateProjectWithLocks = sinon.stub().callsArgWith(3) + @HttpController.updateProject(@req, @res, @next) + + it "should accept the change", -> + @ProjectManager.updateProjectWithLocks + .calledWith(@project_id, @userId, @docUpdates) + .should.equal true + + it "should return a successful No Content response", -> + @res.send + .calledWith(204) + .should.equal true + + it "should time the request", -> + @Metrics.Timer::done.called.should.equal true + + describe "when an errors occurs", -> + beforeEach -> + @ProjectManager.updateProjectWithLocks = sinon.stub().callsArgWith(3, new Error("oops")) + @HttpController.updateProject(@req, @res, @next) + + it "should call next with the error", -> + @next + .calledWith(new Error("oops")) + .should.equal true diff --git a/services/document-updater/test/unit/coffee/ProjectManager/updateProjectTests.coffee b/services/document-updater/test/unit/coffee/ProjectManager/updateProjectTests.coffee new file mode 100644 index 0000000000..fc81834782 --- /dev/null +++ b/services/document-updater/test/unit/coffee/ProjectManager/updateProjectTests.coffee @@ -0,0 +1,54 @@ +sinon = require('sinon') +chai = require('chai') +should = chai.should() +modulePath = "../../../../app/js/ProjectManager.js" +SandboxedModule = require('sandboxed-module') + +describe "ProjectManager", -> + beforeEach -> + @ProjectManager = SandboxedModule.require modulePath, requires: + "./RedisManager": @RedisManager = {} + "./DocumentManager": @DocumentManager = {} + "logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() } + "./Metrics": @Metrics = + Timer: class Timer + done: sinon.stub() + + @project_id = "project-id-123" + @user_id = "user-id-123" + @callback = sinon.stub() + + describe "updateProjectWithLocks", -> + beforeEach -> + @firstUpdate = + id: 1 + update: 'foo' + @secondUpdate = + id: 2 + update: 'bar' + @updates = [ @firstUpdate, @secondUpdate ] + + describe "successfully", -> + beforeEach -> + @DocumentManager.renameDocWithLock = sinon.stub().yields() + @ProjectManager.updateProjectWithLocks @project_id, @user_id, @updates, @callback + + it "should rename the documents in the updates", -> + @DocumentManager.renameDocWithLock + .calledWith(@project_id, @firstUpdate.id, @user_id, @firstUpdate) + .should.equal true + @DocumentManager.renameDocWithLock + .calledWith(@project_id, @secondUpdate.id, @user_id, @secondUpdate) + .should.equal true + + it "should call the callback", -> + @callback.called.should.equal true + + describe "when renaming a doc fails", -> + beforeEach -> + @error = new Error('error') + @DocumentManager.renameDocWithLock = sinon.stub().yields(@error) + @ProjectManager.updateProjectWithLocks @project_id, @user_id, @updates, @callback + + it "should call the callback with the error", -> + @callback.calledWith(@error).should.equal true diff --git a/services/document-updater/test/unit/coffee/RedisManager/RedisManagerTests.coffee b/services/document-updater/test/unit/coffee/RedisManager/RedisManagerTests.coffee index f5bf3843fa..2b81c18a18 100644 --- a/services/document-updater/test/unit/coffee/RedisManager/RedisManagerTests.coffee +++ b/services/document-updater/test/unit/coffee/RedisManager/RedisManagerTests.coffee @@ -673,3 +673,45 @@ describe "RedisManager", -> @rclient.del .calledWith("ProjectState:#{@project_id}") .should.equal true + + describe "renameDoc", -> + beforeEach () -> + @rclient.rpush = sinon.stub().callsArg(2) + @rclient.set = sinon.stub() + @update = + id: @doc_id + pathname: @pathname = 'pathname' + newPathname: @newPathname = 'new-pathname' + + describe "the document is cached in redis", -> + beforeEach -> + @RedisManager.getDoc = sinon.stub().callsArgWith(2, null, 'lines', 'version') + @RedisManager.renameDoc @project_id, @doc_id, @userId, @update, @callback + + it "update the cached pathname", -> + @rclient.set + .calledWith("Pathname:#{@doc_id}", @newPathname) + .should.equal true + + it "should queue an update", -> + update = + doc: @doc_id + pathname: @pathname + new_pathname: @newPathname + meta: + user_id: @userId + ts: new Date() + @rclient.rpush + .calledWith("ProjectHistory:Ops:#{@project_id}", JSON.stringify(update)) + .should.equal true + + it "should call the callback", -> + @callback.calledWith().should.equal true + + describe "the document is not cached in redis", -> + beforeEach -> + @RedisManager.getDoc = sinon.stub().callsArgWith(2, null, null, null) + @RedisManager.renameDoc @project_id, @doc_id, @userId, @update, @callback + + it "does not update the cached pathname", -> + @rclient.set.called.should.equal false