UpdatesManager.coffee 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. MongoManager = require "./MongoManager"
  2. PackManager = require "./PackManager"
  3. RedisManager = require "./RedisManager"
  4. UpdateCompressor = require "./UpdateCompressor"
  5. LockManager = require "./LockManager"
  6. WebApiManager = require "./WebApiManager"
  7. UpdateTrimmer = require "./UpdateTrimmer"
  8. logger = require "logger-sharelatex"
  9. async = require "async"
  10. _ = require "underscore"
  11. Settings = require "settings-sharelatex"
  12. keys = Settings.redis.lock.key_schema
  13. module.exports = UpdatesManager =
  14. compressAndSaveRawUpdates: (project_id, doc_id, rawUpdates, temporary, callback = (error) ->) ->
  15. length = rawUpdates.length
  16. if length == 0
  17. return callback()
  18. # check that ops are in the correct order
  19. for op, i in rawUpdates when i > 0
  20. thisVersion = op?.v
  21. prevVersion = rawUpdates[i-1]?.v
  22. if not (prevVersion < thisVersion)
  23. logger.error project_id: project_id, doc_id: doc_id, rawUpdates:rawUpdates, temporary: temporary, thisVersion:thisVersion, prevVersion:prevVersion, "op versions out of order"
  24. # FIXME: we no longer need the lastCompressedUpdate, so change functions not to need it
  25. # CORRECTION: we do use it to log the time in case of error
  26. MongoManager.peekLastCompressedUpdate doc_id, (error, lastCompressedUpdate, lastVersion) ->
  27. # lastCompressedUpdate is the most recent update in Mongo, and
  28. # lastVersion is its sharejs version number.
  29. #
  30. # The peekLastCompressedUpdate method may pass the update back
  31. # as 'null' (for example if the previous compressed update has
  32. # been archived). In this case it can still pass back the
  33. # lastVersion from the update to allow us to check consistency.
  34. return callback(error) if error?
  35. # Ensure that raw updates start where lastVersion left off
  36. if lastVersion?
  37. discardedUpdates = []
  38. rawUpdates = rawUpdates.slice(0)
  39. while rawUpdates[0]? and rawUpdates[0].v <= lastVersion
  40. discardedUpdates.push rawUpdates.shift()
  41. if discardedUpdates.length
  42. logger.error project_id: project_id, doc_id: doc_id, discardedUpdates: discardedUpdates, temporary: temporary, lastVersion: lastVersion, "discarded updates already present"
  43. if rawUpdates[0]? and rawUpdates[0].v != lastVersion + 1
  44. ts = lastCompressedUpdate?.meta?.end_ts
  45. last_timestamp = if ts? then new Date(ts) else 'unknown time'
  46. error = new Error("Tried to apply raw op at version #{rawUpdates[0].v} to last compressed update with version #{lastVersion} from #{last_timestamp}")
  47. logger.error err: error, doc_id: doc_id, project_id: project_id, prev_end_ts: ts, temporary: temporary, lastCompressedUpdate: lastCompressedUpdate, "inconsistent doc versions"
  48. if Settings.trackchanges?.continueOnError and rawUpdates[0].v > lastVersion + 1
  49. # we have lost some ops - continue to write into the database, we can't recover at this point
  50. lastCompressedUpdate = null
  51. else
  52. return callback error
  53. if rawUpdates.length == 0
  54. return callback()
  55. # some old large ops in redis need to be rejected, they predate
  56. # the size limit that now prevents them going through the system
  57. REJECT_LARGE_OP_SIZE = 4 * 1024 * 1024
  58. for rawUpdate in rawUpdates
  59. opSizes = ((op.i?.length || op.d?.length) for op in rawUpdate?.op or [])
  60. size = _.max opSizes
  61. if size > REJECT_LARGE_OP_SIZE
  62. error = new Error("dropped op exceeding maximum allowed size of #{REJECT_LARGE_OP_SIZE}")
  63. logger.error err: error, doc_id: doc_id, project_id: project_id, size: size, rawUpdate: rawUpdate, "dropped op - too big"
  64. rawUpdate.op = []
  65. compressedUpdates = UpdateCompressor.compressRawUpdates null, rawUpdates
  66. PackManager.insertCompressedUpdates project_id, doc_id, lastCompressedUpdate, compressedUpdates, temporary, (error, result) ->
  67. return callback(error) if error?
  68. logger.log {project_id, doc_id, orig_v: lastCompressedUpdate?.v, new_v: result.v}, "inserted updates into pack" if result?
  69. callback()
  70. # Check whether the updates are temporary (per-project property)
  71. _prepareProjectForUpdates: (project_id, callback = (error, temporary) ->) ->
  72. UpdateTrimmer.shouldTrimUpdates project_id, (error, temporary) ->
  73. return callback(error) if error?
  74. callback(null, temporary)
  75. # Check for project id on document history (per-document property)
  76. _prepareDocForUpdates: (project_id, doc_id, callback = (error) ->) ->
  77. MongoManager.backportProjectId project_id, doc_id, (error) ->
  78. return callback(error) if error?
  79. callback(null)
  80. # Apply updates for specific project/doc after preparing at project and doc level
  81. REDIS_READ_BATCH_SIZE: 100
  82. processUncompressedUpdates: (project_id, doc_id, temporary, callback = (error) ->) ->
  83. # get the updates as strings from redis (so we can delete them after they are applied)
  84. RedisManager.getOldestDocUpdates doc_id, UpdatesManager.REDIS_READ_BATCH_SIZE, (error, docUpdates) ->
  85. return callback(error) if error?
  86. length = docUpdates.length
  87. # parse the redis strings into ShareJs updates
  88. RedisManager.expandDocUpdates docUpdates, (error, rawUpdates) ->
  89. if error?
  90. logger.err project_id: project_id, doc_id: doc_id, docUpdates: docUpdates, "failed to parse docUpdates"
  91. return callback(error)
  92. logger.log project_id: project_id, doc_id: doc_id, rawUpdates: rawUpdates, "retrieved raw updates from redis"
  93. UpdatesManager.compressAndSaveRawUpdates project_id, doc_id, rawUpdates, temporary, (error) ->
  94. return callback(error) if error?
  95. logger.log project_id: project_id, doc_id: doc_id, "compressed and saved doc updates"
  96. # delete the applied updates from redis
  97. RedisManager.deleteAppliedDocUpdates project_id, doc_id, docUpdates, (error) ->
  98. return callback(error) if error?
  99. if length == UpdatesManager.REDIS_READ_BATCH_SIZE
  100. # There might be more updates
  101. logger.log project_id: project_id, doc_id: doc_id, "continuing processing updates"
  102. setTimeout () ->
  103. UpdatesManager.processUncompressedUpdates project_id, doc_id, temporary, callback
  104. , 0
  105. else
  106. logger.log project_id: project_id, doc_id: doc_id, "all raw updates processed"
  107. callback()
  108. # Process updates for a doc when we flush it individually
  109. processUncompressedUpdatesWithLock: (project_id, doc_id, callback = (error) ->) ->
  110. UpdatesManager._prepareProjectForUpdates project_id, (error, temporary) ->
  111. return callback(error) if error?
  112. UpdatesManager._processUncompressedUpdatesForDocWithLock project_id, doc_id, temporary, callback
  113. # Process updates for a doc when the whole project is flushed (internal method)
  114. _processUncompressedUpdatesForDocWithLock: (project_id, doc_id, temporary, callback = (error) ->) ->
  115. UpdatesManager._prepareDocForUpdates project_id, doc_id, (error) ->
  116. return callback(error) if error?
  117. LockManager.runWithLock(
  118. keys.historyLock({doc_id}),
  119. (releaseLock) ->
  120. UpdatesManager.processUncompressedUpdates project_id, doc_id, temporary, releaseLock
  121. callback
  122. )
  123. # Process all updates for a project, only check project-level information once
  124. processUncompressedUpdatesForProject: (project_id, callback = (error) ->) ->
  125. RedisManager.getDocIdsWithHistoryOps project_id, (error, doc_ids) ->
  126. return callback(error) if error?
  127. UpdatesManager._prepareProjectForUpdates project_id, (error, temporary) ->
  128. jobs = []
  129. for doc_id in doc_ids
  130. do (doc_id) ->
  131. jobs.push (cb) ->
  132. UpdatesManager._processUncompressedUpdatesForDocWithLock project_id, doc_id, temporary, cb
  133. async.parallelLimit jobs, 5, callback
  134. # flush all outstanding changes
  135. flushAll: (limit, callback = (error, result) ->) ->
  136. RedisManager.getProjectIdsWithHistoryOps (error, project_ids) ->
  137. return callback(error) if error?
  138. logger.log {count: project_ids?.length, project_ids: project_ids}, "found projects"
  139. jobs = []
  140. project_ids = _.shuffle project_ids # randomise to avoid hitting same projects each time
  141. selectedProjects = if limit < 0 then project_ids else project_ids[0...limit]
  142. for project_id in selectedProjects
  143. do (project_id) ->
  144. jobs.push (cb) ->
  145. UpdatesManager.processUncompressedUpdatesForProject project_id, (err) ->
  146. return cb(null, {failed: err?, project_id: project_id})
  147. async.series jobs, (error, result) ->
  148. return callback(error) if error?
  149. failedProjects = (x.project_id for x in result when x.failed)
  150. succeededProjects = (x.project_id for x in result when not x.failed)
  151. callback(null, {failed: failedProjects, succeeded: succeededProjects, all: project_ids})
  152. getDanglingUpdates: (callback = (error, doc_ids) ->) ->
  153. RedisManager.getAllDocIdsWithHistoryOps (error, all_doc_ids) ->
  154. return callback(error) if error?
  155. RedisManager.getProjectIdsWithHistoryOps (error, all_project_ids) ->
  156. return callback(error) if error?
  157. # function to get doc_ids for each project
  158. task = (cb) -> async.concatSeries all_project_ids, RedisManager.getDocIdsWithHistoryOps, cb
  159. # find the dangling doc ids
  160. task (error, project_doc_ids) ->
  161. dangling_doc_ids = _.difference(all_doc_ids, project_doc_ids)
  162. logger.log {all_doc_ids: all_doc_ids, all_project_ids: all_project_ids, project_doc_ids: project_doc_ids, dangling_doc_ids: dangling_doc_ids}, "checking for dangling doc ids"
  163. callback(null, dangling_doc_ids)
  164. getDocUpdates: (project_id, doc_id, options = {}, callback = (error, updates) ->) ->
  165. UpdatesManager.processUncompressedUpdatesWithLock project_id, doc_id, (error) ->
  166. return callback(error) if error?
  167. #console.log "options", options
  168. PackManager.getOpsByVersionRange project_id, doc_id, options.from, options.to, (error, updates) ->
  169. return callback(error) if error?
  170. callback null, updates
  171. getDocUpdatesWithUserInfo: (project_id, doc_id, options = {}, callback = (error, updates) ->) ->
  172. UpdatesManager.getDocUpdates project_id, doc_id, options, (error, updates) ->
  173. return callback(error) if error?
  174. UpdatesManager.fillUserInfo updates, (error, updates) ->
  175. return callback(error) if error?
  176. callback null, updates
  177. getSummarizedProjectUpdates: (project_id, options = {}, callback = (error, updates) ->) ->
  178. options.min_count ||= 25
  179. summarizedUpdates = []
  180. before = options.before
  181. nextBeforeTimestamp = null
  182. UpdatesManager.processUncompressedUpdatesForProject project_id, (error) ->
  183. return callback(error) if error?
  184. PackManager.makeProjectIterator project_id, before, (err, iterator) ->
  185. return callback(err) if err?
  186. # repeatedly get updates and pass them through the summariser to get an final output with user info
  187. async.whilst () ->
  188. #console.log "checking iterator.done", iterator.done()
  189. return summarizedUpdates.length < options.min_count and not iterator.done()
  190. , (cb) ->
  191. iterator.next (err, partialUpdates) ->
  192. return callback(err) if err?
  193. #logger.log {partialUpdates}, 'got partialUpdates'
  194. return cb() if partialUpdates.length is 0 ## FIXME should try to avoid this happening
  195. nextBeforeTimestamp = partialUpdates[partialUpdates.length - 1].meta.end_ts
  196. # add the updates to the summary list
  197. summarizedUpdates = UpdatesManager._summarizeUpdates partialUpdates, summarizedUpdates
  198. cb()
  199. , () ->
  200. # finally done all updates
  201. #console.log 'summarized Updates', summarizedUpdates
  202. UpdatesManager.fillSummarizedUserInfo summarizedUpdates, (err, results) ->
  203. return callback(err) if err?
  204. callback null, results, if not iterator.done() then nextBeforeTimestamp else undefined
  205. fetchUserInfo: (users, callback = (error, fetchedUserInfo) ->) ->
  206. jobs = []
  207. fetchedUserInfo = {}
  208. for user_id of users
  209. do (user_id) ->
  210. jobs.push (callback) ->
  211. WebApiManager.getUserInfo user_id, (error, userInfo) ->
  212. return callback(error) if error?
  213. fetchedUserInfo[user_id] = userInfo
  214. callback()
  215. async.series jobs, (err) ->
  216. return callback(err) if err?
  217. callback(null, fetchedUserInfo)
  218. fillUserInfo: (updates, callback = (error, updates) ->) ->
  219. users = {}
  220. for update in updates
  221. user_id = update.meta.user_id
  222. if UpdatesManager._validUserId(user_id)
  223. users[user_id] = true
  224. UpdatesManager.fetchUserInfo users, (error, fetchedUserInfo) ->
  225. return callback(error) if error?
  226. for update in updates
  227. user_id = update.meta.user_id
  228. delete update.meta.user_id
  229. if UpdatesManager._validUserId(user_id)
  230. update.meta.user = fetchedUserInfo[user_id]
  231. callback null, updates
  232. fillSummarizedUserInfo: (updates, callback = (error, updates) ->) ->
  233. users = {}
  234. for update in updates
  235. user_ids = update.meta.user_ids or []
  236. for user_id in user_ids
  237. if UpdatesManager._validUserId(user_id)
  238. users[user_id] = true
  239. UpdatesManager.fetchUserInfo users, (error, fetchedUserInfo) ->
  240. return callback(error) if error?
  241. for update in updates
  242. user_ids = update.meta.user_ids or []
  243. update.meta.users = []
  244. delete update.meta.user_ids
  245. for user_id in user_ids
  246. if UpdatesManager._validUserId(user_id)
  247. update.meta.users.push fetchedUserInfo[user_id]
  248. else
  249. update.meta.users.push null
  250. callback null, updates
  251. _validUserId: (user_id) ->
  252. if !user_id?
  253. return false
  254. else
  255. return !!user_id.match(/^[a-f0-9]{24}$/)
  256. TIME_BETWEEN_DISTINCT_UPDATES: fiveMinutes = 5 * 60 * 1000
  257. SPLIT_ON_DELETE_SIZE: 16 # characters
  258. _summarizeUpdates: (updates, existingSummarizedUpdates = []) ->
  259. summarizedUpdates = existingSummarizedUpdates.slice()
  260. previousUpdateWasBigDelete = false
  261. for update in updates
  262. earliestUpdate = summarizedUpdates[summarizedUpdates.length - 1]
  263. shouldConcat = false
  264. # If a user inserts some text, then deletes a big chunk including that text,
  265. # the update we show might concat the insert and delete, and there will be no sign
  266. # of that insert having happened, or be able to restore to it (restoring after a big delete is common).
  267. # So, we split the summary on 'big' deletes. However, we've stepping backwards in time with
  268. # most recent changes considered first, so if this update is a big delete, we want to start
  269. # a new summarized update next timge, hence we monitor the previous update.
  270. if previousUpdateWasBigDelete
  271. shouldConcat = false
  272. else if earliestUpdate and earliestUpdate.meta.end_ts - update.meta.start_ts < @TIME_BETWEEN_DISTINCT_UPDATES
  273. # We're going backwards in time through the updates, so only combine if this update starts less than 5 minutes before
  274. # the end of current summarized block, so no block spans more than 5 minutes.
  275. shouldConcat = true
  276. isBigDelete = false
  277. for op in update.op or []
  278. if op.d? and op.d.length > @SPLIT_ON_DELETE_SIZE
  279. isBigDelete = true
  280. previousUpdateWasBigDelete = isBigDelete
  281. if shouldConcat
  282. # check if the user in this update is already present in the earliest update,
  283. # if not, add them to the users list of the earliest update
  284. earliestUpdate.meta.user_ids = _.union earliestUpdate.meta.user_ids, [update.meta.user_id]
  285. doc_id = update.doc_id.toString()
  286. doc = earliestUpdate.docs[doc_id]
  287. if doc?
  288. doc.fromV = Math.min(doc.fromV, update.v)
  289. doc.toV = Math.max(doc.toV, update.v)
  290. else
  291. earliestUpdate.docs[doc_id] =
  292. fromV: update.v
  293. toV: update.v
  294. earliestUpdate.meta.start_ts = Math.min(earliestUpdate.meta.start_ts, update.meta.start_ts)
  295. earliestUpdate.meta.end_ts = Math.max(earliestUpdate.meta.end_ts, update.meta.end_ts)
  296. else
  297. newUpdate =
  298. meta:
  299. user_ids: []
  300. start_ts: update.meta.start_ts
  301. end_ts: update.meta.end_ts
  302. docs: {}
  303. newUpdate.docs[update.doc_id.toString()] =
  304. fromV: update.v
  305. toV: update.v
  306. newUpdate.meta.user_ids.push update.meta.user_id
  307. summarizedUpdates.push newUpdate
  308. return summarizedUpdates