Kaynağa Gözat

Merge pull request #2 from sharelatex/hof-doc-pathname

Add metadata to updates for history
Hayden Faulds 9 yıl önce
ebeveyn
işleme
5b7be93c79
18 değiştirilmiş dosya ile 653 ekleme ve 390 silme
  1. 1 1
      services/document-updater/Jenkinsfile
  2. 18 21
      services/document-updater/app/coffee/DocumentManager.coffee
  3. 55 27
      services/document-updater/app/coffee/HistoryManager.coffee
  4. 2 2
      services/document-updater/app/coffee/PersistenceManager.coffee
  5. 21 10
      services/document-updater/app/coffee/RedisManager.coffee
  6. 18 8
      services/document-updater/app/coffee/UpdateManager.coffee
  7. 8 0
      services/document-updater/config/settings.defaults.coffee
  8. 20 8
      services/document-updater/test/acceptance/coffee/ApplyingUpdatesToADocTests.coffee
  9. 12 6
      services/document-updater/test/acceptance/coffee/DeletingADocumentTests.coffee
  10. 8 4
      services/document-updater/test/acceptance/coffee/DeletingAProjectTests.coffee
  11. 20 14
      services/document-updater/test/acceptance/coffee/SettingADocumentTests.coffee
  12. 19 0
      services/document-updater/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee
  13. 2 0
      services/document-updater/test/acceptance/coffee/helpers/MockWebApi.coffee
  14. 43 42
      services/document-updater/test/unit/coffee/DocumentManager/DocumentManagerTests.coffee
  15. 135 70
      services/document-updater/test/unit/coffee/HistoryManager/HistoryManagerTests.coffee
  16. 8 6
      services/document-updater/test/unit/coffee/PersistenceManager/PersistenceManagerTests.coffee
  17. 209 148
      services/document-updater/test/unit/coffee/RedisManager/RedisManagerTests.coffee
  18. 54 23
      services/document-updater/test/unit/coffee/UpdateManager/UpdateManagerTests.coffee

+ 1 - 1
services/document-updater/Jenkinsfile

@@ -41,7 +41,7 @@ pipeline {
     stage('Acceptance Tests') {
     stage('Acceptance Tests') {
       steps {
       steps {
         sh 'docker pull sharelatex/acceptance-test-runner'
         sh 'docker pull sharelatex/acceptance-test-runner'
-        sh 'docker run --rm -v $(pwd):/app sharelatex/acceptance-test-runner'
+        sh 'docker run --rm -e SHARELATEX_ENABLE_PROJECT_HISTORY=true -v $(pwd):/app sharelatex/acceptance-test-runner'
       }
       }
     }
     }
     stage('Package') {
     stage('Package') {

+ 18 - 21
services/document-updater/app/coffee/DocumentManager.coffee

@@ -11,31 +11,31 @@ RangesManager = require "./RangesManager"
 MAX_UNFLUSHED_AGE = 300 * 1000 # 5 mins, document should be flushed to mongo this time after a change
 MAX_UNFLUSHED_AGE = 300 * 1000 # 5 mins, document should be flushed to mongo this time after a change
 
 
 module.exports = DocumentManager =
 module.exports = DocumentManager =
-	getDoc: (project_id, doc_id, _callback = (error, lines, version, ranges, unflushedTime, alreadyLoaded) ->) ->
+	getDoc: (project_id, doc_id, _callback = (error, lines, version, ranges, pathname, unflushedTime, alreadyLoaded) ->) ->
 		timer = new Metrics.Timer("docManager.getDoc")
 		timer = new Metrics.Timer("docManager.getDoc")
 		callback = (args...) ->
 		callback = (args...) ->
 			timer.done()
 			timer.done()
 			_callback(args...)
 			_callback(args...)
 
 
-		RedisManager.getDoc project_id, doc_id, (error, lines, version, ranges, unflushedTime) ->
+		RedisManager.getDoc project_id, doc_id, (error, lines, version, ranges, pathname, unflushedTime) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			if !lines? or !version?
 			if !lines? or !version?
 				logger.log {project_id, doc_id}, "doc not in redis so getting from persistence API"
 				logger.log {project_id, doc_id}, "doc not in redis so getting from persistence API"
-				PersistenceManager.getDoc project_id, doc_id, (error, lines, version, ranges) ->
+				PersistenceManager.getDoc project_id, doc_id, (error, lines, version, ranges, pathname) ->
 					return callback(error) if error?
 					return callback(error) if error?
 					logger.log {project_id, doc_id, lines, version}, "got doc from persistence API"
 					logger.log {project_id, doc_id, lines, version}, "got doc from persistence API"
-					RedisManager.putDocInMemory project_id, doc_id, lines, version, ranges, (error) ->
+					RedisManager.putDocInMemory project_id, doc_id, lines, version, ranges, pathname, (error) ->
 						return callback(error) if error?
 						return callback(error) if error?
-						callback null, lines, version, ranges, null, false
+						callback null, lines, version, ranges, pathname, null, false
 			else
 			else
-				callback null, lines, version, ranges, unflushedTime, true
+				callback null, lines, version, ranges, pathname, unflushedTime, true
 
 
 	getDocAndRecentOps: (project_id, doc_id, fromVersion, _callback = (error, lines, version, recentOps, ranges) ->) ->
 	getDocAndRecentOps: (project_id, doc_id, fromVersion, _callback = (error, lines, version, recentOps, ranges) ->) ->
 		timer = new Metrics.Timer("docManager.getDocAndRecentOps")
 		timer = new Metrics.Timer("docManager.getDocAndRecentOps")
 		callback = (args...) ->
 		callback = (args...) ->
 			timer.done()
 			timer.done()
 			_callback(args...)
 			_callback(args...)
-		
+
 		DocumentManager.getDoc project_id, doc_id, (error, lines, version, ranges) ->
 		DocumentManager.getDoc project_id, doc_id, (error, lines, version, ranges) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			if fromVersion == -1
 			if fromVersion == -1
@@ -55,9 +55,9 @@ module.exports = DocumentManager =
 			return callback(new Error("No lines were provided to setDoc"))
 			return callback(new Error("No lines were provided to setDoc"))
 
 
 		UpdateManager = require "./UpdateManager"
 		UpdateManager = require "./UpdateManager"
-		DocumentManager.getDoc project_id, doc_id, (error, oldLines, version, ranges, unflushedTime, alreadyLoaded) ->
+		DocumentManager.getDoc project_id, doc_id, (error, oldLines, version, ranges, pathname, unflushedTime, alreadyLoaded) ->
 			return callback(error) if error?
 			return callback(error) if error?
-			
+
 			if oldLines? and oldLines.length > 0 and oldLines[0].text?
 			if oldLines? and oldLines.length > 0 and oldLines[0].text?
 				logger.log doc_id: doc_id, project_id: project_id, oldLines: oldLines, newLines: newLines, "document is JSON so not updating"
 				logger.log doc_id: doc_id, project_id: project_id, oldLines: oldLines, newLines: newLines, "document is JSON so not updating"
 				return callback(null)
 				return callback(null)
@@ -115,12 +115,9 @@ module.exports = DocumentManager =
 
 
 		DocumentManager.flushDocIfLoaded project_id, doc_id, (error) ->
 		DocumentManager.flushDocIfLoaded project_id, doc_id, (error) ->
 			return callback(error) if error?
 			return callback(error) if error?
-			
-			# Flush in the background since it requires and http request
-			# to track changes
-			HistoryManager.flushDocChanges project_id, doc_id, (err) ->
-				if err?
-					logger.err {err, project_id, doc_id}, "error flushing to track changes"
+
+			# Flush in the background since it requires a http request
+			HistoryManager.flushChangesAsync project_id, doc_id
 
 
 			RedisManager.removeDocFromMemory project_id, doc_id, (error) ->
 			RedisManager.removeDocFromMemory project_id, doc_id, (error) ->
 				return callback(error) if error?
 				return callback(error) if error?
@@ -138,10 +135,10 @@ module.exports = DocumentManager =
 				return callback(new Errors.NotFoundError("document not found: #{doc_id}"))
 				return callback(new Errors.NotFoundError("document not found: #{doc_id}"))
 			RangesManager.acceptChanges change_ids, ranges, (error, new_ranges) ->
 			RangesManager.acceptChanges change_ids, ranges, (error, new_ranges) ->
 				return callback(error) if error?
 				return callback(error) if error?
-				RedisManager.updateDocument doc_id, lines, version, [], new_ranges, (error) ->
+				RedisManager.updateDocument project_id, doc_id, lines, version, [], new_ranges, (error) ->
 					return callback(error) if error?
 					return callback(error) if error?
 					callback()
 					callback()
-	
+
 	deleteComment: (project_id, doc_id, comment_id, _callback = (error) ->) ->
 	deleteComment: (project_id, doc_id, comment_id, _callback = (error) ->) ->
 		timer = new Metrics.Timer("docManager.deleteComment")
 		timer = new Metrics.Timer("docManager.deleteComment")
 		callback = (args...) ->
 		callback = (args...) ->
@@ -154,12 +151,12 @@ module.exports = DocumentManager =
 				return callback(new Errors.NotFoundError("document not found: #{doc_id}"))
 				return callback(new Errors.NotFoundError("document not found: #{doc_id}"))
 			RangesManager.deleteComment comment_id, ranges, (error, new_ranges) ->
 			RangesManager.deleteComment comment_id, ranges, (error, new_ranges) ->
 				return callback(error) if error?
 				return callback(error) if error?
-				RedisManager.updateDocument doc_id, lines, version, [], new_ranges, (error) ->
+				RedisManager.updateDocument project_id, doc_id, lines, version, [], new_ranges, (error) ->
 					return callback(error) if error?
 					return callback(error) if error?
 					callback()
 					callback()
 
 
 	getDocAndFlushIfOld: (project_id, doc_id, callback = (error, doc) ->) ->
 	getDocAndFlushIfOld: (project_id, doc_id, callback = (error, doc) ->) ->
-		DocumentManager.getDoc project_id, doc_id, (error, lines, version, ranges, unflushedTime, alreadyLoaded) ->
+		DocumentManager.getDoc project_id, doc_id, (error, lines, version, ranges, pathname, unflushedTime, alreadyLoaded) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			# if doc was already loaded see if it needs to be flushed
 			# if doc was already loaded see if it needs to be flushed
 			if alreadyLoaded and unflushedTime? and (Date.now() - unflushedTime) > MAX_UNFLUSHED_AGE
 			if alreadyLoaded and unflushedTime? and (Date.now() - unflushedTime) > MAX_UNFLUSHED_AGE
@@ -172,7 +169,7 @@ module.exports = DocumentManager =
 	getDocWithLock: (project_id, doc_id, callback = (error, lines, version) ->) ->
 	getDocWithLock: (project_id, doc_id, callback = (error, lines, version) ->) ->
 		UpdateManager = require "./UpdateManager"
 		UpdateManager = require "./UpdateManager"
 		UpdateManager.lockUpdatesAndDo DocumentManager.getDoc, project_id, doc_id, callback
 		UpdateManager.lockUpdatesAndDo DocumentManager.getDoc, project_id, doc_id, callback
-		
+
 	getDocAndRecentOpsWithLock: (project_id, doc_id, fromVersion, callback = (error, lines, version) ->) ->
 	getDocAndRecentOpsWithLock: (project_id, doc_id, fromVersion, callback = (error, lines, version) ->) ->
 		UpdateManager = require "./UpdateManager"
 		UpdateManager = require "./UpdateManager"
 		UpdateManager.lockUpdatesAndDo DocumentManager.getDocAndRecentOps, project_id, doc_id, fromVersion, callback
 		UpdateManager.lockUpdatesAndDo DocumentManager.getDocAndRecentOps, project_id, doc_id, fromVersion, callback
@@ -184,7 +181,7 @@ module.exports = DocumentManager =
 	setDocWithLock: (project_id, doc_id, lines, source, user_id, undoing, callback = (error) ->) ->
 	setDocWithLock: (project_id, doc_id, lines, source, user_id, undoing, callback = (error) ->) ->
 		UpdateManager = require "./UpdateManager"
 		UpdateManager = require "./UpdateManager"
 		UpdateManager.lockUpdatesAndDo DocumentManager.setDoc, project_id, doc_id, lines, source, user_id, undoing, callback
 		UpdateManager.lockUpdatesAndDo DocumentManager.setDoc, project_id, doc_id, lines, source, user_id, undoing, callback
-		
+
 	flushDocIfLoadedWithLock: (project_id, doc_id, callback = (error) ->) ->
 	flushDocIfLoadedWithLock: (project_id, doc_id, callback = (error) ->) ->
 		UpdateManager = require "./UpdateManager"
 		UpdateManager = require "./UpdateManager"
 		UpdateManager.lockUpdatesAndDo DocumentManager.flushDocIfLoaded, project_id, doc_id, callback
 		UpdateManager.lockUpdatesAndDo DocumentManager.flushDocIfLoaded, project_id, doc_id, callback

+ 55 - 27
services/document-updater/app/coffee/HistoryManager.coffee

@@ -1,44 +1,72 @@
-settings = require "settings-sharelatex"
+Settings = require "settings-sharelatex"
 request  = require "request"
 request  = require "request"
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
 HistoryRedisManager = require "./HistoryRedisManager"
 HistoryRedisManager = require "./HistoryRedisManager"
 
 
 module.exports = HistoryManager =
 module.exports = HistoryManager =
-	flushDocChanges: (project_id, doc_id, callback = (error) ->) ->
-		if !settings.apis?.trackchanges?
-			logger.warn doc_id: doc_id, "track changes API is not configured, so not flushing"
-			return callback()
+	flushChangesAsync: (project_id, doc_id) ->
+		HistoryManager._flushDocChangesAsync project_id, doc_id
+		if Settings.apis?.project_history?.enabled
+			HistoryManager._flushProjectChangesAsync project_id
+
+	_flushDocChangesAsync: (project_id, doc_id) ->
+		if !Settings.apis?.trackchanges?
+			logger.warn { doc_id }, "track changes API is not configured, so not flushing"
+			return
+
+		url = "#{Settings.apis.trackchanges.url}/project/#{project_id}/doc/#{doc_id}/flush"
+		logger.log { project_id, doc_id, url }, "flushing doc in track changes api"
+		request.post url, (error, res, body)->
+			if error?
+				logger.error(
+				   { error, doc_id, project_id},
+					"track changes doc to track changes api"
+				)
+			else if res.statusCode < 200 and res.statusCode >= 300
+				logger.error(
+					{ doc_id, project_id },
+					"track changes api returned a failure status code: #{res.statusCode}"
+				)
+
+	_flushProjectChangesAsync: (project_id) ->
+		return if !Settings.apis?.project_history?
 
 
-		url = "#{settings.apis.trackchanges.url}/project/#{project_id}/doc/#{doc_id}/flush"
-		logger.log project_id: project_id, doc_id: doc_id, url: url, "flushing doc in track changes api"
+		url = "#{Settings.apis.project_history.url}/project/#{project_id}/flush"
+		logger.log { project_id, url }, "flushing doc in project history api"
 		request.post url, (error, res, body)->
 		request.post url, (error, res, body)->
 			if error?
 			if error?
-				return callback(error)
-			else if res.statusCode >= 200 and res.statusCode < 300
-				return callback(null)
-			else
-				error = new Error("track changes api returned a failure status code: #{res.statusCode}")
-				return callback(error)
+				logger.error { error, project_id}, "project history doc to track changes api"
+			else if res.statusCode < 200 and res.statusCode >= 300
+				logger.error { project_id }, "project history api returned a failure status code: #{res.statusCode}"
 
 
 	FLUSH_EVERY_N_OPS: 100
 	FLUSH_EVERY_N_OPS: 100
-	recordAndFlushHistoryOps: (project_id, doc_id, ops = [], length, callback = (error) ->) ->
+	recordAndFlushHistoryOps: (project_id, doc_id, ops = [], doc_ops_length, project_ops_length, callback = (error) ->) ->
 		if ops.length == 0
 		if ops.length == 0
 			return callback()
 			return callback()
+
+		if Settings.apis?.project_history?.enabled
+			if HistoryManager._shouldFlushHistoryOps(project_ops_length, ops, HistoryManager.FLUSH_EVERY_N_OPS)
+				# Do this in the background since it uses HTTP and so may be too
+				# slow to wait for when processing a doc update.
+				logger.log { project_ops_length, project_id }, "flushing project history api"
+				HistoryManager._flushProjectChangesAsync project_id
+
 		HistoryRedisManager.recordDocHasHistoryOps project_id, doc_id, ops, (error) ->
 		HistoryRedisManager.recordDocHasHistoryOps project_id, doc_id, ops, (error) ->
 			return callback(error) if error?
 			return callback(error) if error?
-			return callback() if not length? # don't flush unless we know the length
-			# We want to flush every 100 ops, i.e. 100, 200, 300, etc
-			# Find out which 'block' (i.e. 0-99, 100-199) we were in before and after pushing these
-			# ops. If we've changed, then we've gone over a multiple of 100 and should flush.
-			# (Most of the time, we will only hit 100 and then flushing will put us back to 0)
-			previousLength = length - ops.length
-			prevBlock = Math.floor(previousLength / HistoryManager.FLUSH_EVERY_N_OPS)
-			newBlock  = Math.floor(length / HistoryManager.FLUSH_EVERY_N_OPS)
-			if newBlock != prevBlock
+			if HistoryManager._shouldFlushHistoryOps(doc_ops_length, ops, HistoryManager.FLUSH_EVERY_N_OPS)
 				# Do this in the background since it uses HTTP and so may be too
 				# Do this in the background since it uses HTTP and so may be too
 				# slow to wait for when processing a doc update.
 				# slow to wait for when processing a doc update.
-				logger.log length: length, doc_id: doc_id, project_id: project_id, "flushing track changes api"
-				HistoryManager.flushDocChanges project_id, doc_id,  (error) ->
-					if error?
-						logger.error err: error, doc_id: doc_id, project_id: project_id, "error flushing doc to track changes api"
+				logger.log { doc_ops_length, doc_id, project_id }, "flushing track changes api"
+				HistoryManager._flushDocChangesAsync project_id, doc_id
 			callback()
 			callback()
+
+	_shouldFlushHistoryOps: (length, ops, threshold) ->
+		return false if !length # don't flush unless we know the length
+		# We want to flush every 100 ops, i.e. 100, 200, 300, etc
+		# Find out which 'block' (i.e. 0-99, 100-199) we were in before and after pushing these
+		# ops. If we've changed, then we've gone over a multiple of 100 and should flush.
+		# (Most of the time, we will only hit 100 and then flushing will put us back to 0)
+		previousLength = length - ops.length
+		prevBlock = Math.floor(previousLength / threshold)
+		newBlock  = Math.floor(length / threshold)
+		return newBlock != prevBlock

+ 2 - 2
services/document-updater/app/coffee/PersistenceManager.coffee

@@ -13,7 +13,7 @@ request = (require("requestretry")).defaults({
 MAX_HTTP_REQUEST_LENGTH = 5000 # 5 seconds
 MAX_HTTP_REQUEST_LENGTH = 5000 # 5 seconds
 
 
 module.exports = PersistenceManager =
 module.exports = PersistenceManager =
-	getDoc: (project_id, doc_id, _callback = (error, lines, version, ranges) ->) ->
+	getDoc: (project_id, doc_id, _callback = (error, lines, version, ranges, pathname) ->) ->
 		timer = new Metrics.Timer("persistenceManager.getDoc")
 		timer = new Metrics.Timer("persistenceManager.getDoc")
 		callback = (args...) ->
 		callback = (args...) ->
 			timer.done()
 			timer.done()
@@ -42,7 +42,7 @@ module.exports = PersistenceManager =
 					return callback(new Error("web API response had no doc lines"))
 					return callback(new Error("web API response had no doc lines"))
 				if !body.version? or not body.version instanceof Number
 				if !body.version? or not body.version instanceof Number
 					return callback(new Error("web API response had no valid doc version"))
 					return callback(new Error("web API response had no valid doc version"))
-				return callback null, body.lines, body.version, body.ranges
+				return callback null, body.lines, body.version, body.ranges, body.pathname
 			else if res.statusCode == 404
 			else if res.statusCode == 404
 				return callback(new Errors.NotFoundError("doc not not found: #{url}"))
 				return callback(new Errors.NotFoundError("doc not not found: #{url}"))
 			else
 			else

+ 21 - 10
services/document-updater/app/coffee/RedisManager.coffee

@@ -31,11 +31,12 @@ MAX_RANGES_SIZE = 3 * MEGABYTES
 
 
 keys = Settings.redis.documentupdater.key_schema
 keys = Settings.redis.documentupdater.key_schema
 historyKeys = Settings.redis.history.key_schema
 historyKeys = Settings.redis.history.key_schema
+projectHistoryKeys = Settings.redis.project_history.key_schema
 
 
 module.exports = RedisManager =
 module.exports = RedisManager =
 	rclient: rclient
 	rclient: rclient
 
 
-	putDocInMemory : (project_id, doc_id, docLines, version, ranges, _callback)->
+	putDocInMemory : (project_id, doc_id, docLines, version, ranges, pathname, _callback)->
 		timer = new metrics.Timer("redis.put-doc")
 		timer = new metrics.Timer("redis.put-doc")
 		callback = (error) ->
 		callback = (error) ->
 			timer.done()
 			timer.done()
@@ -60,6 +61,7 @@ module.exports = RedisManager =
 				multi.set keys.ranges(doc_id:doc_id), ranges
 				multi.set keys.ranges(doc_id:doc_id), ranges
 			else
 			else
 				multi.del keys.ranges(doc_id:doc_id)
 				multi.del keys.ranges(doc_id:doc_id)
+			multi.set keys.pathname(doc_id:doc_id), pathname
 			multi.exec (error, result) ->
 			multi.exec (error, result) ->
 				return callback(error) if error?
 				return callback(error) if error?
 				# check the hash computed on the redis server
 				# check the hash computed on the redis server
@@ -85,6 +87,7 @@ module.exports = RedisManager =
 		multi.del keys.docVersion(doc_id:doc_id)
 		multi.del keys.docVersion(doc_id:doc_id)
 		multi.del keys.docHash(doc_id:doc_id)
 		multi.del keys.docHash(doc_id:doc_id)
 		multi.del keys.ranges(doc_id:doc_id)
 		multi.del keys.ranges(doc_id:doc_id)
+		multi.del keys.pathname(doc_id:doc_id)
 		multi.del keys.unflushedTime(doc_id:doc_id)
 		multi.del keys.unflushedTime(doc_id:doc_id)
 		multi.exec (error) ->
 		multi.exec (error) ->
 			return callback(error) if error?
 			return callback(error) if error?
@@ -105,7 +108,7 @@ module.exports = RedisManager =
 	clearProjectState: (project_id, callback = (error) ->) ->
 	clearProjectState: (project_id, callback = (error) ->) ->
 		rclient.del keys.projectState(project_id:project_id), callback
 		rclient.del keys.projectState(project_id:project_id), callback
 
 
-	getDoc : (project_id, doc_id, callback = (error, lines, version, ranges, unflushedTime) ->)->
+	getDoc : (project_id, doc_id, callback = (error, lines, version, ranges, pathname, unflushedTime) ->)->
 		timer = new metrics.Timer("redis.get-doc")
 		timer = new metrics.Timer("redis.get-doc")
 		multi = rclient.multi()
 		multi = rclient.multi()
 		multi.get keys.docLines(doc_id:doc_id)
 		multi.get keys.docLines(doc_id:doc_id)
@@ -113,8 +116,9 @@ module.exports = RedisManager =
 		multi.get keys.docHash(doc_id:doc_id)
 		multi.get keys.docHash(doc_id:doc_id)
 		multi.get keys.projectKey(doc_id:doc_id)
 		multi.get keys.projectKey(doc_id:doc_id)
 		multi.get keys.ranges(doc_id:doc_id)
 		multi.get keys.ranges(doc_id:doc_id)
+		multi.get keys.pathname(doc_id:doc_id)
 		multi.get keys.unflushedTime(doc_id:doc_id)
 		multi.get keys.unflushedTime(doc_id:doc_id)
-		multi.exec (error, [docLines, version, storedHash, doc_project_id, ranges, unflushedTime])->
+		multi.exec (error, [docLines, version, storedHash, doc_project_id, ranges, pathname, unflushedTime])->
 			timeSpan = timer.done()
 			timeSpan = timer.done()
 			return callback(error) if error?
 			return callback(error) if error?
 			# check if request took too long and bail out.  only do this for
 			# check if request took too long and bail out.  only do this for
@@ -143,14 +147,14 @@ module.exports = RedisManager =
 
 
 			# doc is not in redis, bail out
 			# doc is not in redis, bail out
 			if !docLines?
 			if !docLines?
-				return callback null, docLines, version, ranges
+				return callback null, docLines, version, ranges, pathname, unflushedTime
 
 
 			# doc should be in project set, check if missing (workaround for missing docs from putDoc)
 			# doc should be in project set, check if missing (workaround for missing docs from putDoc)
 			rclient.sadd keys.docsInProject(project_id:project_id), doc_id, (error, result) ->
 			rclient.sadd keys.docsInProject(project_id:project_id), doc_id, (error, result) ->
 				return callback(error) if error?
 				return callback(error) if error?
 				if result isnt 0 # doc should already be in set
 				if result isnt 0 # doc should already be in set
 					logger.error project_id: project_id, doc_id: doc_id, doc_project_id: doc_project_id, "doc missing from docsInProject set"
 					logger.error project_id: project_id, doc_id: doc_id, doc_project_id: doc_project_id, "doc missing from docsInProject set"
-				callback null, docLines, version, ranges, unflushedTime
+				callback null, docLines, version, ranges, pathname, unflushedTime
 
 
 	getDocVersion: (doc_id, callback = (error, version) ->) ->
 	getDocVersion: (doc_id, callback = (error, version) ->) ->
 		rclient.get keys.docVersion(doc_id: doc_id), (error, version) ->
 		rclient.get keys.docVersion(doc_id: doc_id), (error, version) ->
@@ -200,7 +204,7 @@ module.exports = RedisManager =
 
 
 	DOC_OPS_TTL: 60 * minutes
 	DOC_OPS_TTL: 60 * minutes
 	DOC_OPS_MAX_LENGTH: 100
 	DOC_OPS_MAX_LENGTH: 100
-	updateDocument : (doc_id, docLines, newVersion, appliedOps = [], ranges, callback = (error) ->)->
+	updateDocument : (project_id, doc_id, docLines, newVersion, appliedOps = [], ranges, callback = (error) ->)->
 		RedisManager.getDocVersion doc_id, (error, currentVersion) ->
 		RedisManager.getDocVersion doc_id, (error, currentVersion) ->
 			return callback(error) if error?
 			return callback(error) if error?
 			if currentVersion + appliedOps.length != newVersion
 			if currentVersion + appliedOps.length != newVersion
@@ -258,9 +262,16 @@ module.exports = RedisManager =
 						writeHash = result?[0]
 						writeHash = result?[0]
 						if logHashWriteErrors and writeHash? and writeHash isnt newHash
 						if logHashWriteErrors and writeHash? and writeHash isnt newHash
 							logger.error doc_id: doc_id, writeHash: writeHash, origHash: newHash, docLines:newDocLines, "hash mismatch on updateDocument"
 							logger.error doc_id: doc_id, writeHash: writeHash, origHash: newHash, docLines:newDocLines, "hash mismatch on updateDocument"
-						# return length of uncompressedHistoryOps queue (index 7)
-						uncompressedHistoryOpsLength = result?[7]
-						return callback(null, uncompressedHistoryOpsLength)
+
+						# length of uncompressedHistoryOps queue (index 7)
+						docUpdateCount = result[7]
+
+						if jsonOps.length > 0 && Settings.apis?.project_history?.enabled
+							rclient.rpush projectHistoryKeys.projectHistoryOps({project_id}), jsonOps..., (error, projectUpdateCount) ->
+								callback null, docUpdateCount, projectUpdateCount
+						else
+							callback null, docUpdateCount
+
 
 
 	clearUnflushedTime: (doc_id, callback = (error) ->) ->
 	clearUnflushedTime: (doc_id, callback = (error) ->) ->
 		rclient.del keys.unflushedTime(doc_id:doc_id), callback
 		rclient.del keys.unflushedTime(doc_id:doc_id), callback
@@ -276,7 +287,7 @@ module.exports = RedisManager =
 			# Most doc will have empty ranges so don't fill redis with lots of '{}' keys
 			# Most doc will have empty ranges so don't fill redis with lots of '{}' keys
 			jsonRanges = null
 			jsonRanges = null
 		return callback null, jsonRanges
 		return callback null, jsonRanges
-	
+
 	_deserializeRanges: (ranges) ->
 	_deserializeRanges: (ranges) ->
 		if !ranges? or ranges == ""
 		if !ranges? or ranges == ""
 			return {}
 			return {}

+ 18 - 8
services/document-updater/app/coffee/UpdateManager.coffee

@@ -4,6 +4,7 @@ RealTimeRedisManager = require "./RealTimeRedisManager"
 ShareJsUpdateManager = require "./ShareJsUpdateManager"
 ShareJsUpdateManager = require "./ShareJsUpdateManager"
 HistoryManager = require "./HistoryManager"
 HistoryManager = require "./HistoryManager"
 Settings = require('settings-sharelatex')
 Settings = require('settings-sharelatex')
+_ = require("underscore")
 async = require("async")
 async = require("async")
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')
 Metrics = require "./Metrics"
 Metrics = require "./Metrics"
@@ -69,7 +70,7 @@ module.exports = UpdateManager =
 		profile = new Profiler("applyUpdate", {project_id, doc_id})
 		profile = new Profiler("applyUpdate", {project_id, doc_id})
 		UpdateManager._sanitizeUpdate update
 		UpdateManager._sanitizeUpdate update
 		profile.log("sanitizeUpdate")
 		profile.log("sanitizeUpdate")
-		DocumentManager.getDoc project_id, doc_id, (error, lines, version, ranges) ->
+		DocumentManager.getDoc project_id, doc_id, (error, lines, version, ranges, pathname) ->
 			profile.log("getDoc")
 			profile.log("getDoc")
 			return callback(error) if error?
 			return callback(error) if error?
 			if !lines? or !version?
 			if !lines? or !version?
@@ -78,15 +79,15 @@ module.exports = UpdateManager =
 				profile.log("sharejs.applyUpdate")
 				profile.log("sharejs.applyUpdate")
 				return callback(error) if error?
 				return callback(error) if error?
 				RangesManager.applyUpdate project_id, doc_id, ranges, appliedOps, updatedDocLines, (error, new_ranges) ->
 				RangesManager.applyUpdate project_id, doc_id, ranges, appliedOps, updatedDocLines, (error, new_ranges) ->
+					UpdateManager._addProjectHistoryMetadataToOps(appliedOps, pathname, updatedDocLines)
 					profile.log("RangesManager.applyUpdate")
 					profile.log("RangesManager.applyUpdate")
 					return callback(error) if error?
 					return callback(error) if error?
-					RedisManager.updateDocument doc_id, updatedDocLines, version, appliedOps, new_ranges, (error, historyOpsLength) ->
+					RedisManager.updateDocument project_id, doc_id, updatedDocLines, version, appliedOps, new_ranges, (error, doc_ops_length, project_ops_length) ->
 						profile.log("RedisManager.updateDocument")
 						profile.log("RedisManager.updateDocument")
 						return callback(error) if error?
 						return callback(error) if error?
-						HistoryManager.recordAndFlushHistoryOps project_id, doc_id, appliedOps, historyOpsLength, (error) ->
+						HistoryManager.recordAndFlushHistoryOps project_id, doc_id, appliedOps, doc_ops_length, project_ops_length, (error) ->
 							profile.log("recordAndFlushHistoryOps")
 							profile.log("recordAndFlushHistoryOps")
-							return callback(error) if error?
-							callback()
+							callback(error)
 
 
 	lockUpdatesAndDo: (method, project_id, doc_id, args..., callback) ->
 	lockUpdatesAndDo: (method, project_id, doc_id, args..., callback) ->
 		profile = new Profiler("lockUpdatesAndDo", {project_id, doc_id})
 		profile = new Profiler("lockUpdatesAndDo", {project_id, doc_id})
@@ -109,15 +110,15 @@ module.exports = UpdateManager =
 	_handleErrorInsideLock: (doc_id, lockValue, original_error, callback = (error) ->) ->
 	_handleErrorInsideLock: (doc_id, lockValue, original_error, callback = (error) ->) ->
 		LockManager.releaseLock doc_id, lockValue, (lock_error) ->
 		LockManager.releaseLock doc_id, lockValue, (lock_error) ->
 			callback(original_error)
 			callback(original_error)
-	
+
 	_sanitizeUpdate: (update) ->
 	_sanitizeUpdate: (update) ->
 		# In Javascript, characters are 16-bits wide. It does not understand surrogates as characters.
 		# In Javascript, characters are 16-bits wide. It does not understand surrogates as characters.
-		# 
+		#
 		# From Wikipedia (http://en.wikipedia.org/wiki/Plane_(Unicode)#Basic_Multilingual_Plane):
 		# From Wikipedia (http://en.wikipedia.org/wiki/Plane_(Unicode)#Basic_Multilingual_Plane):
 		# "The High Surrogates (U+D800–U+DBFF) and Low Surrogate (U+DC00–U+DFFF) codes are reserved
 		# "The High Surrogates (U+D800–U+DBFF) and Low Surrogate (U+DC00–U+DFFF) codes are reserved
 		# for encoding non-BMP characters in UTF-16 by using a pair of 16-bit codes: one High Surrogate
 		# for encoding non-BMP characters in UTF-16 by using a pair of 16-bit codes: one High Surrogate
 		# and one Low Surrogate. A single surrogate code point will never be assigned a character.""
 		# and one Low Surrogate. A single surrogate code point will never be assigned a character.""
-		# 
+		#
 		# The main offender seems to be \uD835 as a stand alone character, which would be the first
 		# The main offender seems to be \uD835 as a stand alone character, which would be the first
 		# 16-bit character of a blackboard bold character (http://www.fileformat.info/info/unicode/char/1d400/index.htm).
 		# 16-bit character of a blackboard bold character (http://www.fileformat.info/info/unicode/char/1d400/index.htm).
 		# Something must be going on client side that is screwing up the encoding and splitting the
 		# Something must be going on client side that is screwing up the encoding and splitting the
@@ -128,3 +129,12 @@ module.exports = UpdateManager =
 				op.i = op.i.replace(/[\uD800-\uDFFF]/g, "\uFFFD")
 				op.i = op.i.replace(/[\uD800-\uDFFF]/g, "\uFFFD")
 		return update
 		return update
 
 
+	_addProjectHistoryMetadataToOps: (ops, pathname, lines) ->
+		doc_length = _.reduce lines,
+			(chars, line) -> chars + line.length,
+			0
+		doc_length += lines.length - 1  # count newline characters
+		ops.forEach (op) ->
+			op.meta ||= {}
+			op.meta.pathname = pathname
+			op.meta.doc_length = doc_length

+ 8 - 0
services/document-updater/config/settings.defaults.coffee

@@ -14,6 +14,9 @@ module.exports =
 			pass: "password"
 			pass: "password"
 		trackchanges:
 		trackchanges:
 			url: "http://localhost:3015"
 			url: "http://localhost:3015"
+		project_history:
+			enabled: process.env.SHARELATEX_ENABLE_PROJECT_HISTORY == 'true'
+			url: "http://localhost:3054"
 
 
 	redis:
 	redis:
 		realtime:
 		realtime:
@@ -41,6 +44,7 @@ module.exports =
 				projectKey: ({doc_id}) -> "ProjectId:#{doc_id}"
 				projectKey: ({doc_id}) -> "ProjectId:#{doc_id}"
 				docsInProject: ({project_id}) -> "DocsIn:#{project_id}"
 				docsInProject: ({project_id}) -> "DocsIn:#{project_id}"
 				ranges: ({doc_id}) -> "Ranges:#{doc_id}"
 				ranges: ({doc_id}) -> "Ranges:#{doc_id}"
+				pathname: ({doc_id}) -> "Pathname:#{doc_id}"
 				projectState: ({project_id}) -> "ProjectState:#{project_id}"
 				projectState: ({project_id}) -> "ProjectState:#{project_id}"
 				unflushedTime: ({doc_id}) -> "UnflushedTime:#{doc_id}"
 				unflushedTime: ({doc_id}) -> "UnflushedTime:#{doc_id}"
 			# cluster: [{
 			# cluster: [{
@@ -64,6 +68,10 @@ module.exports =
 			key_schema:
 			key_schema:
 				uncompressedHistoryOps: ({doc_id}) -> "UncompressedHistoryOps:#{doc_id}"
 				uncompressedHistoryOps: ({doc_id}) -> "UncompressedHistoryOps:#{doc_id}"
 				docsWithHistoryOps: ({project_id}) -> "DocsWithHistoryOps:#{project_id}"
 				docsWithHistoryOps: ({project_id}) -> "DocsWithHistoryOps:#{project_id}"
+
+		project_history:
+			key_schema:
+				projectHistoryOps: ({project_id}) -> "ProjectHistory:Ops:#{project_id}"
 			# cluster: [{
 			# cluster: [{
 			# 	port: "7000"
 			# 	port: "7000"
 			# 	host: "localhost"
 			# 	host: "localhost"

+ 20 - 8
services/document-updater/test/acceptance/coffee/ApplyingUpdatesToADocTests.coffee

@@ -8,6 +8,7 @@ rclient_history = require("redis-sharelatex").createClient(Settings.redis.histor
 rclient_du = require("redis-sharelatex").createClient(Settings.redis.documentupdater)
 rclient_du = require("redis-sharelatex").createClient(Settings.redis.documentupdater)
 Keys = Settings.redis.documentupdater.key_schema
 Keys = Settings.redis.documentupdater.key_schema
 HistoryKeys = Settings.redis.history.key_schema
 HistoryKeys = Settings.redis.history.key_schema
+ProjectHistoryKeys = Settings.redis.project_history.key_schema
 
 
 MockTrackChangesApi = require "./helpers/MockTrackChangesApi"
 MockTrackChangesApi = require "./helpers/MockTrackChangesApi"
 MockWebApi = require "./helpers/MockWebApi"
 MockWebApi = require "./helpers/MockWebApi"
@@ -58,6 +59,11 @@ describe "Applying updates to a doc", ->
 					result.should.equal 1
 					result.should.equal 1
 					done()
 					done()
 
 
+		it "should push the applied updates to the project history changes api", (done) ->
+			rclient_history.lrange ProjectHistoryKeys.projectHistoryOps({@project_id}), 0, -1, (error, updates) =>
+				throw error if error?
+				JSON.parse(updates[0]).op.should.deep.equal @update.op
+				done()
 
 
 	describe "when the document is loaded", ->
 	describe "when the document is loaded", ->
 		before (done) ->
 		before (done) ->
@@ -89,6 +95,12 @@ describe "Applying updates to a doc", ->
 					result.should.equal 1
 					result.should.equal 1
 					done()
 					done()
 
 
+		it "should push the applied updates to the project history changes api", (done) ->
+			rclient_history.lrange ProjectHistoryKeys.projectHistoryOps({@project_id}), 0, -1, (error, updates) =>
+				JSON.parse(updates[0]).op.should.deep.equal @update.op
+				done()
+
+
 	describe "when the document has been deleted", ->
 	describe "when the document has been deleted", ->
 		describe "when the ops come in a single linear order", ->
 		describe "when the ops come in a single linear order", ->
 			before (done) ->
 			before (done) ->
@@ -136,7 +148,7 @@ describe "Applying updates to a doc", ->
 					rclient_history.sismember HistoryKeys.docsWithHistoryOps({@project_id}), @doc_id, (error, result) =>
 					rclient_history.sismember HistoryKeys.docsWithHistoryOps({@project_id}), @doc_id, (error, result) =>
 						result.should.equal 1
 						result.should.equal 1
 						done()
 						done()
-			
+
 			it "should store the doc ops in the correct order", (done) ->
 			it "should store the doc ops in the correct order", (done) ->
 				rclient_du.lrange Keys.docOps({doc_id: @doc_id}), 0, -1, (error, updates) =>
 				rclient_du.lrange Keys.docOps({doc_id: @doc_id}), 0, -1, (error, updates) =>
 					updates = (JSON.parse(u) for u in updates)
 					updates = (JSON.parse(u) for u in updates)
@@ -181,7 +193,7 @@ describe "Applying updates to a doc", ->
 			[@project_id, @doc_id] = [DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]
 			[@project_id, @doc_id] = [DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]
 			@broken_update = { doc_id: @doc_id, v: @version, op: [d: "not the correct content", p: 0 ] }
 			@broken_update = { doc_id: @doc_id, v: @version, op: [d: "not the correct content", p: 0 ] }
 			MockWebApi.insertDoc @project_id, @doc_id, {lines: @lines, version: @version}
 			MockWebApi.insertDoc @project_id, @doc_id, {lines: @lines, version: @version}
-	
+
 			DocUpdaterClient.subscribeToAppliedOps @messageCallback = sinon.stub()
 			DocUpdaterClient.subscribeToAppliedOps @messageCallback = sinon.stub()
 
 
 			DocUpdaterClient.sendUpdate @project_id, @doc_id, @broken_update, (error) ->
 			DocUpdaterClient.sendUpdate @project_id, @doc_id, @broken_update, (error) ->
@@ -192,14 +204,14 @@ describe "Applying updates to a doc", ->
 			DocUpdaterClient.getDoc @project_id, @doc_id, (error, res, doc) =>
 			DocUpdaterClient.getDoc @project_id, @doc_id, (error, res, doc) =>
 				doc.lines.should.deep.equal @lines
 				doc.lines.should.deep.equal @lines
 				done()
 				done()
-		
+
 		it "should send a message with an error", ->
 		it "should send a message with an error", ->
 			@messageCallback.called.should.equal true
 			@messageCallback.called.should.equal true
 			[channel, message] = @messageCallback.args[0]
 			[channel, message] = @messageCallback.args[0]
 			channel.should.equal "applied-ops"
 			channel.should.equal "applied-ops"
 			JSON.parse(message).should.deep.equal {
 			JSON.parse(message).should.deep.equal {
 				project_id: @project_id,
 				project_id: @project_id,
-				doc_id: @doc_id, 
+				doc_id: @doc_id,
 				error:'Delete component \'not the correct content\' does not match deleted text \'one\ntwo\nthree\''
 				error:'Delete component \'not the correct content\' does not match deleted text \'one\ntwo\nthree\''
 			}
 			}
 
 
@@ -240,7 +252,7 @@ describe "Applying updates to a doc", ->
 				lines: @lines
 				lines: @lines
 			}
 			}
 
 
-			update = 
+			update =
 				doc: @doc_id
 				doc: @doc_id
 				op: @update.op
 				op: @update.op
 				v: 0
 				v: 0
@@ -252,12 +264,12 @@ describe "Applying updates to a doc", ->
 			DocUpdaterClient.getDoc @project_id, @doc_id, (error, res, doc) =>
 			DocUpdaterClient.getDoc @project_id, @doc_id, (error, res, doc) =>
 				doc.lines.should.deep.equal @result
 				doc.lines.should.deep.equal @result
 				done()
 				done()
-	
+
 	describe "when the sending duplicate ops", ->
 	describe "when the sending duplicate ops", ->
 		before (done) ->
 		before (done) ->
 			[@project_id, @doc_id] = [DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]
 			[@project_id, @doc_id] = [DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]
 			MockWebApi.insertDoc @project_id, @doc_id, {lines: @lines, version: @version}
 			MockWebApi.insertDoc @project_id, @doc_id, {lines: @lines, version: @version}
-	
+
 			DocUpdaterClient.subscribeToAppliedOps @messageCallback = sinon.stub()
 			DocUpdaterClient.subscribeToAppliedOps @messageCallback = sinon.stub()
 
 
 			# One user delete 'one', the next turns it into 'once'. The second becomes a NOP.
 			# One user delete 'one', the next turns it into 'once'. The second becomes a NOP.
@@ -292,7 +304,7 @@ describe "Applying updates to a doc", ->
 			DocUpdaterClient.getDoc @project_id, @doc_id, (error, res, doc) =>
 			DocUpdaterClient.getDoc @project_id, @doc_id, (error, res, doc) =>
 				doc.lines.should.deep.equal @result
 				doc.lines.should.deep.equal @result
 				done()
 				done()
-		
+
 		it "should return a message about duplicate ops", ->
 		it "should return a message about duplicate ops", ->
 			@messageCallback.calledTwice.should.equal true
 			@messageCallback.calledTwice.should.equal true
 			@messageCallback.args[0][0].should.equal "applied-ops"
 			@messageCallback.args[0][0].should.equal "applied-ops"

+ 12 - 6
services/document-updater/test/acceptance/coffee/DeletingADocumentTests.coffee

@@ -3,6 +3,7 @@ chai = require("chai")
 chai.should()
 chai.should()
 
 
 MockTrackChangesApi = require "./helpers/MockTrackChangesApi"
 MockTrackChangesApi = require "./helpers/MockTrackChangesApi"
+MockProjectHistoryApi = require "./helpers/MockProjectHistoryApi"
 MockWebApi = require "./helpers/MockWebApi"
 MockWebApi = require "./helpers/MockWebApi"
 DocUpdaterClient = require "./helpers/DocUpdaterClient"
 DocUpdaterClient = require "./helpers/DocUpdaterClient"
 
 
@@ -18,11 +19,13 @@ describe "Deleting a document", ->
 			}]
 			}]
 			v: @version
 			v: @version
 		@result = ["one", "one and a half", "two", "three"]
 		@result = ["one", "one and a half", "two", "three"]
-		
+
 		sinon.spy MockTrackChangesApi, "flushDoc"
 		sinon.spy MockTrackChangesApi, "flushDoc"
-	
+		sinon.spy MockProjectHistoryApi, "flushProject"
+
 	after ->
 	after ->
 		MockTrackChangesApi.flushDoc.restore()
 		MockTrackChangesApi.flushDoc.restore()
+		MockProjectHistoryApi.flushProject.restore()
 
 
 	describe "when the updated doc exists in the doc updater", ->
 	describe "when the updated doc exists in the doc updater", ->
 		before (done) ->
 		before (done) ->
@@ -60,10 +63,13 @@ describe "Deleting a document", ->
 					.calledWith(@project_id, @doc_id)
 					.calledWith(@project_id, @doc_id)
 					.should.equal true
 					.should.equal true
 				done()
 				done()
-		
+
 		it "should flush track changes", ->
 		it "should flush track changes", ->
 			MockTrackChangesApi.flushDoc.calledWith(@doc_id).should.equal true
 			MockTrackChangesApi.flushDoc.calledWith(@doc_id).should.equal true
 
 
+		it "should flush project history", ->
+			MockProjectHistoryApi.flushProject.calledWith(@project_id).should.equal true
+
 	describe "when the doc is not in the doc updater", ->
 	describe "when the doc is not in the doc updater", ->
 		before (done) ->
 		before (done) ->
 			[@project_id, @doc_id] = [DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]
 			[@project_id, @doc_id] = [DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]
@@ -93,9 +99,9 @@ describe "Deleting a document", ->
 					.calledWith(@project_id, @doc_id)
 					.calledWith(@project_id, @doc_id)
 					.should.equal true
 					.should.equal true
 				done()
 				done()
-		
+
 		it "should flush track changes", ->
 		it "should flush track changes", ->
 			MockTrackChangesApi.flushDoc.calledWith(@doc_id).should.equal true
 			MockTrackChangesApi.flushDoc.calledWith(@doc_id).should.equal true
 
 
-		
-
+		it "should flush project history", ->
+			MockProjectHistoryApi.flushProject.calledWith(@project_id).should.equal true

+ 8 - 4
services/document-updater/test/acceptance/coffee/DeletingAProjectTests.coffee

@@ -4,6 +4,7 @@ chai.should()
 async = require "async"
 async = require "async"
 
 
 MockTrackChangesApi = require "./helpers/MockTrackChangesApi"
 MockTrackChangesApi = require "./helpers/MockTrackChangesApi"
+MockProjectHistoryApi = require "./helpers/MockProjectHistoryApi"
 MockWebApi = require "./helpers/MockWebApi"
 MockWebApi = require "./helpers/MockWebApi"
 DocUpdaterClient = require "./helpers/DocUpdaterClient"
 DocUpdaterClient = require "./helpers/DocUpdaterClient"
 
 
@@ -38,11 +39,13 @@ describe "Deleting a project", ->
 				lines: doc.lines
 				lines: doc.lines
 				version: doc.update.v
 				version: doc.update.v
 			}
 			}
-			
+
 		sinon.spy MockTrackChangesApi, "flushDoc"
 		sinon.spy MockTrackChangesApi, "flushDoc"
-		
+		sinon.spy MockProjectHistoryApi, "flushProject"
+
 	after ->
 	after ->
 		MockTrackChangesApi.flushDoc.restore()
 		MockTrackChangesApi.flushDoc.restore()
+		MockProjectHistoryApi.flushProject.restore()
 
 
 	describe "with documents which have been updated", ->
 	describe "with documents which have been updated", ->
 		before (done) ->
 		before (done) ->
@@ -84,9 +87,10 @@ describe "Deleting a project", ->
 			), () ->
 			), () ->
 				MockWebApi.getDocument.restore()
 				MockWebApi.getDocument.restore()
 				done()
 				done()
-		
+
 		it "should flush each doc in track changes", ->
 		it "should flush each doc in track changes", ->
 			for doc in @docs
 			for doc in @docs
 				MockTrackChangesApi.flushDoc.calledWith(doc.id).should.equal true
 				MockTrackChangesApi.flushDoc.calledWith(doc.id).should.equal true
 
 
-
+		it "should flush each doc in project history", ->
+			MockProjectHistoryApi.flushProject.calledWith(@project_id).should.equal true

+ 20 - 14
services/document-updater/test/acceptance/coffee/SettingADocumentTests.coffee

@@ -7,6 +7,7 @@ rclient_du = require("redis-sharelatex").createClient(Settings.redis.documentupd
 Keys = Settings.redis.documentupdater.key_schema
 Keys = Settings.redis.documentupdater.key_schema
 
 
 MockTrackChangesApi = require "./helpers/MockTrackChangesApi"
 MockTrackChangesApi = require "./helpers/MockTrackChangesApi"
+MockProjectHistoryApi = require "./helpers/MockProjectHistoryApi"
 MockWebApi = require "./helpers/MockWebApi"
 MockWebApi = require "./helpers/MockWebApi"
 DocUpdaterClient = require "./helpers/DocUpdaterClient"
 DocUpdaterClient = require "./helpers/DocUpdaterClient"
 
 
@@ -25,14 +26,16 @@ describe "Setting a document", ->
 		@newLines = ["these", "are", "the", "new", "lines"]
 		@newLines = ["these", "are", "the", "new", "lines"]
 		@source = "dropbox"
 		@source = "dropbox"
 		@user_id = "user-id-123"
 		@user_id = "user-id-123"
-		
+
 		sinon.spy MockTrackChangesApi, "flushDoc"
 		sinon.spy MockTrackChangesApi, "flushDoc"
+		sinon.spy MockProjectHistoryApi, "flushProject"
 		sinon.spy MockWebApi, "setDocument"
 		sinon.spy MockWebApi, "setDocument"
-	
+
 	after ->
 	after ->
-		MockWebApi.setDocument.restore()
 		MockTrackChangesApi.flushDoc.restore()
 		MockTrackChangesApi.flushDoc.restore()
-				
+		MockProjectHistoryApi.flushProject.restore()
+		MockWebApi.setDocument.restore()
+
 	describe "when the updated doc exists in the doc updater", ->
 	describe "when the updated doc exists in the doc updater", ->
 		before (done) ->
 		before (done) ->
 			[@project_id, @doc_id] = [DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]
 			[@project_id, @doc_id] = [DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]
@@ -64,13 +67,13 @@ describe "Setting a document", ->
 			DocUpdaterClient.getDoc @project_id, @doc_id, (error, res, doc) =>
 			DocUpdaterClient.getDoc @project_id, @doc_id, (error, res, doc) =>
 				doc.version.should.equal @version + 2
 				doc.version.should.equal @version + 2
 				done()
 				done()
-		
+
 		it "should leave the document in redis", (done) ->
 		it "should leave the document in redis", (done) ->
 			rclient_du.get Keys.docLines({doc_id: @doc_id}), (error, lines) =>
 			rclient_du.get Keys.docLines({doc_id: @doc_id}), (error, lines) =>
 				throw error if error?
 				throw error if error?
 				expect(JSON.parse(lines)).to.deep.equal @newLines
 				expect(JSON.parse(lines)).to.deep.equal @newLines
 				done()
 				done()
-	
+
 	describe "when the updated doc does not exist in the doc updater", ->
 	describe "when the updated doc does not exist in the doc updater", ->
 		before (done) ->
 		before (done) ->
 			[@project_id, @doc_id] = [DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]
 			[@project_id, @doc_id] = [DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]
@@ -78,7 +81,7 @@ describe "Setting a document", ->
 			DocUpdaterClient.setDocLines @project_id, @doc_id, @newLines, @source, @user_id, false, (error, res, body) =>
 			DocUpdaterClient.setDocLines @project_id, @doc_id, @newLines, @source, @user_id, false, (error, res, body) =>
 				@statusCode = res.statusCode
 				@statusCode = res.statusCode
 				setTimeout done, 200
 				setTimeout done, 200
-		
+
 		it "should return a 204 status code", ->
 		it "should return a 204 status code", ->
 			@statusCode.should.equal 204
 			@statusCode.should.equal 204
 
 
@@ -86,16 +89,19 @@ describe "Setting a document", ->
 			MockWebApi.setDocument
 			MockWebApi.setDocument
 				.calledWith(@project_id, @doc_id, @newLines)
 				.calledWith(@project_id, @doc_id, @newLines)
 				.should.equal true
 				.should.equal true
-		
+
 		it "should flush track changes", ->
 		it "should flush track changes", ->
 			MockTrackChangesApi.flushDoc.calledWith(@doc_id).should.equal true
 			MockTrackChangesApi.flushDoc.calledWith(@doc_id).should.equal true
-		
+
+		it "should flush project history", ->
+			MockProjectHistoryApi.flushProject.calledWith(@project_id).should.equal true
+
 		it "should remove the document from redis", (done) ->
 		it "should remove the document from redis", (done) ->
 			rclient_du.get Keys.docLines({doc_id: @doc_id}), (error, lines) =>
 			rclient_du.get Keys.docLines({doc_id: @doc_id}), (error, lines) =>
 				throw error if error?
 				throw error if error?
 				expect(lines).to.not.exist
 				expect(lines).to.not.exist
 				done()
 				done()
-	
+
 	describe "with track changes", ->
 	describe "with track changes", ->
 		before ->
 		before ->
 			@lines = ["one", "one and a half", "two", "three"]
 			@lines = ["one", "one and a half", "two", "three"]
@@ -123,14 +129,14 @@ describe "Setting a document", ->
 						DocUpdaterClient.setDocLines @project_id, @doc_id, @lines, @source, @user_id, true, (error, res, body) =>
 						DocUpdaterClient.setDocLines @project_id, @doc_id, @lines, @source, @user_id, true, (error, res, body) =>
 							@statusCode = res.statusCode
 							@statusCode = res.statusCode
 							setTimeout done, 200
 							setTimeout done, 200
-			
+
 			it "should undo the tracked changes", (done) ->
 			it "should undo the tracked changes", (done) ->
 				DocUpdaterClient.getDoc @project_id, @doc_id, (error, res, data) =>
 				DocUpdaterClient.getDoc @project_id, @doc_id, (error, res, data) =>
 					throw error if error?
 					throw error if error?
 					ranges = data.ranges
 					ranges = data.ranges
 					expect(ranges.changes).to.be.undefined
 					expect(ranges.changes).to.be.undefined
 					done()
 					done()
-			
+
 		describe "without the undo flag", ->
 		describe "without the undo flag", ->
 			before (done) ->
 			before (done) ->
 				[@project_id, @doc_id] = [DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]
 				[@project_id, @doc_id] = [DocUpdaterClient.randomId(), DocUpdaterClient.randomId()]
@@ -143,7 +149,7 @@ describe "Setting a document", ->
 						DocUpdaterClient.setDocLines @project_id, @doc_id, @lines, @source, @user_id, false, (error, res, body) =>
 						DocUpdaterClient.setDocLines @project_id, @doc_id, @lines, @source, @user_id, false, (error, res, body) =>
 							@statusCode = res.statusCode
 							@statusCode = res.statusCode
 							setTimeout done, 200
 							setTimeout done, 200
-			
+
 			it "should not undo the tracked changes", (done) ->
 			it "should not undo the tracked changes", (done) ->
 				DocUpdaterClient.getDoc @project_id, @doc_id, (error, res, data) =>
 				DocUpdaterClient.getDoc @project_id, @doc_id, (error, res, data) =>
 					throw error if error?
 					throw error if error?
@@ -151,4 +157,4 @@ describe "Setting a document", ->
 					expect(ranges.changes.length).to.equal 1
 					expect(ranges.changes.length).to.equal 1
 					done()
 					done()
 
 
-				
+

+ 19 - 0
services/document-updater/test/acceptance/coffee/helpers/MockProjectHistoryApi.coffee

@@ -0,0 +1,19 @@
+express = require("express")
+app = express()
+
+module.exports = MockProjectHistoryApi =
+	flushProject: (doc_id, callback = (error) ->) ->
+		callback()
+
+	run: () ->
+		app.post "/project/:project_id/flush", (req, res, next) =>
+			@flushProject req.params.project_id, (error) ->
+				if error?
+					res.send 500
+				else
+					res.send 204
+
+		app.listen 3054, (error) ->
+			throw error if error?
+
+MockProjectHistoryApi.run()

+ 2 - 0
services/document-updater/test/acceptance/coffee/helpers/MockWebApi.coffee

@@ -9,6 +9,7 @@ module.exports = MockWebApi =
 	insertDoc: (project_id, doc_id, doc) ->
 	insertDoc: (project_id, doc_id, doc) ->
 		doc.version ?= 0
 		doc.version ?= 0
 		doc.lines ?= []
 		doc.lines ?= []
+		doc.pathname = '/a/b/c.tex'
 		@docs["#{project_id}:#{doc_id}"] = doc
 		@docs["#{project_id}:#{doc_id}"] = doc
 
 
 	setDocument: (project_id, doc_id, lines, version, ranges, callback = (error) ->) ->
 	setDocument: (project_id, doc_id, lines, version, ranges, callback = (error) ->) ->
@@ -16,6 +17,7 @@ module.exports = MockWebApi =
 		doc.lines = lines
 		doc.lines = lines
 		doc.version = version
 		doc.version = version
 		doc.ranges = ranges
 		doc.ranges = ranges
+		doc.pathname = '/a/b/c.tex'
 		callback null
 		callback null
 
 
 	getDocument: (project_id, doc_id, callback = (error, doc) ->) ->
 	getDocument: (project_id, doc_id, callback = (error, doc) ->) ->

+ 43 - 42
services/document-updater/test/unit/coffee/DocumentManager/DocumentManagerTests.coffee

@@ -27,6 +27,7 @@ describe "DocumentManager", ->
 		@lines = ["one", "two", "three"]
 		@lines = ["one", "two", "three"]
 		@version = 42
 		@version = 42
 		@ranges = { comments: "mock", entries: "mock" }
 		@ranges = { comments: "mock", entries: "mock" }
+		@pathname = '/a/b/c.tex'
 		@unflushedTime = Date.now()
 		@unflushedTime = Date.now()
 
 
 	describe "flushAndDeleteDoc", ->
 	describe "flushAndDeleteDoc", ->
@@ -34,9 +35,9 @@ describe "DocumentManager", ->
 			beforeEach ->
 			beforeEach ->
 				@RedisManager.removeDocFromMemory = sinon.stub().callsArg(2)
 				@RedisManager.removeDocFromMemory = sinon.stub().callsArg(2)
 				@DocumentManager.flushDocIfLoaded = sinon.stub().callsArgWith(2)
 				@DocumentManager.flushDocIfLoaded = sinon.stub().callsArgWith(2)
-				@HistoryManager.flushDocChanges = sinon.stub().callsArg(2)
+				@HistoryManager.flushChangesAsync = sinon.stub()
 				@DocumentManager.flushAndDeleteDoc @project_id, @doc_id, @callback
 				@DocumentManager.flushAndDeleteDoc @project_id, @doc_id, @callback
-			
+
 			it "should flush the doc", ->
 			it "should flush the doc", ->
 				@DocumentManager.flushDocIfLoaded
 				@DocumentManager.flushDocIfLoaded
 					.calledWith(@project_id, @doc_id)
 					.calledWith(@project_id, @doc_id)
@@ -52,12 +53,12 @@ describe "DocumentManager", ->
 
 
 			it "should time the execution", ->
 			it "should time the execution", ->
 				@Metrics.Timer::done.called.should.equal true
 				@Metrics.Timer::done.called.should.equal true
-			
+
 			it "should flush to the history api", ->
 			it "should flush to the history api", ->
-				@HistoryManager.flushDocChanges
+				@HistoryManager.flushChangesAsync
 					.calledWith(@project_id, @doc_id)
 					.calledWith(@project_id, @doc_id)
 					.should.equal true
 					.should.equal true
-	
+
 	describe "flushDocIfLoaded", ->
 	describe "flushDocIfLoaded", ->
 		describe "when the doc is in Redis", ->
 		describe "when the doc is in Redis", ->
 			beforeEach ->
 			beforeEach ->
@@ -75,7 +76,7 @@ describe "DocumentManager", ->
 				@PersistenceManager.setDoc
 				@PersistenceManager.setDoc
 					.calledWith(@project_id, @doc_id, @lines, @version, @ranges)
 					.calledWith(@project_id, @doc_id, @lines, @version, @ranges)
 					.should.equal true
 					.should.equal true
-			
+
 			it "should call the callback without error", ->
 			it "should call the callback without error", ->
 				@callback.calledWith(null).should.equal true
 				@callback.calledWith(null).should.equal true
 
 
@@ -103,7 +104,7 @@ describe "DocumentManager", ->
 
 
 			it "should time the execution", ->
 			it "should time the execution", ->
 				@Metrics.Timer::done.called.should.equal true
 				@Metrics.Timer::done.called.should.equal true
-	
+
 	describe "getDocAndRecentOps", ->
 	describe "getDocAndRecentOps", ->
 		describe "with a previous version specified", ->
 		describe "with a previous version specified", ->
 			beforeEach ->
 			beforeEach ->
@@ -146,28 +147,28 @@ describe "DocumentManager", ->
 
 
 			it "should time the execution", ->
 			it "should time the execution", ->
 				@Metrics.Timer::done.called.should.equal true
 				@Metrics.Timer::done.called.should.equal true
-	
+
 	describe "getDoc", ->
 	describe "getDoc", ->
 		describe "when the doc exists in Redis", ->
 		describe "when the doc exists in Redis", ->
 			beforeEach ->
 			beforeEach ->
-				@RedisManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges, @unflushedTime)
+				@RedisManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges, @pathname, @unflushedTime)
 				@DocumentManager.getDoc @project_id, @doc_id, @callback
 				@DocumentManager.getDoc @project_id, @doc_id, @callback
 
 
 			it "should get the doc from Redis", ->
 			it "should get the doc from Redis", ->
 				@RedisManager.getDoc
 				@RedisManager.getDoc
 					.calledWith(@project_id, @doc_id)
 					.calledWith(@project_id, @doc_id)
 					.should.equal true
 					.should.equal true
-			
+
 			it "should call the callback with the doc info", ->
 			it "should call the callback with the doc info", ->
-				@callback.calledWith(null, @lines, @version, @ranges, @unflushedTime, true).should.equal true
+				@callback.calledWith(null, @lines, @version, @ranges, @pathname, @unflushedTime, true).should.equal true
 
 
 			it "should time the execution", ->
 			it "should time the execution", ->
 				@Metrics.Timer::done.called.should.equal true
 				@Metrics.Timer::done.called.should.equal true
 
 
 		describe "when the doc does not exist in Redis", ->
 		describe "when the doc does not exist in Redis", ->
 			beforeEach ->
 			beforeEach ->
-				@RedisManager.getDoc = sinon.stub().callsArgWith(2, null, null, null, null, null)
-				@PersistenceManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges)
+				@RedisManager.getDoc = sinon.stub().callsArgWith(2, null, null, null, null, null, null)
+				@PersistenceManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges, @pathname)
 				@RedisManager.putDocInMemory = sinon.stub().yields()
 				@RedisManager.putDocInMemory = sinon.stub().yields()
 				@DocumentManager.getDoc @project_id, @doc_id, @callback
 				@DocumentManager.getDoc @project_id, @doc_id, @callback
 
 
@@ -183,22 +184,22 @@ describe "DocumentManager", ->
 
 
 			it "should set the doc in Redis", ->
 			it "should set the doc in Redis", ->
 				@RedisManager.putDocInMemory
 				@RedisManager.putDocInMemory
-					.calledWith(@project_id, @doc_id, @lines, @version, @ranges)
+					.calledWith(@project_id, @doc_id, @lines, @version, @ranges, @pathname)
 					.should.equal true
 					.should.equal true
 
 
 			it "should call the callback with the doc info", ->
 			it "should call the callback with the doc info", ->
-				@callback.calledWith(null, @lines, @version, @ranges, null, false).should.equal true
+				@callback.calledWith(null, @lines, @version, @ranges, @pathname, null, false).should.equal true
 
 
 			it "should time the execution", ->
 			it "should time the execution", ->
 				@Metrics.Timer::done.called.should.equal true
 				@Metrics.Timer::done.called.should.equal true
-	
+
 	describe "setDoc", ->
 	describe "setDoc", ->
 		describe "with plain tex lines", ->
 		describe "with plain tex lines", ->
 			beforeEach ->
 			beforeEach ->
 				@beforeLines = ["before", "lines"]
 				@beforeLines = ["before", "lines"]
 				@afterLines = ["after", "lines"]
 				@afterLines = ["after", "lines"]
 				@ops = [{ i: "foo", p: 4 }, { d: "bar", p: 42 }]
 				@ops = [{ i: "foo", p: 4 }, { d: "bar", p: 42 }]
-				@DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @beforeLines, @version, @ranges, @unflushedTime, true)
+				@DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @beforeLines, @version, @ranges, @pathname, @unflushedTime, true)
 				@DiffCodec.diffAsShareJsOp = sinon.stub().callsArgWith(2, null, @ops)
 				@DiffCodec.diffAsShareJsOp = sinon.stub().callsArgWith(2, null, @ops)
 				@UpdateManager.applyUpdate = sinon.stub().callsArgWith(3, null)
 				@UpdateManager.applyUpdate = sinon.stub().callsArgWith(3, null)
 				@DocumentManager.flushDocIfLoaded = sinon.stub().callsArg(2)
 				@DocumentManager.flushDocIfLoaded = sinon.stub().callsArg(2)
@@ -240,16 +241,16 @@ describe "DocumentManager", ->
 					@DocumentManager.flushDocIfLoaded
 					@DocumentManager.flushDocIfLoaded
 						.calledWith(@project_id, @doc_id)
 						.calledWith(@project_id, @doc_id)
 						.should.equal true
 						.should.equal true
-				
+
 				it "should call the callback", ->
 				it "should call the callback", ->
 					@callback.calledWith(null).should.equal true
 					@callback.calledWith(null).should.equal true
 
 
 				it "should time the execution", ->
 				it "should time the execution", ->
 					@Metrics.Timer::done.called.should.equal true
 					@Metrics.Timer::done.called.should.equal true
-			
+
 			describe "when not already loaded", ->
 			describe "when not already loaded", ->
 				beforeEach ->
 				beforeEach ->
-					@DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @beforeLines, @version, null, false)
+					@DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @beforeLines, @version, @pathname, null, false)
 					@DocumentManager.setDoc @project_id, @doc_id, @afterLines, @source, @user_id, false, @callback
 					@DocumentManager.setDoc @project_id, @doc_id, @afterLines, @source, @user_id, false, @callback
 
 
 				it "should flush and delete the doc from the doc updater", ->
 				it "should flush and delete the doc from the doc updater", ->
@@ -263,21 +264,21 @@ describe "DocumentManager", ->
 
 
 				it "should return the callback with an error", ->
 				it "should return the callback with an error", ->
 					@callback.calledWith(new Error("No lines were passed to setDoc"))
 					@callback.calledWith(new Error("No lines were passed to setDoc"))
-					
+
 				it "should not try to get the doc lines", ->
 				it "should not try to get the doc lines", ->
 					@DocumentManager.getDoc.called.should.equal false
 					@DocumentManager.getDoc.called.should.equal false
-			
+
 			describe "with the undoing flag", ->
 			describe "with the undoing flag", ->
 				beforeEach ->
 				beforeEach ->
 					# Copy ops so we don't interfere with other tests
 					# Copy ops so we don't interfere with other tests
 					@ops = [{ i: "foo", p: 4 }, { d: "bar", p: 42 }]
 					@ops = [{ i: "foo", p: 4 }, { d: "bar", p: 42 }]
 					@DiffCodec.diffAsShareJsOp = sinon.stub().callsArgWith(2, null, @ops)
 					@DiffCodec.diffAsShareJsOp = sinon.stub().callsArgWith(2, null, @ops)
 					@DocumentManager.setDoc @project_id, @doc_id, @afterLines, @source, @user_id, true, @callback
 					@DocumentManager.setDoc @project_id, @doc_id, @afterLines, @source, @user_id, true, @callback
-				
+
 				it "should set the undo flag on each op", ->
 				it "should set the undo flag on each op", ->
 					for op in @ops
 					for op in @ops
 						op.u.should.equal true
 						op.u.should.equal true
-	
+
 	describe "acceptChanges", ->
 	describe "acceptChanges", ->
 		beforeEach ->
 		beforeEach ->
 			@change_id = "mock-change-id"
 			@change_id = "mock-change-id"
@@ -289,33 +290,33 @@ describe "DocumentManager", ->
 			@DocumentManager.getDoc = sinon.stub().yields(null, @lines, @version, @ranges)
 			@DocumentManager.getDoc = sinon.stub().yields(null, @lines, @version, @ranges)
 			@RangesManager.acceptChanges = sinon.stub().yields(null, @updated_ranges)
 			@RangesManager.acceptChanges = sinon.stub().yields(null, @updated_ranges)
 			@RedisManager.updateDocument = sinon.stub().yields()
 			@RedisManager.updateDocument = sinon.stub().yields()
-		
+
 		describe "successfully with a single change", ->
 		describe "successfully with a single change", ->
 			beforeEach ->
 			beforeEach ->
 				@DocumentManager.acceptChanges @project_id, @doc_id, [ @change_id ], @callback
 				@DocumentManager.acceptChanges @project_id, @doc_id, [ @change_id ], @callback
-			
+
 			it "should get the document's current ranges", ->
 			it "should get the document's current ranges", ->
 				@DocumentManager.getDoc
 				@DocumentManager.getDoc
 					.calledWith(@project_id, @doc_id)
 					.calledWith(@project_id, @doc_id)
 					.should.equal true
 					.should.equal true
-			
+
 			it "should apply the accept change to the ranges", ->
 			it "should apply the accept change to the ranges", ->
 				@RangesManager.acceptChanges
 				@RangesManager.acceptChanges
 					.calledWith([ @change_id ], @ranges)
 					.calledWith([ @change_id ], @ranges)
 					.should.equal true
 					.should.equal true
-					
+
 			it "should save the updated ranges", ->
 			it "should save the updated ranges", ->
 				@RedisManager.updateDocument
 				@RedisManager.updateDocument
-					.calledWith(@doc_id, @lines, @version, [], @updated_ranges)
+					.calledWith(@project_id, @doc_id, @lines, @version, [], @updated_ranges)
 					.should.equal true
 					.should.equal true
-			
+
 			it "should call the callback", ->
 			it "should call the callback", ->
 				@callback.called.should.equal true
 				@callback.called.should.equal true
 
 
 		describe "successfully with multiple changes", ->
 		describe "successfully with multiple changes", ->
 			beforeEach ->
 			beforeEach ->
 				@DocumentManager.acceptChanges @project_id, @doc_id, @change_ids, @callback
 				@DocumentManager.acceptChanges @project_id, @doc_id, @change_ids, @callback
-			
+
 			it "should apply the accept change to the ranges", ->
 			it "should apply the accept change to the ranges", ->
 				@RangesManager.acceptChanges
 				@RangesManager.acceptChanges
 					.calledWith(@change_ids, @ranges)
 					.calledWith(@change_ids, @ranges)
@@ -328,11 +329,11 @@ describe "DocumentManager", ->
 
 
 			it "should not save anything", ->
 			it "should not save anything", ->
 				@RedisManager.updateDocument.called.should.equal false
 				@RedisManager.updateDocument.called.should.equal false
-			
+
 			it "should call the callback with a not found error", ->
 			it "should call the callback with a not found error", ->
 				error = new Errors.NotFoundError("document not found: #{@doc_id}")
 				error = new Errors.NotFoundError("document not found: #{@doc_id}")
 				@callback.calledWith(error).should.equal true
 				@callback.calledWith(error).should.equal true
-	
+
 	describe "deleteComment", ->
 	describe "deleteComment", ->
 		beforeEach ->
 		beforeEach ->
 			@comment_id = "mock-comment-id"
 			@comment_id = "mock-comment-id"
@@ -343,26 +344,26 @@ describe "DocumentManager", ->
 			@DocumentManager.getDoc = sinon.stub().yields(null, @lines, @version, @ranges)
 			@DocumentManager.getDoc = sinon.stub().yields(null, @lines, @version, @ranges)
 			@RangesManager.deleteComment = sinon.stub().yields(null, @updated_ranges)
 			@RangesManager.deleteComment = sinon.stub().yields(null, @updated_ranges)
 			@RedisManager.updateDocument = sinon.stub().yields()
 			@RedisManager.updateDocument = sinon.stub().yields()
-		
+
 		describe "successfully", ->
 		describe "successfully", ->
 			beforeEach ->
 			beforeEach ->
 				@DocumentManager.deleteComment @project_id, @doc_id, @comment_id, @callback
 				@DocumentManager.deleteComment @project_id, @doc_id, @comment_id, @callback
-			
+
 			it "should get the document's current ranges", ->
 			it "should get the document's current ranges", ->
 				@DocumentManager.getDoc
 				@DocumentManager.getDoc
 					.calledWith(@project_id, @doc_id)
 					.calledWith(@project_id, @doc_id)
 					.should.equal true
 					.should.equal true
-			
+
 			it "should delete the comment from the ranges", ->
 			it "should delete the comment from the ranges", ->
 				@RangesManager.deleteComment
 				@RangesManager.deleteComment
 					.calledWith(@comment_id, @ranges)
 					.calledWith(@comment_id, @ranges)
 					.should.equal true
 					.should.equal true
-					
+
 			it "should save the updated ranges", ->
 			it "should save the updated ranges", ->
 				@RedisManager.updateDocument
 				@RedisManager.updateDocument
-					.calledWith(@doc_id, @lines, @version, [], @updated_ranges)
+					.calledWith(@project_id, @doc_id, @lines, @version, [], @updated_ranges)
 					.should.equal true
 					.should.equal true
-			
+
 			it "should call the callback", ->
 			it "should call the callback", ->
 				@callback.called.should.equal true
 				@callback.called.should.equal true
 
 
@@ -373,7 +374,7 @@ describe "DocumentManager", ->
 
 
 			it "should not save anything", ->
 			it "should not save anything", ->
 				@RedisManager.updateDocument.called.should.equal false
 				@RedisManager.updateDocument.called.should.equal false
-			
+
 			it "should call the callback with a not found error", ->
 			it "should call the callback with a not found error", ->
 				error = new Errors.NotFoundError("document not found: #{@doc_id}")
 				error = new Errors.NotFoundError("document not found: #{@doc_id}")
 				@callback.calledWith(error).should.equal true
 				@callback.calledWith(error).should.equal true
@@ -389,7 +390,7 @@ describe "DocumentManager", ->
 		describe "when the doc is in Redis", ->
 		describe "when the doc is in Redis", ->
 			describe "and has changes to be flushed", ->
 			describe "and has changes to be flushed", ->
 				beforeEach ->
 				beforeEach ->
-					@DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges, Date.now() - 1e9, true)
+					@DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges, @pathname, Date.now() - 1e9, true)
 					@DocumentManager.getDocAndFlushIfOld @project_id, @doc_id, @callback
 					@DocumentManager.getDocAndFlushIfOld @project_id, @doc_id, @callback
 
 
 				it "should get the doc", ->
 				it "should get the doc", ->
@@ -407,7 +408,7 @@ describe "DocumentManager", ->
 
 
 			describe "and has only changes that don't need to be flushed", ->
 			describe "and has only changes that don't need to be flushed", ->
 				beforeEach ->
 				beforeEach ->
-					@DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges, Date.now() - 100, true)
+					@DocumentManager.getDoc = sinon.stub().callsArgWith(2, null, @lines, @version, @ranges, @pathname, Date.now() - 100, true)
 					@DocumentManager.getDocAndFlushIfOld @project_id, @doc_id, @callback
 					@DocumentManager.getDocAndFlushIfOld @project_id, @doc_id, @callback
 
 
 				it "should get the doc", ->
 				it "should get the doc", ->

+ 135 - 70
services/document-updater/test/unit/coffee/HistoryManager/HistoryManagerTests.coffee

@@ -7,106 +7,171 @@ describe "HistoryManager", ->
 	beforeEach ->
 	beforeEach ->
 		@HistoryManager = SandboxedModule.require modulePath, requires:
 		@HistoryManager = SandboxedModule.require modulePath, requires:
 			"request": @request = {}
 			"request": @request = {}
-			"settings-sharelatex": @Settings = {}
+			"settings-sharelatex": @Settings = {
+				apis:
+					project_history:
+						enabled: true
+						url: "http://project_history.example.com"
+					trackchanges:
+						url: "http://trackchanges.example.com"
+			}
 			"logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() }
 			"logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() }
 			"./HistoryRedisManager": @HistoryRedisManager = {}
 			"./HistoryRedisManager": @HistoryRedisManager = {}
 		@project_id = "mock-project-id"
 		@project_id = "mock-project-id"
 		@doc_id = "mock-doc-id"
 		@doc_id = "mock-doc-id"
 		@callback = sinon.stub()
 		@callback = sinon.stub()
 
 
-	describe "flushDocChanges", ->
+	describe "flushChangesAsync", ->
 		beforeEach ->
 		beforeEach ->
-			@Settings.apis =
-				trackchanges: url: "http://trackchanges.example.com"
+			@HistoryManager._flushDocChangesAsync = sinon.stub()
+			@HistoryManager._flushProjectChangesAsync = sinon.stub()
 
 
-		describe "successfully", ->
-			beforeEach ->
-				@request.post = sinon.stub().callsArgWith(1, null, statusCode: 204)
-				@HistoryManager.flushDocChanges @project_id, @doc_id, @callback
+			@HistoryManager.flushChangesAsync(@project_id, @doc_id)
 
 
-			it "should send a request to the track changes api", ->
-				@request.post
-					.calledWith("#{@Settings.apis.trackchanges.url}/project/#{@project_id}/doc/#{@doc_id}/flush")
-					.should.equal true
+		it "flushes doc changes", ->
+			@HistoryManager._flushDocChangesAsync
+				.calledWith(@project_id, @doc_id)
+				.should.equal true
 
 
-			it "should return the callback", ->
-				@callback.calledWith(null).should.equal true
+		it "flushes project changes", ->
+			@HistoryManager._flushProjectChangesAsync
+				.calledWith(@project_id)
+				.should.equal true
 
 
-		describe "when the track changes api returns an error", ->
-			beforeEach ->
-				@request.post = sinon.stub().callsArgWith(1, null, statusCode: 500)
-				@HistoryManager.flushDocChanges @project_id, @doc_id, @callback
+	describe "_flushDocChangesAsync", ->
+		beforeEach ->
+			@request.post = sinon.stub().callsArgWith(1, null, statusCode: 204)
+
+			@HistoryManager._flushDocChangesAsync @project_id, @doc_id
 
 
-			it "should return the callback with an error", ->
-				@callback.calledWith(new Error("track changes api return non-success code: 500")).should.equal true
+		it "should send a request to the track changes api", ->
+			@request.post
+				.calledWith("#{@Settings.apis.trackchanges.url}/project/#{@project_id}/doc/#{@doc_id}/flush")
+				.should.equal true
+
+	describe "_flushProjectChangesAsync", ->
+		beforeEach ->
+			@request.post = sinon.stub().callsArgWith(1, null, statusCode: 204)
+
+			@HistoryManager._flushProjectChangesAsync @project_id
+
+		it "should send a request to the project history api", ->
+			@request.post
+				.calledWith("#{@Settings.apis.project_history.url}/project/#{@project_id}/flush")
+				.should.equal true
 
 
 	describe "recordAndFlushHistoryOps", ->
 	describe "recordAndFlushHistoryOps", ->
 		beforeEach ->
 		beforeEach ->
-			@ops = ["mock-ops"]
-			@HistoryManager.flushDocChanges = sinon.stub().callsArg(2)
+			@ops = [ 'mock-ops' ]
+			@project_ops_length = 10
+			@doc_ops_length = 5
 
 
-		describe "pushing the op", ->
+			@HistoryManager._flushProjectChangesAsync = sinon.stub()
+			@HistoryRedisManager.recordDocHasHistoryOps = sinon.stub().callsArg(3)
+			@HistoryManager._flushDocChangesAsync = sinon.stub()
+
+		describe "with no ops", ->
 			beforeEach ->
 			beforeEach ->
-				@HistoryRedisManager.recordDocHasHistoryOps = sinon.stub().callsArgWith(3, null)
-				@HistoryManager.recordAndFlushHistoryOps @project_id, @doc_id, @ops, 1, @callback
+				@HistoryManager.recordAndFlushHistoryOps(
+					@project_id, @doc_id, [], @doc_ops_length, @project_ops_length, @callback
+				)
 
 
-			it "should push the ops into redis", ->
-				@HistoryRedisManager.recordDocHasHistoryOps
-					.calledWith(@project_id, @doc_id, @ops)
-					.should.equal true
+			it "should not flush project changes", ->
+				@HistoryManager._flushProjectChangesAsync.called.should.equal false
+
+			it "should not record doc has history ops", ->
+				@HistoryRedisManager.recordDocHasHistoryOps.called.should.equal false
+
+			it "should not flush doc changes", ->
+				@HistoryManager._flushDocChangesAsync.called.should.equal false
 
 
 			it "should call the callback", ->
 			it "should call the callback", ->
 				@callback.called.should.equal true
 				@callback.called.should.equal true
 
 
-			it "should not try to flush the op", ->
-				@HistoryManager.flushDocChanges.called.should.equal false
-
-		describe "when we hit a multiple of FLUSH_EVERY_N_OPS ops", ->
+		describe "with enough ops to flush project changes", ->
 			beforeEach ->
 			beforeEach ->
-				@HistoryRedisManager.recordDocHasHistoryOps =
-					sinon.stub().callsArgWith(3, null)
-				@HistoryManager.recordAndFlushHistoryOps @project_id, @doc_id, @ops, 2 * @HistoryManager.FLUSH_EVERY_N_OPS,@callback
+				@HistoryManager._shouldFlushHistoryOps = sinon.stub()
+				@HistoryManager._shouldFlushHistoryOps.withArgs(@project_ops_length).returns(true)
+				@HistoryManager._shouldFlushHistoryOps.withArgs(@doc_ops_length).returns(false)
 
 
-			it "should tell the track changes api to flush", ->
-				@HistoryManager.flushDocChanges
-					.calledWith(@project_id, @doc_id)
+				@HistoryManager.recordAndFlushHistoryOps(
+					@project_id, @doc_id, @ops, @doc_ops_length, @project_ops_length, @callback
+				)
+
+			it "should flush project changes", ->
+				@HistoryManager._flushProjectChangesAsync
+					.calledWith(@project_id)
 					.should.equal true
 					.should.equal true
 
 
-		describe "when we go over a multiple of FLUSH_EVERY_N_OPS ops", ->
+			it "should record doc has history ops", ->
+				@HistoryRedisManager.recordDocHasHistoryOps
+					.calledWith(@project_id, @doc_id, @ops)
+
+			it "should not flush doc changes", ->
+				@HistoryManager._flushDocChangesAsync.called.should.equal false
+
+			it "should call the callback", ->
+				@callback.called.should.equal true
+
+		describe "with enough ops to flush doc changes", ->
 			beforeEach ->
 			beforeEach ->
-				@ops = ["op1", "op2", "op3"]
-				@HistoryRedisManager.recordDocHasHistoryOps =
-					sinon.stub().callsArgWith(3, null)
-				@HistoryManager.recordAndFlushHistoryOps @project_id, @doc_id, @ops, 2 * @HistoryManager.FLUSH_EVERY_N_OPS + 1, @callback
+				@HistoryManager._shouldFlushHistoryOps = sinon.stub()
+				@HistoryManager._shouldFlushHistoryOps.withArgs(@project_ops_length).returns(false)
+				@HistoryManager._shouldFlushHistoryOps.withArgs(@doc_ops_length).returns(true)
+
+				@HistoryManager.recordAndFlushHistoryOps(
+					@project_id, @doc_id, @ops, @doc_ops_length, @project_ops_length, @callback
+				)
 
 
-			it "should tell the track changes api to flush", ->
-				@HistoryManager.flushDocChanges
+			it "should not flush project changes", ->
+				@HistoryManager._flushProjectChangesAsync.called.should.equal false
+
+			it "should record doc has history ops", ->
+				@HistoryRedisManager.recordDocHasHistoryOps
+					.calledWith(@project_id, @doc_id, @ops)
+
+			it "should flush doc changes", ->
+				@HistoryManager._flushDocChangesAsync
 					.calledWith(@project_id, @doc_id)
 					.calledWith(@project_id, @doc_id)
 					.should.equal true
 					.should.equal true
 
 
-		describe "when HistoryManager errors", ->
+			it "should call the callback", ->
+				@callback.called.should.equal true
+
+		describe "when recording doc has history ops errors", ->
 			beforeEach ->
 			beforeEach ->
+				@error = new Error("error")
 				@HistoryRedisManager.recordDocHasHistoryOps =
 				@HistoryRedisManager.recordDocHasHistoryOps =
-					sinon.stub().callsArgWith(3, null)
-				@HistoryManager.flushDocChanges = sinon.stub().callsArgWith(2, @error = new Error("oops"))
-				@HistoryManager.recordAndFlushHistoryOps @project_id, @doc_id, @ops, 2 * @HistoryManager.FLUSH_EVERY_N_OPS, @callback
-
-			it "should log out the error", ->
-				@logger.error
-					.calledWith(
-						err: @error
-						doc_id: @doc_id
-						project_id: @project_id
-						"error flushing doc to track changes api"
-					)
-					.should.equal true
-		
-		describe "with no ops", ->
-			beforeEach ->
-				@HistoryRedisManager.recordDocHasHistoryOps = sinon.stub().callsArgWith(3, null)
-				@HistoryManager.recordAndFlushHistoryOps @project_id, @doc_id, [], 1, @callback
-			
-			it "should not call HistoryRedisManager.recordDocHasHistoryOps", ->
-				@HistoryRedisManager.recordDocHasHistoryOps.called.should.equal false
-			
-
+					sinon.stub().callsArgWith(3, @error)
+
+				@HistoryManager.recordAndFlushHistoryOps(
+					@project_id, @doc_id, @ops, @doc_ops_length, @project_ops_length, @callback
+				)
+
+			it "should not flush doc changes", ->
+				@HistoryManager._flushDocChangesAsync.called.should.equal false
+
+			it "should call the callback with the error", ->
+				@callback.calledWith(@error).should.equal true
+
+		describe "_shouldFlushHistoryOps", ->
+			it "should return false if the number of ops is not known", ->
+				@HistoryManager._shouldFlushHistoryOps(null, ['a', 'b', 'c'], 1).should.equal false
+
+			it "should return false if the updates didn't take us past the threshold", ->
+				# Currently there are 14 ops
+				# Previously we were on 11 ops
+				# We didn't pass over a multiple of 5
+				@HistoryManager._shouldFlushHistoryOps(14, ['a', 'b', 'c'], 5).should.equal false
+
+		  it "should return true if the updates took to the threshold", ->
+				# Currently there are 15 ops
+				# Previously we were on 12 ops
+				# We've reached a new multiple of 5
+				@HistoryManager._shouldFlushHistoryOps(15, ['a', 'b', 'c'], 5).should.equal true
+
+			it "should return true if the updates took past the threshold", ->
+				# Currently there are 19 ops
+				# Previously we were on 16 ops
+				# We didn't pass over a multiple of 5
+				@HistoryManager._shouldFlushHistoryOps(17, ['a', 'b', 'c'], 5).should.equal true

+ 8 - 6
services/document-updater/test/unit/coffee/PersistenceManager/PersistenceManagerTests.coffee

@@ -22,6 +22,7 @@ describe "PersistenceManager", ->
 		@version = 42
 		@version = 42
 		@callback = sinon.stub()
 		@callback = sinon.stub()
 		@ranges = { comments: "mock", entries: "mock" }
 		@ranges = { comments: "mock", entries: "mock" }
+		@pathname = '/a/b/c.tex'
 		@Settings.apis =
 		@Settings.apis =
 			web:
 			web:
 				url: @url = "www.example.com"
 				url: @url = "www.example.com"
@@ -29,13 +30,14 @@ describe "PersistenceManager", ->
 				pass: @pass = "password"
 				pass: @pass = "password"
 
 
 	describe "getDoc", ->
 	describe "getDoc", ->
-	
+
 		describe "with a successful response from the web api", ->
 		describe "with a successful response from the web api", ->
 			beforeEach ->
 			beforeEach ->
 				@request.callsArgWith(1, null, {statusCode: 200}, JSON.stringify({
 				@request.callsArgWith(1, null, {statusCode: 200}, JSON.stringify({
 					lines: @lines,
 					lines: @lines,
 					version: @version,
 					version: @version,
 					ranges: @ranges
 					ranges: @ranges
+					pathname: @pathname,
 				}))
 				}))
 				@PersistenceManager.getDoc(@project_id, @doc_id, @callback)
 				@PersistenceManager.getDoc(@project_id, @doc_id, @callback)
 
 
@@ -56,7 +58,7 @@ describe "PersistenceManager", ->
 					.should.equal true
 					.should.equal true
 
 
 			it "should call the callback with the doc lines, version and ranges", ->
 			it "should call the callback with the doc lines, version and ranges", ->
-				@callback.calledWith(null, @lines, @version, @ranges).should.equal true
+				@callback.calledWith(null, @lines, @version, @ranges, @pathname).should.equal true
 
 
 			it "should time the execution", ->
 			it "should time the execution", ->
 				@Metrics.Timer::done.called.should.equal true
 				@Metrics.Timer::done.called.should.equal true
@@ -76,7 +78,7 @@ describe "PersistenceManager", ->
 			beforeEach ->
 			beforeEach ->
 				@request.callsArgWith(1, null, {statusCode: 404}, "")
 				@request.callsArgWith(1, null, {statusCode: 404}, "")
 				@PersistenceManager.getDoc(@project_id, @doc_id, @callback)
 				@PersistenceManager.getDoc(@project_id, @doc_id, @callback)
-				
+
 			it "should return a NotFoundError", ->
 			it "should return a NotFoundError", ->
 				@callback.calledWith(new Errors.NotFoundError("not found")).should.equal true
 				@callback.calledWith(new Errors.NotFoundError("not found")).should.equal true
 
 
@@ -87,7 +89,7 @@ describe "PersistenceManager", ->
 			beforeEach ->
 			beforeEach ->
 				@request.callsArgWith(1, null, {statusCode: 500}, "")
 				@request.callsArgWith(1, null, {statusCode: 500}, "")
 				@PersistenceManager.getDoc(@project_id, @doc_id, @callback)
 				@PersistenceManager.getDoc(@project_id, @doc_id, @callback)
-				
+
 			it "should return an error", ->
 			it "should return an error", ->
 				@callback.calledWith(new Error("web api error")).should.equal true
 				@callback.calledWith(new Error("web api error")).should.equal true
 
 
@@ -155,7 +157,7 @@ describe "PersistenceManager", ->
 			beforeEach ->
 			beforeEach ->
 				@request.callsArgWith(1, null, {statusCode: 404}, "")
 				@request.callsArgWith(1, null, {statusCode: 404}, "")
 				@PersistenceManager.setDoc(@project_id, @doc_id, @lines, @version, @ranges, @callback)
 				@PersistenceManager.setDoc(@project_id, @doc_id, @lines, @version, @ranges, @callback)
-				
+
 			it "should return a NotFoundError", ->
 			it "should return a NotFoundError", ->
 				@callback.calledWith(new Errors.NotFoundError("not found")).should.equal true
 				@callback.calledWith(new Errors.NotFoundError("not found")).should.equal true
 
 
@@ -166,7 +168,7 @@ describe "PersistenceManager", ->
 			beforeEach ->
 			beforeEach ->
 				@request.callsArgWith(1, null, {statusCode: 500}, "")
 				@request.callsArgWith(1, null, {statusCode: 500}, "")
 				@PersistenceManager.setDoc(@project_id, @doc_id, @lines, @version, @ranges, @callback)
 				@PersistenceManager.setDoc(@project_id, @doc_id, @lines, @version, @ranges, @callback)
-				
+
 			it "should return an error", ->
 			it "should return an error", ->
 				@callback.calledWith(new Error("web api error")).should.equal true
 				@callback.calledWith(new Error("web api error")).should.equal true
 
 

+ 209 - 148
services/document-updater/test/unit/coffee/RedisManager/RedisManagerTests.coffee

@@ -9,16 +9,16 @@ tk = require "timekeeper"
 
 
 describe "RedisManager", ->
 describe "RedisManager", ->
 	beforeEach ->
 	beforeEach ->
-		@rclient =
-			auth: () ->
-			exec: sinon.stub()
-		@rclient.multi = () => @rclient
+		@multi = exec: sinon.stub()
+		@rclient = multi: () => @multi
 		tk.freeze(new Date())
 		tk.freeze(new Date())
 		@RedisManager = SandboxedModule.require modulePath,
 		@RedisManager = SandboxedModule.require modulePath,
 			requires:
 			requires:
 				"logger-sharelatex": @logger = { error: sinon.stub(), log: sinon.stub(), warn: sinon.stub() }
 				"logger-sharelatex": @logger = { error: sinon.stub(), log: sinon.stub(), warn: sinon.stub() }
-				"settings-sharelatex": {
+				"settings-sharelatex": @settings = {
 					documentupdater: {logHashErrors: {write:true, read:true}}
 					documentupdater: {logHashErrors: {write:true, read:true}}
+					apis:
+						project_history: {enabled: true}
 					redis:
 					redis:
 						documentupdater:
 						documentupdater:
 							key_schema:
 							key_schema:
@@ -31,12 +31,16 @@ describe "RedisManager", ->
 								pendingUpdates: ({doc_id}) -> "PendingUpdates:#{doc_id}"
 								pendingUpdates: ({doc_id}) -> "PendingUpdates:#{doc_id}"
 								docsInProject: ({project_id}) -> "DocsIn:#{project_id}"
 								docsInProject: ({project_id}) -> "DocsIn:#{project_id}"
 								ranges: ({doc_id}) -> "Ranges:#{doc_id}"
 								ranges: ({doc_id}) -> "Ranges:#{doc_id}"
+								pathname: ({doc_id}) -> "Pathname:#{doc_id}"
 								projectState: ({project_id}) -> "ProjectState:#{project_id}"
 								projectState: ({project_id}) -> "ProjectState:#{project_id}"
 								unflushedTime: ({doc_id}) -> "UnflushedTime:#{doc_id}"
 								unflushedTime: ({doc_id}) -> "UnflushedTime:#{doc_id}"
 						history:
 						history:
 							key_schema:
 							key_schema:
 								uncompressedHistoryOps: ({doc_id}) -> "UncompressedHistoryOps:#{doc_id}"
 								uncompressedHistoryOps: ({doc_id}) -> "UncompressedHistoryOps:#{doc_id}"
 								docsWithHistoryOps: ({project_id}) -> "DocsWithHistoryOps:#{project_id}"
 								docsWithHistoryOps: ({project_id}) -> "DocsWithHistoryOps:#{project_id}"
+						project_history:
+							key_schema:
+								projectHistoryOps: ({project_id}) -> "ProjectHistory:Ops:#{project_id}"
 				}
 				}
 				"redis-sharelatex":
 				"redis-sharelatex":
 					createClient: () => @rclient
 					createClient: () => @rclient
@@ -54,7 +58,7 @@ describe "RedisManager", ->
 
 
 		afterEach ->
 		afterEach ->
 			tk.reset()
 			tk.reset()
-		
+
 		@doc_id = "doc-id-123"
 		@doc_id = "doc-id-123"
 		@project_id = "project-id-123"
 		@project_id = "project-id-123"
 		@callback = sinon.stub()
 		@callback = sinon.stub()
@@ -68,8 +72,9 @@ describe "RedisManager", ->
 			@ranges = { comments: "mock", entries: "mock" }
 			@ranges = { comments: "mock", entries: "mock" }
 			@json_ranges = JSON.stringify @ranges
 			@json_ranges = JSON.stringify @ranges
 			@unflushed_time = 12345
 			@unflushed_time = 12345
-			@rclient.get = sinon.stub()
-			@rclient.exec = sinon.stub().callsArgWith(0, null, [@jsonlines, @version, @hash, @project_id, @json_ranges, @unflushed_time])
+			@pathname = '/a/b/c.tex'
+			@multi.get = sinon.stub()
+			@multi.exec = sinon.stub().callsArgWith(0, null, [@jsonlines, @version, @hash, @project_id, @json_ranges, @pathname, @unflushed_time])
 			@rclient.sadd = sinon.stub().yields(null, 0)
 			@rclient.sadd = sinon.stub().yields(null, 0)
 
 
 		describe "successfully", ->
 		describe "successfully", ->
@@ -77,30 +82,35 @@ describe "RedisManager", ->
 				@RedisManager.getDoc @project_id, @doc_id, @callback
 				@RedisManager.getDoc @project_id, @doc_id, @callback
 
 
 			it "should get the lines from redis", ->
 			it "should get the lines from redis", ->
-				@rclient.get
+				@multi.get
 					.calledWith("doclines:#{@doc_id}")
 					.calledWith("doclines:#{@doc_id}")
 					.should.equal true
 					.should.equal true
-			
+
 			it "should get the version from", ->
 			it "should get the version from", ->
-				@rclient.get
+				@multi.get
 					.calledWith("DocVersion:#{@doc_id}")
 					.calledWith("DocVersion:#{@doc_id}")
 					.should.equal true
 					.should.equal true
 
 
 			it 'should get the hash', ->
 			it 'should get the hash', ->
-				@rclient.get
+				@multi.get
 					.calledWith("DocHash:#{@doc_id}")
 					.calledWith("DocHash:#{@doc_id}")
 					.should.equal true
 					.should.equal true
 
 
 			it "should get the ranges", ->
 			it "should get the ranges", ->
-				@rclient.get
+				@multi.get
 					.calledWith("Ranges:#{@doc_id}")
 					.calledWith("Ranges:#{@doc_id}")
 					.should.equal true
 					.should.equal true
 
 
 			it "should get the unflushed time", ->
 			it "should get the unflushed time", ->
-				@rclient.get
+				@multi.get
 					.calledWith("UnflushedTime:#{@doc_id}")
 					.calledWith("UnflushedTime:#{@doc_id}")
 					.should.equal true
 					.should.equal true
 
 
+			it "should get the pathname", ->
+				@multi.get
+					.calledWith("Pathname:#{@doc_id}")
+					.should.equal true
+
 			it "should check if the document is in the DocsIn set", ->
 			it "should check if the document is in the DocsIn set", ->
 				@rclient.sadd
 				@rclient.sadd
 					.calledWith("DocsIn:#{@project_id}")
 					.calledWith("DocsIn:#{@project_id}")
@@ -108,7 +118,7 @@ describe "RedisManager", ->
 
 
 			it 'should return the document', ->
 			it 'should return the document', ->
 				@callback
 				@callback
-					.calledWithExactly(null, @lines, @version, @ranges, @unflushed_time)
+					.calledWithExactly(null, @lines, @version, @ranges, @pathname, @unflushed_time)
 					.should.equal true
 					.should.equal true
 
 
 			it 'should not log any errors', ->
 			it 'should not log any errors', ->
@@ -117,7 +127,7 @@ describe "RedisManager", ->
 
 
 		describe "when the document is not present", ->
 		describe "when the document is not present", ->
 			beforeEach ->
 			beforeEach ->
-				@rclient.exec = sinon.stub().callsArgWith(0, null, [null, null, null, null, null])
+				@multi.exec = sinon.stub().callsArgWith(0, null, [null, null, null, null, null, null, null])
 				@rclient.sadd = sinon.stub().yields()
 				@rclient.sadd = sinon.stub().yields()
 				@RedisManager.getDoc @project_id, @doc_id, @callback
 				@RedisManager.getDoc @project_id, @doc_id, @callback
 
 
@@ -128,7 +138,7 @@ describe "RedisManager", ->
 
 
 			it 'should return an empty result', ->
 			it 'should return an empty result', ->
 				@callback
 				@callback
-					.calledWithExactly(null, null, 0, {})
+					.calledWithExactly(null, null, 0, {}, null, null)
 					.should.equal true
 					.should.equal true
 
 
 			it 'should not log any errors', ->
 			it 'should not log any errors', ->
@@ -146,13 +156,13 @@ describe "RedisManager", ->
 
 
 			it 'should return the document', ->
 			it 'should return the document', ->
 				@callback
 				@callback
-					.calledWithExactly(null, @lines, @version, @ranges, @unflushed_time)
+					.calledWithExactly(null, @lines, @version, @ranges, @pathname, @unflushed_time)
 					.should.equal true
 					.should.equal true
 
 
 		describe "with a corrupted document", ->
 		describe "with a corrupted document", ->
 			beforeEach ->
 			beforeEach ->
 				@badHash = "INVALID-HASH-VALUE"
 				@badHash = "INVALID-HASH-VALUE"
-				@rclient.exec = sinon.stub().callsArgWith(0, null, [@jsonlines, @version, @badHash, @project_id, @json_ranges])
+				@multi.exec = sinon.stub().callsArgWith(0, null, [@jsonlines, @version, @badHash, @project_id, @json_ranges])
 				@RedisManager.getDoc @project_id, @doc_id, @callback
 				@RedisManager.getDoc @project_id, @doc_id, @callback
 
 
 			it 'should log a hash error', ->
 			it 'should log a hash error', ->
@@ -167,11 +177,11 @@ describe "RedisManager", ->
 
 
 		describe "with a slow request to redis", ->
 		describe "with a slow request to redis", ->
 			beforeEach ->
 			beforeEach ->
-				@rclient.exec = sinon.stub().callsArgWith(0, null, [@jsonlines, @version, @badHash, @project_id, @json_ranges, @unflushed_time])
+				@multi.exec = sinon.stub().callsArgWith(0, null, [@jsonlines, @version, @badHash, @project_id, @json_ranges, @pathname, @unflushed_time])
 				@clock = sinon.useFakeTimers();
 				@clock = sinon.useFakeTimers();
-				@rclient.exec = (cb) =>
+				@multi.exec = (cb) =>
 					@clock.tick(6000);
 					@clock.tick(6000);
-					cb(null, [@jsonlines, @version, @another_project_id, @json_ranges, @unflushed_time])
+					cb(null, [@jsonlines, @version, @another_project_id, @json_ranges, @pathname, @unflushed_time])
 
 
 				@RedisManager.getDoc @project_id, @doc_id, @callback
 				@RedisManager.getDoc @project_id, @doc_id, @callback
 
 
@@ -186,7 +196,7 @@ describe "RedisManager", ->
 		describe "getDoc with an invalid project id", ->
 		describe "getDoc with an invalid project id", ->
 			beforeEach ->
 			beforeEach ->
 				@another_project_id = "project-id-456"
 				@another_project_id = "project-id-456"
-				@rclient.exec = sinon.stub().callsArgWith(0, null, [@jsonlines, @version, @another_project_id, @json_ranges, @unflushed_time])
+				@multi.exec = sinon.stub().callsArgWith(0, null, [@jsonlines, @version, @hash, @another_project_id, @json_ranges, @pathname, @unflushed_time])
 				@RedisManager.getDoc @project_id, @doc_id, @callback
 				@RedisManager.getDoc @project_id, @doc_id, @callback
 
 
 			it 'should return an error', ->
 			it 'should return an error', ->
@@ -271,7 +281,7 @@ describe "RedisManager", ->
 				@rclient.get = sinon.stub().callsArgWith(1, null, @version.toString())
 				@rclient.get = sinon.stub().callsArgWith(1, null, @version.toString())
 				@rclient.lrange = sinon.stub().callsArgWith(3, null, @jsonOps)
 				@rclient.lrange = sinon.stub().callsArgWith(3, null, @jsonOps)
 				@RedisManager.getPreviousDocOps(@doc_id, @start, @end, @callback)
 				@RedisManager.getPreviousDocOps(@doc_id, @start, @end, @callback)
-				
+
 			it "should return an error", ->
 			it "should return an error", ->
 				@callback.calledWith(new Errors.OpRangeNotAvailableError("doc ops range is not loaded in redis")).should.equal true
 				@callback.calledWith(new Errors.OpRangeNotAvailableError("doc ops range is not loaded in redis")).should.equal true
 
 
@@ -309,128 +319,168 @@ describe "RedisManager", ->
 
 
 	describe "updateDocument", ->
 	describe "updateDocument", ->
 		beforeEach ->
 		beforeEach ->
-			@rclient.set = sinon.stub()
-			@rclient.rpush = sinon.stub()
-			@rclient.expire = sinon.stub()
-			@rclient.ltrim = sinon.stub()
-			@rclient.del = sinon.stub()
-			@rclient.eval = sinon.stub()
-			@RedisManager.getDocVersion = sinon.stub()
-			
 			@lines = ["one", "two", "three", "これは"]
 			@lines = ["one", "two", "three", "これは"]
 			@ops = [{ op: [{ i: "foo", p: 4 }] },{ op: [{ i: "bar", p: 8 }] }]
 			@ops = [{ op: [{ i: "foo", p: 4 }] },{ op: [{ i: "bar", p: 8 }] }]
 			@version = 42
 			@version = 42
 			@hash = crypto.createHash('sha1').update(JSON.stringify(@lines),'utf8').digest('hex')
 			@hash = crypto.createHash('sha1').update(JSON.stringify(@lines),'utf8').digest('hex')
 			@ranges = { comments: "mock", entries: "mock" }
 			@ranges = { comments: "mock", entries: "mock" }
+			@doc_update_list_length = sinon.stub()
+			@project_update_list_length = sinon.stub()
 
 
-			@rclient.exec = sinon.stub().callsArg(0, null, [@hash])
+			@RedisManager.getDocVersion = sinon.stub()
+			@multi.set = sinon.stub()
+			@multi.rpush = sinon.stub()
+			@multi.expire = sinon.stub()
+			@multi.ltrim = sinon.stub()
+			@multi.del = sinon.stub()
+			@multi.eval = sinon.stub()
+			@multi.exec = sinon.stub().callsArgWith(0, null,
+				[@hash, null, null, null, null, null, null, @doc_update_list_length]
+			)
+			@rclient.rpush = sinon.stub().callsArgWith(@ops.length + 1, null, @project_update_list_length)
 
 
 		describe "with a consistent version", ->
 		describe "with a consistent version", ->
 			beforeEach ->
 			beforeEach ->
 				@RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version - @ops.length)
 				@RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version - @ops.length)
-				@RedisManager.updateDocument @doc_id, @lines, @version, @ops, @ranges, @callback
-		
-			it "should get the current doc version to check for consistency", ->
-				@RedisManager.getDocVersion
-					.calledWith(@doc_id)
-					.should.equal true
-		
-			it "should set the doclines", ->
-				@rclient.eval
-					.calledWith(sinon.match(/redis.call/), 1, "doclines:#{@doc_id}", JSON.stringify(@lines))
-					.should.equal true
-				
-			it "should set the version", ->
-				@rclient.set
-					.calledWith("DocVersion:#{@doc_id}", @version)
-					.should.equal true
-
-			it "should set the hash", ->
-				@rclient.set
-					.calledWith("DocHash:#{@doc_id}", @hash)
-					.should.equal true
-				
-			it "should set the ranges", ->
-				@rclient.set
-					.calledWith("Ranges:#{@doc_id}", JSON.stringify(@ranges))
-					.should.equal true
-
-			it "should set the unflushed time", ->
-				@rclient.set
-					.calledWith("UnflushedTime:#{@doc_id}", Date.now(), "NX")
-					.should.equal true
 
 
-			it "should push the doc op into the doc ops list", ->
-				@rclient.rpush
-					.calledWith("DocOps:#{@doc_id}", JSON.stringify(@ops[0]), JSON.stringify(@ops[1]))
-					.should.equal true
+			describe "with project history enabled", ->
+				beforeEach ->
+					@settings.apis.project_history.enabled = true
+					@RedisManager.updateDocument @project_id, @doc_id, @lines, @version, @ops, @ranges, @callback
+
+				it "should get the current doc version to check for consistency", ->
+					@RedisManager.getDocVersion
+						.calledWith(@doc_id)
+						.should.equal true
+
+				it "should set the doclines", ->
+					@multi.eval
+						.calledWith(sinon.match(/redis.call/), 1, "doclines:#{@doc_id}", JSON.stringify(@lines))
+						.should.equal true
+
+				it "should set the version", ->
+					@multi.set
+						.calledWith("DocVersion:#{@doc_id}", @version)
+						.should.equal true
+
+				it "should set the hash", ->
+					@multi.set
+						.calledWith("DocHash:#{@doc_id}", @hash)
+						.should.equal true
+
+				it "should set the ranges", ->
+					@multi.set
+						.calledWith("Ranges:#{@doc_id}", JSON.stringify(@ranges))
+						.should.equal true
+
+				it "should set the unflushed time", ->
+					@multi.set
+						.calledWith("UnflushedTime:#{@doc_id}", Date.now(), "NX")
+						.should.equal true
+
+				it "should push the doc op into the doc ops list", ->
+					@multi.rpush
+						.calledWith("DocOps:#{@doc_id}", JSON.stringify(@ops[0]), JSON.stringify(@ops[1]))
+						.should.equal true
+
+				it "should renew the expiry ttl on the doc ops array", ->
+					@multi.expire
+						.calledWith("DocOps:#{@doc_id}", @RedisManager.DOC_OPS_TTL)
+						.should.equal true
+
+				it "should truncate the list to 100 members", ->
+					@multi.ltrim
+						.calledWith("DocOps:#{@doc_id}", -@RedisManager.DOC_OPS_MAX_LENGTH, -1)
+						.should.equal true
+
+				it "should push the updates into the history ops list", ->
+					@multi.rpush
+						.calledWith("UncompressedHistoryOps:#{@doc_id}", JSON.stringify(@ops[0]), JSON.stringify(@ops[1]))
+						.should.equal true
+
+				it "should push the updates into the project history ops list", ->
+					@rclient.rpush
+						.calledWith("ProjectHistory:Ops:#{@project_id}", JSON.stringify(@ops[0]), JSON.stringify(@ops[1]))
+						.should.equal true
+
+				it "should call the callback", ->
+					@callback
+						.calledWith(null, @doc_update_list_length, @project_update_list_length)
+						.should.equal true
+
+				it 'should not log any errors', ->
+					@logger.error.calledWith()
+						.should.equal false
+
+			describe "with project history disabled", ->
+				beforeEach ->
+					@rclient.rpush = sinon.stub()
+					@settings.apis.project_history.enabled = false
+					@RedisManager.updateDocument @project_id, @doc_id, @lines, @version, @ops, @ranges, @callback
+
+				it "should not push the updates into the project history ops list", ->
+					@rclient.rpush.called.should.equal false
+
+				it "should call the callback", ->
+					@callback
+						.calledWith(null, @doc_update_list_length)
+						.should.equal true
 
 
-			it "should renew the expiry ttl on the doc ops array", ->
-				@rclient.expire
-					.calledWith("DocOps:#{@doc_id}", @RedisManager.DOC_OPS_TTL)
-					.should.equal true
-
-			it "should truncate the list to 100 members", ->
-				@rclient.ltrim
-					.calledWith("DocOps:#{@doc_id}", -@RedisManager.DOC_OPS_MAX_LENGTH, -1)
-					.should.equal true
-
-			it "should call the callback", ->
-				@callback.called.should.equal true
-
-			it 'should not log any errors', ->
-				@logger.error.calledWith()
-					.should.equal false
-		
 		describe "with an inconsistent version", ->
 		describe "with an inconsistent version", ->
 			beforeEach ->
 			beforeEach ->
 				@RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version - @ops.length - 1)
 				@RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version - @ops.length - 1)
-				@RedisManager.updateDocument @doc_id, @lines, @version, @ops, @ranges, @callback
-			
+				@RedisManager.updateDocument @project_id, @doc_id, @lines, @version, @ops, @ranges, @callback
+
 			it "should not call multi.exec", ->
 			it "should not call multi.exec", ->
-				@rclient.exec.called.should.equal false
-				
+				@multi.exec.called.should.equal false
+
 			it "should call the callback with an error", ->
 			it "should call the callback with an error", ->
 				@callback
 				@callback
 					.calledWith(new Error("Version mismatch. '#{@doc_id}' is corrupted."))
 					.calledWith(new Error("Version mismatch. '#{@doc_id}' is corrupted."))
 					.should.equal true
 					.should.equal true
-		
+
 		describe "with no updates", ->
 		describe "with no updates", ->
 			beforeEach ->
 			beforeEach ->
+				@rclient.rpush = sinon.stub().callsArgWith(1, null, @project_update_list_length)
 				@RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version)
 				@RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version)
-				@RedisManager.updateDocument @doc_id, @lines, @version, [], @ranges, @callback
-			
-			it "should not do an rpush", ->
+				@RedisManager.updateDocument @project_id, @doc_id, @lines, @version, [], @ranges, @callback
+
+			it "should not try to enqueue doc updates", ->
+				@multi.rpush
+					.called
+					.should.equal false
+
+			it "should not try to enqueue project updates", ->
 				@rclient.rpush
 				@rclient.rpush
 					.called
 					.called
 					.should.equal false
 					.should.equal false
-		
+
 			it "should still set the doclines", ->
 			it "should still set the doclines", ->
-				@rclient.eval
+				@multi.eval
 					.calledWith(sinon.match(/redis.call/), 1, "doclines:#{@doc_id}", JSON.stringify(@lines))
 					.calledWith(sinon.match(/redis.call/), 1, "doclines:#{@doc_id}", JSON.stringify(@lines))
 					.should.equal true
 					.should.equal true
-		
+
 		describe "with empty ranges", ->
 		describe "with empty ranges", ->
 			beforeEach ->
 			beforeEach ->
 				@RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version - @ops.length)
 				@RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version - @ops.length)
-				@RedisManager.updateDocument @doc_id, @lines, @version, @ops, {}, @callback
-			
+				@RedisManager.updateDocument @project_id, @doc_id, @lines, @version, @ops, {}, @callback
+
 			it "should not set the ranges", ->
 			it "should not set the ranges", ->
-				@rclient.set
+				@multi.set
 					.calledWith("Ranges:#{@doc_id}", JSON.stringify(@ranges))
 					.calledWith("Ranges:#{@doc_id}", JSON.stringify(@ranges))
 					.should.equal false
 					.should.equal false
-					
+
 			it "should delete the ranges key", ->
 			it "should delete the ranges key", ->
-				@rclient.del
+				@multi.del
 					.calledWith("Ranges:#{@doc_id}")
 					.calledWith("Ranges:#{@doc_id}")
 					.should.equal true
 					.should.equal true
 
 
 		describe "with a corrupted write", ->
 		describe "with a corrupted write", ->
 			beforeEach ->
 			beforeEach ->
 				@badHash = "INVALID-HASH-VALUE"
 				@badHash = "INVALID-HASH-VALUE"
-				@rclient.exec = sinon.stub().callsArgWith(0, null, [@badHash])
+				@multi.exec = sinon.stub().callsArgWith(0, null, [@badHash])
 				@RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version - @ops.length)
 				@RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version - @ops.length)
-				@RedisManager.updateDocument @doc_id, @lines, @version, @ops, @ranges, @callback
+				@RedisManager.updateDocument @project_id, @doc_id, @lines, @version, @ops, @ranges, @callback
 
 
 			it 'should log a hash error', ->
 			it 'should log a hash error', ->
 				@logger.error.calledWith()
 				@logger.error.calledWith()
@@ -444,22 +494,22 @@ describe "RedisManager", ->
 				@RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version - @ops.length)
 				@RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version - @ops.length)
 				@_stringify = JSON.stringify
 				@_stringify = JSON.stringify
 				@JSON.stringify = () -> return '["bad bytes! \u0000 <- here"]'
 				@JSON.stringify = () -> return '["bad bytes! \u0000 <- here"]'
-				@RedisManager.updateDocument @doc_id, @lines, @version, @ops, @ranges, @callback
+				@RedisManager.updateDocument @project_id, @doc_id, @lines, @version, @ops, @ranges, @callback
 
 
 			afterEach ->
 			afterEach ->
 				@JSON.stringify = @_stringify
 				@JSON.stringify = @_stringify
-			
+
 			it "should log an error", ->
 			it "should log an error", ->
 				@logger.error.called.should.equal true
 				@logger.error.called.should.equal true
 
 
 			it "should call the callback with an error", ->
 			it "should call the callback with an error", ->
 				@callback.calledWith(new Error("null bytes found in doc lines")).should.equal true
 				@callback.calledWith(new Error("null bytes found in doc lines")).should.equal true
-	
+
 		describe "with ranges that are too big", ->
 		describe "with ranges that are too big", ->
 			beforeEach ->
 			beforeEach ->
 				@RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version - @ops.length)
 				@RedisManager.getDocVersion.withArgs(@doc_id).yields(null, @version - @ops.length)
 				@RedisManager._serializeRanges = sinon.stub().yields(new Error("ranges are too large"))
 				@RedisManager._serializeRanges = sinon.stub().yields(new Error("ranges are too large"))
-				@RedisManager.updateDocument @doc_id, @lines, @version, @ops, @ranges, @callback
+				@RedisManager.updateDocument @project_id, @doc_id, @lines, @version, @ops, @ranges, @callback
 
 
 			it 'should log an error', ->
 			it 'should log an error', ->
 				@logger.error.called.should.equal true
 				@logger.error.called.should.equal true
@@ -469,45 +519,51 @@ describe "RedisManager", ->
 
 
 	describe "putDocInMemory", ->
 	describe "putDocInMemory", ->
 		beforeEach ->
 		beforeEach ->
-			@rclient.set = sinon.stub()
+			@multi.set = sinon.stub()
 			@rclient.sadd = sinon.stub().yields()
 			@rclient.sadd = sinon.stub().yields()
-			@rclient.del = sinon.stub()
-			@rclient.eval = sinon.stub()
+			@multi.del = sinon.stub()
+			@multi.eval = sinon.stub()
 			@lines = ["one", "two", "three", "これは"]
 			@lines = ["one", "two", "three", "これは"]
 			@version = 42
 			@version = 42
 			@hash = crypto.createHash('sha1').update(JSON.stringify(@lines),'utf8').digest('hex')
 			@hash = crypto.createHash('sha1').update(JSON.stringify(@lines),'utf8').digest('hex')
-			@rclient.exec = sinon.stub().callsArgWith(0, null, [@hash])
+			@multi.exec = sinon.stub().callsArgWith(0, null, [@hash])
 			@ranges = { comments: "mock", entries: "mock" }
 			@ranges = { comments: "mock", entries: "mock" }
-		
+			@pathname = '/a/b/c.tex'
+
 		describe "with non-empty ranges", ->
 		describe "with non-empty ranges", ->
 			beforeEach (done) ->
 			beforeEach (done) ->
-				@RedisManager.putDocInMemory @project_id, @doc_id, @lines, @version, @ranges, done
-			
+				@RedisManager.putDocInMemory @project_id, @doc_id, @lines, @version, @ranges, @pathname, done
+
 			it "should set the lines", ->
 			it "should set the lines", ->
-				@rclient.eval
+				@multi.eval
 					.calledWith(sinon.match(/redis.call/), 1, "doclines:#{@doc_id}", JSON.stringify(@lines))
 					.calledWith(sinon.match(/redis.call/), 1, "doclines:#{@doc_id}", JSON.stringify(@lines))
 					.should.equal true
 					.should.equal true
-			
+
 			it "should set the version", ->
 			it "should set the version", ->
-				@rclient.set
+				@multi.set
 					.calledWith("DocVersion:#{@doc_id}", @version)
 					.calledWith("DocVersion:#{@doc_id}", @version)
 					.should.equal true
 					.should.equal true
 
 
 			it "should set the hash", ->
 			it "should set the hash", ->
-				@rclient.set
+				@multi.set
 					.calledWith("DocHash:#{@doc_id}", @hash)
 					.calledWith("DocHash:#{@doc_id}", @hash)
 					.should.equal true
 					.should.equal true
-				
+
 			it "should set the ranges", ->
 			it "should set the ranges", ->
-				@rclient.set
+				@multi.set
 					.calledWith("Ranges:#{@doc_id}", JSON.stringify(@ranges))
 					.calledWith("Ranges:#{@doc_id}", JSON.stringify(@ranges))
 					.should.equal true
 					.should.equal true
-				
+
 			it "should set the project_id for the doc", ->
 			it "should set the project_id for the doc", ->
-				@rclient.set
+				@multi.set
 					.calledWith("ProjectId:#{@doc_id}", @project_id)
 					.calledWith("ProjectId:#{@doc_id}", @project_id)
 					.should.equal true
 					.should.equal true
-			
+
+			it "should set the pathname for the doc", ->
+				@multi.set
+					.calledWith("Pathname:#{@doc_id}", @pathname)
+					.should.equal true
+
 			it "should add the doc_id to the project set", ->
 			it "should add the doc_id to the project set", ->
 				@rclient.sadd
 				@rclient.sadd
 					.calledWith("DocsIn:#{@project_id}", @doc_id)
 					.calledWith("DocsIn:#{@project_id}", @doc_id)
@@ -516,25 +572,25 @@ describe "RedisManager", ->
 			it 'should not log any errors', ->
 			it 'should not log any errors', ->
 				@logger.error.calledWith()
 				@logger.error.calledWith()
 					.should.equal false
 					.should.equal false
-	
+
 		describe "with empty ranges", ->
 		describe "with empty ranges", ->
 			beforeEach (done) ->
 			beforeEach (done) ->
-				@RedisManager.putDocInMemory @project_id, @doc_id, @lines, @version, {}, done
-				
+				@RedisManager.putDocInMemory @project_id, @doc_id, @lines, @version, {}, @pathname, done
+
 			it "should delete the ranges key", ->
 			it "should delete the ranges key", ->
-				@rclient.del
+				@multi.del
 					.calledWith("Ranges:#{@doc_id}")
 					.calledWith("Ranges:#{@doc_id}")
 					.should.equal true
 					.should.equal true
-				
+
 			it "should not set the ranges", ->
 			it "should not set the ranges", ->
-				@rclient.set
+				@multi.set
 					.calledWith("Ranges:#{@doc_id}", JSON.stringify(@ranges))
 					.calledWith("Ranges:#{@doc_id}", JSON.stringify(@ranges))
 					.should.equal false
 					.should.equal false
 
 
 		describe "with a corrupted write", ->
 		describe "with a corrupted write", ->
 			beforeEach (done) ->
 			beforeEach (done) ->
-				@rclient.exec = sinon.stub().callsArgWith(0, null, ["INVALID-HASH-VALUE"])
-				@RedisManager.putDocInMemory @project_id, @doc_id, @lines, @version, @ranges, done
+				@multi.exec = sinon.stub().callsArgWith(0, null, ["INVALID-HASH-VALUE"])
+				@RedisManager.putDocInMemory @project_id, @doc_id, @lines, @version, @ranges, @pathname, done
 
 
 			it 'should log a hash error', ->
 			it 'should log a hash error', ->
 				@logger.error.calledWith()
 				@logger.error.calledWith()
@@ -544,21 +600,21 @@ describe "RedisManager", ->
 			beforeEach ->
 			beforeEach ->
 				@_stringify = JSON.stringify
 				@_stringify = JSON.stringify
 				@JSON.stringify = () -> return '["bad bytes! \u0000 <- here"]'
 				@JSON.stringify = () -> return '["bad bytes! \u0000 <- here"]'
-				@RedisManager.putDocInMemory @project_id, @doc_id, @lines, @version, @ranges, @callback
+				@RedisManager.putDocInMemory @project_id, @doc_id, @lines, @version, @ranges, @pathname, @callback
 
 
 			afterEach ->
 			afterEach ->
 				@JSON.stringify = @_stringify
 				@JSON.stringify = @_stringify
-			
+
 			it "should log an error", ->
 			it "should log an error", ->
 				@logger.error.called.should.equal true
 				@logger.error.called.should.equal true
 
 
 			it "should call the callback with an error", ->
 			it "should call the callback with an error", ->
 				@callback.calledWith(new Error("null bytes found in doc lines")).should.equal true
 				@callback.calledWith(new Error("null bytes found in doc lines")).should.equal true
-	
+
 		describe "with ranges that are too big", ->
 		describe "with ranges that are too big", ->
 			beforeEach ->
 			beforeEach ->
 				@RedisManager._serializeRanges = sinon.stub().yields(new Error("ranges are too large"))
 				@RedisManager._serializeRanges = sinon.stub().yields(new Error("ranges are too large"))
-				@RedisManager.putDocInMemory @project_id, @doc_id, @lines, @version, @ranges, @callback
+				@RedisManager.putDocInMemory @project_id, @doc_id, @lines, @version, @ranges, @pathname, @callback
 
 
 			it 'should log an error', ->
 			it 'should log an error', ->
 				@logger.error.called.should.equal true
 				@logger.error.called.should.equal true
@@ -568,41 +624,46 @@ describe "RedisManager", ->
 
 
 	describe "removeDocFromMemory", ->
 	describe "removeDocFromMemory", ->
 		beforeEach (done) ->
 		beforeEach (done) ->
-			@rclient.del = sinon.stub()
-			@rclient.srem = sinon.stub()
-			@rclient.exec.yields()
+			@multi.del = sinon.stub()
+			@multi.srem = sinon.stub()
+			@multi.exec.yields()
 			@RedisManager.removeDocFromMemory @project_id, @doc_id, done
 			@RedisManager.removeDocFromMemory @project_id, @doc_id, done
-		
+
 		it "should delete the lines", ->
 		it "should delete the lines", ->
-			@rclient.del
+			@multi.del
 				.calledWith("doclines:#{@doc_id}")
 				.calledWith("doclines:#{@doc_id}")
 				.should.equal true
 				.should.equal true
-		
+
 		it "should delete the version", ->
 		it "should delete the version", ->
-			@rclient.del
+			@multi.del
 				.calledWith("DocVersion:#{@doc_id}")
 				.calledWith("DocVersion:#{@doc_id}")
 				.should.equal true
 				.should.equal true
 
 
 		it "should delete the hash", ->
 		it "should delete the hash", ->
-			@rclient.del
+			@multi.del
 				.calledWith("DocHash:#{@doc_id}")
 				.calledWith("DocHash:#{@doc_id}")
 				.should.equal true
 				.should.equal true
 
 
 		it "should delete the unflushed time", ->
 		it "should delete the unflushed time", ->
-			@rclient.del
+			@multi.del
 				.calledWith("UnflushedTime:#{@doc_id}")
 				.calledWith("UnflushedTime:#{@doc_id}")
 				.should.equal true
 				.should.equal true
-		
+
 		it "should delete the project_id for the doc", ->
 		it "should delete the project_id for the doc", ->
-			@rclient.del
+			@multi.del
 				.calledWith("ProjectId:#{@doc_id}")
 				.calledWith("ProjectId:#{@doc_id}")
 				.should.equal true
 				.should.equal true
-		
+
 		it "should remove the doc_id from the project set", ->
 		it "should remove the doc_id from the project set", ->
-			@rclient.srem
+			@multi.srem
 				.calledWith("DocsIn:#{@project_id}", @doc_id)
 				.calledWith("DocsIn:#{@project_id}", @doc_id)
 				.should.equal true
 				.should.equal true
 
 
+		it "should delete the pathname for the doc", ->
+			@multi.del
+				.calledWith("Pathname:#{@doc_id}")
+				.should.equal true
+
 	describe "clearProjectState", ->
 	describe "clearProjectState", ->
 		beforeEach (done) ->
 		beforeEach (done) ->
 			@rclient.del = sinon.stub().callsArg(1)
 			@rclient.del = sinon.stub().callsArg(1)

+ 54 - 23
services/document-updater/test/unit/coffee/UpdateManager/UpdateManagerTests.coffee

@@ -19,7 +19,7 @@ describe "UpdateManager", ->
 			"./Metrics": @Metrics =
 			"./Metrics": @Metrics =
 				Timer: class Timer
 				Timer: class Timer
 					done: sinon.stub()
 					done: sinon.stub()
-			"settings-sharelatex": Settings = {}
+			"settings-sharelatex": @Settings = {}
 			"./DocumentManager": @DocumentManager = {}
 			"./DocumentManager": @DocumentManager = {}
 			"./RangesManager": @RangesManager = {}
 			"./RangesManager": @RangesManager = {}
 			"./Profiler": class Profiler
 			"./Profiler": class Profiler
@@ -60,14 +60,14 @@ describe "UpdateManager", ->
 
 
 				it "should process the outstanding updates", ->
 				it "should process the outstanding updates", ->
 					@UpdateManager.processOutstandingUpdates.calledWith(@project_id, @doc_id).should.equal true
 					@UpdateManager.processOutstandingUpdates.calledWith(@project_id, @doc_id).should.equal true
-					
+
 				it "should do everything with the lock acquired", ->
 				it "should do everything with the lock acquired", ->
 					@UpdateManager.processOutstandingUpdates.calledAfter(@LockManager.tryLock).should.equal true
 					@UpdateManager.processOutstandingUpdates.calledAfter(@LockManager.tryLock).should.equal true
 					@UpdateManager.processOutstandingUpdates.calledBefore(@LockManager.releaseLock).should.equal true
 					@UpdateManager.processOutstandingUpdates.calledBefore(@LockManager.releaseLock).should.equal true
 
 
 				it "should continue processing new updates that may have come in", ->
 				it "should continue processing new updates that may have come in", ->
 					@UpdateManager.continueProcessingUpdatesWithLock.calledWith(@project_id, @doc_id).should.equal true
 					@UpdateManager.continueProcessingUpdatesWithLock.calledWith(@project_id, @doc_id).should.equal true
-				
+
 				it "should return the callback", ->
 				it "should return the callback", ->
 					@callback.called.should.equal true
 					@callback.called.should.equal true
 
 
@@ -78,7 +78,7 @@ describe "UpdateManager", ->
 
 
 				it "should free the lock", ->
 				it "should free the lock", ->
 					@LockManager.releaseLock.calledWith(@doc_id, @lockValue).should.equal true
 					@LockManager.releaseLock.calledWith(@doc_id, @lockValue).should.equal true
-					
+
 				it "should return the error in the callback", ->
 				it "should return the error in the callback", ->
 					@callback.calledWith(@error).should.equal true
 					@callback.calledWith(@error).should.equal true
 
 
@@ -93,7 +93,7 @@ describe "UpdateManager", ->
 
 
 			it "should not process the updates", ->
 			it "should not process the updates", ->
 				@UpdateManager.processOutstandingUpdates.called.should.equal false
 				@UpdateManager.processOutstandingUpdates.called.should.equal false
-				
+
 	describe "continueProcessingUpdatesWithLock", ->
 	describe "continueProcessingUpdatesWithLock", ->
 		describe "when there are outstanding updates", ->
 		describe "when there are outstanding updates", ->
 			beforeEach ->
 			beforeEach ->
@@ -137,7 +137,7 @@ describe "UpdateManager", ->
 					@UpdateManager.applyUpdate
 					@UpdateManager.applyUpdate
 						.calledWith(@project_id, @doc_id, update)
 						.calledWith(@project_id, @doc_id, update)
 						.should.equal true
 						.should.equal true
-		
+
 			it "should call the callback", ->
 			it "should call the callback", ->
 				@callback.called.should.equal true
 				@callback.called.should.equal true
 
 
@@ -154,7 +154,7 @@ describe "UpdateManager", ->
 
 
 			it "should call the callback", ->
 			it "should call the callback", ->
 				@callback.called.should.equal true
 				@callback.called.should.equal true
-				
+
 	describe "applyUpdate", ->
 	describe "applyUpdate", ->
 		beforeEach ->
 		beforeEach ->
 			@update = {op: [{p: 42, i: "foo"}]}
 			@update = {op: [{p: 42, i: "foo"}]}
@@ -163,23 +163,27 @@ describe "UpdateManager", ->
 			@lines = ["original", "lines"]
 			@lines = ["original", "lines"]
 			@ranges = { entries: "mock", comments: "mock" }
 			@ranges = { entries: "mock", comments: "mock" }
 			@updated_ranges = { entries: "updated", comments: "updated" }
 			@updated_ranges = { entries: "updated", comments: "updated" }
-			@appliedOps = ["mock-applied-ops"]
-			@DocumentManager.getDoc = sinon.stub().yields(null, @lines, @version, @ranges)
+			@appliedOps = [ {v: 42, op: "mock-op-42"}, { v: 45, op: "mock-op-45" }]
+			@doc_ops_length = sinon.stub()
+			@project_ops_length = sinon.stub()
+			@pathname = '/a/b/c.tex'
+			@DocumentManager.getDoc = sinon.stub().yields(null, @lines, @version, @ranges, @pathname)
 			@RangesManager.applyUpdate = sinon.stub().yields(null, @updated_ranges)
 			@RangesManager.applyUpdate = sinon.stub().yields(null, @updated_ranges)
 			@ShareJsUpdateManager.applyUpdate = sinon.stub().yields(null, @updatedDocLines, @version, @appliedOps)
 			@ShareJsUpdateManager.applyUpdate = sinon.stub().yields(null, @updatedDocLines, @version, @appliedOps)
-			@RedisManager.updateDocument = sinon.stub().yields()
+			@RedisManager.updateDocument = sinon.stub().yields(null, @doc_ops_length, @project_ops_length)
 			@RealTimeRedisManager.sendData = sinon.stub()
 			@RealTimeRedisManager.sendData = sinon.stub()
-			@HistoryManager.recordAndFlushHistoryOps = sinon.stub().callsArg(4)
-		
+			@UpdateManager._addProjectHistoryMetadataToOps = sinon.stub()
+			@HistoryManager.recordAndFlushHistoryOps = sinon.stub().callsArg(5)
+
 		describe "normally", ->
 		describe "normally", ->
 			beforeEach ->
 			beforeEach ->
 				@UpdateManager.applyUpdate @project_id, @doc_id, @update, @callback
 				@UpdateManager.applyUpdate @project_id, @doc_id, @update, @callback
-			
+
 			it "should apply the updates via ShareJS", ->
 			it "should apply the updates via ShareJS", ->
 				@ShareJsUpdateManager.applyUpdate
 				@ShareJsUpdateManager.applyUpdate
 					.calledWith(@project_id, @doc_id, @update, @lines, @version)
 					.calledWith(@project_id, @doc_id, @update, @lines, @version)
 					.should.equal true
 					.should.equal true
-			
+
 			it "should update the ranges", ->
 			it "should update the ranges", ->
 				@RangesManager.applyUpdate
 				@RangesManager.applyUpdate
 					.calledWith(@project_id, @doc_id, @ranges, @appliedOps, @updatedDocLines)
 					.calledWith(@project_id, @doc_id, @ranges, @appliedOps, @updatedDocLines)
@@ -187,12 +191,17 @@ describe "UpdateManager", ->
 
 
 			it "should save the document", ->
 			it "should save the document", ->
 				@RedisManager.updateDocument
 				@RedisManager.updateDocument
-					.calledWith(@doc_id, @updatedDocLines, @version, @appliedOps, @updated_ranges)
+					.calledWith(@project_id, @doc_id, @updatedDocLines, @version, @appliedOps, @updated_ranges)
 					.should.equal true
 					.should.equal true
-			
+
+			it "shoould add metadata to the ops" , ->
+				@UpdateManager._addProjectHistoryMetadataToOps
+					.calledWith(@appliedOps, @pathname, @updatedDocLines)
+					.should.equal true
+
 			it "should push the applied ops into the history queue", ->
 			it "should push the applied ops into the history queue", ->
 				@HistoryManager.recordAndFlushHistoryOps
 				@HistoryManager.recordAndFlushHistoryOps
-					.calledWith(@project_id, @doc_id, @appliedOps)
+					.calledWith(@project_id, @doc_id, @appliedOps, @doc_ops_length, @project_ops_length)
 					.should.equal true
 					.should.equal true
 
 
 			it "should call the callback", ->
 			it "should call the callback", ->
@@ -207,16 +216,16 @@ describe "UpdateManager", ->
 				@ShareJsUpdateManager.applyUpdate
 				@ShareJsUpdateManager.applyUpdate
 					.calledWith(@project_id, @doc_id, @update)
 					.calledWith(@project_id, @doc_id, @update)
 					.should.equal true
 					.should.equal true
-				
+
 				# \uFFFD is 'replacement character'
 				# \uFFFD is 'replacement character'
 				@update.op[0].i.should.equal "\uFFFD\uFFFD"
 				@update.op[0].i.should.equal "\uFFFD\uFFFD"
-		
+
 		describe "with an error", ->
 		describe "with an error", ->
 			beforeEach ->
 			beforeEach ->
 				@error = new Error("something went wrong")
 				@error = new Error("something went wrong")
 				@ShareJsUpdateManager.applyUpdate = sinon.stub().yields(@error)
 				@ShareJsUpdateManager.applyUpdate = sinon.stub().yields(@error)
 				@UpdateManager.applyUpdate @project_id, @doc_id, @update, @callback
 				@UpdateManager.applyUpdate @project_id, @doc_id, @update, @callback
-			
+
 			it "should call RealTimeRedisManager.sendData with the error", ->
 			it "should call RealTimeRedisManager.sendData with the error", ->
 				@RealTimeRedisManager.sendData
 				@RealTimeRedisManager.sendData
 					.calledWith({
 					.calledWith({
@@ -228,7 +237,29 @@ describe "UpdateManager", ->
 
 
 			it "should call the callback with the error", ->
 			it "should call the callback with the error", ->
 				@callback.calledWith(@error).should.equal true
 				@callback.calledWith(@error).should.equal true
-			
+
+	describe "_addProjectHistoryMetadataToOps", ->
+		it "should add pathname and doc_length metadata to the ops", ->
+			lines = [
+				'some'
+				'test'
+				'data'
+			]
+			appliedOps = [ {v: 42, op: "mock-op-42"}, { v: 45, op: "mock-op-45" }]
+			@UpdateManager._addProjectHistoryMetadataToOps(appliedOps, @pathname, lines)
+			appliedOps.should.deep.equal [{
+				v: 42
+				op: "mock-op-42"
+				meta:
+					pathname: @pathname
+					doc_length: 14
+			}, {
+				v: 45
+				op: "mock-op-45"
+				meta:
+					pathname: @pathname
+					doc_length: 14
+			}]
 
 
 	describe "lockUpdatesAndDo", ->
 	describe "lockUpdatesAndDo", ->
 		beforeEach ->
 		beforeEach ->
@@ -283,7 +314,7 @@ describe "UpdateManager", ->
 
 
 			it "should free the lock", ->
 			it "should free the lock", ->
 				@LockManager.releaseLock.calledWith(@doc_id, @lockValue).should.equal true
 				@LockManager.releaseLock.calledWith(@doc_id, @lockValue).should.equal true
-				
+
 			it "should return the error in the callback", ->
 			it "should return the error in the callback", ->
 				@callback.calledWith(@error).should.equal true
 				@callback.calledWith(@error).should.equal true
 
 
@@ -295,7 +326,7 @@ describe "UpdateManager", ->
 
 
 			it "should free the lock", ->
 			it "should free the lock", ->
 				@LockManager.releaseLock.calledWith(@doc_id, @lockValue).should.equal true
 				@LockManager.releaseLock.calledWith(@doc_id, @lockValue).should.equal true
-				
+
 			it "should return the error in the callback", ->
 			it "should return the error in the callback", ->
 				@callback.calledWith(@error).should.equal true
 				@callback.calledWith(@error).should.equal true