UpdateManager.js 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. // @ts-check
  2. const { callbackifyAll } = require('@overleaf/promise-utils')
  3. const LockManager = require('./LockManager')
  4. const RedisManager = require('./RedisManager')
  5. const RealTimeRedisManager = require('./RealTimeRedisManager')
  6. const ShareJsUpdateManager = require('./ShareJsUpdateManager')
  7. const HistoryManager = require('./HistoryManager')
  8. const _ = require('lodash')
  9. const logger = require('@overleaf/logger')
  10. const Metrics = require('./Metrics')
  11. const Errors = require('./Errors')
  12. const DocumentManager = require('./DocumentManager')
  13. const RangesManager = require('./RangesManager')
  14. const SnapshotManager = require('./SnapshotManager')
  15. const Profiler = require('./Profiler')
  16. const UpdateManager = {
  17. async processOutstandingUpdates(projectId, docId) {
  18. const timer = new Metrics.Timer('updateManager.processOutstandingUpdates')
  19. try {
  20. await UpdateManager.fetchAndApplyUpdates(projectId, docId)
  21. timer.done({ status: 'success' })
  22. } catch (err) {
  23. timer.done({ status: 'error' })
  24. throw err
  25. }
  26. },
  27. async processOutstandingUpdatesWithLock(projectId, docId) {
  28. const profile = new Profiler('processOutstandingUpdatesWithLock', {
  29. project_id: projectId,
  30. doc_id: docId,
  31. })
  32. const lockValue = await LockManager.promises.tryLock(docId)
  33. if (lockValue == null) {
  34. return
  35. }
  36. profile.log('tryLock')
  37. try {
  38. await UpdateManager.processOutstandingUpdates(projectId, docId)
  39. profile.log('processOutstandingUpdates')
  40. } finally {
  41. await LockManager.promises.releaseLock(docId, lockValue)
  42. profile.log('releaseLock').end()
  43. }
  44. await UpdateManager.continueProcessingUpdatesWithLock(projectId, docId)
  45. },
  46. async continueProcessingUpdatesWithLock(projectId, docId) {
  47. const length = await RealTimeRedisManager.promises.getUpdatesLength(docId)
  48. if (length > 0) {
  49. await UpdateManager.processOutstandingUpdatesWithLock(projectId, docId)
  50. }
  51. },
  52. async fetchAndApplyUpdates(projectId, docId) {
  53. const profile = new Profiler('fetchAndApplyUpdates', {
  54. project_id: projectId,
  55. doc_id: docId,
  56. })
  57. const updates = await RealTimeRedisManager.promises.getPendingUpdatesForDoc(
  58. docId
  59. )
  60. logger.debug(
  61. { projectId, docId, count: updates.length },
  62. 'processing updates'
  63. )
  64. if (updates.length === 0) {
  65. return
  66. }
  67. profile.log('getPendingUpdatesForDoc')
  68. for (const update of updates) {
  69. await UpdateManager.applyUpdate(projectId, docId, update)
  70. profile.log('applyUpdate')
  71. }
  72. profile.log('async done').end()
  73. },
  74. async applyUpdate(projectId, docId, update) {
  75. const profile = new Profiler('applyUpdate', {
  76. project_id: projectId,
  77. doc_id: docId,
  78. })
  79. UpdateManager._sanitizeUpdate(update)
  80. profile.log('sanitizeUpdate', { sync: true })
  81. try {
  82. let { lines, version, ranges, pathname, projectHistoryId } =
  83. await DocumentManager.promises.getDoc(projectId, docId)
  84. profile.log('getDoc')
  85. if (lines == null || version == null) {
  86. throw new Errors.NotFoundError(`document not found: ${docId}`)
  87. }
  88. const previousVersion = version
  89. const incomingUpdateVersion = update.v
  90. let updatedDocLines, appliedOps
  91. ;({ updatedDocLines, version, appliedOps } =
  92. await ShareJsUpdateManager.promises.applyUpdate(
  93. projectId,
  94. docId,
  95. update,
  96. lines,
  97. version
  98. ))
  99. profile.log('sharejs.applyUpdate', {
  100. // only synchronous when the update applies directly to the
  101. // doc version, otherwise getPreviousDocOps is called.
  102. sync: incomingUpdateVersion === previousVersion,
  103. })
  104. const { newRanges, rangesWereCollapsed } =
  105. await RangesManager.promises.applyUpdate(
  106. projectId,
  107. docId,
  108. ranges,
  109. appliedOps,
  110. updatedDocLines
  111. )
  112. profile.log('RangesManager.applyUpdate', { sync: true })
  113. UpdateManager._addProjectHistoryMetadataToOps(
  114. appliedOps,
  115. pathname,
  116. projectHistoryId,
  117. lines
  118. )
  119. const projectOpsLength = await RedisManager.promises.updateDocument(
  120. projectId,
  121. docId,
  122. updatedDocLines,
  123. version,
  124. appliedOps,
  125. newRanges,
  126. update.meta
  127. )
  128. profile.log('RedisManager.updateDocument')
  129. HistoryManager.recordAndFlushHistoryOps(
  130. projectId,
  131. appliedOps,
  132. projectOpsLength
  133. )
  134. profile.log('recordAndFlushHistoryOps')
  135. if (rangesWereCollapsed) {
  136. Metrics.inc('doc-snapshot')
  137. logger.debug(
  138. {
  139. projectId,
  140. docId,
  141. previousVersion,
  142. lines,
  143. ranges,
  144. update,
  145. },
  146. 'update collapsed some ranges, snapshotting previous content'
  147. )
  148. // Do this last, since it's a mongo call, and so potentially longest running
  149. // If it overruns the lock, it's ok, since all of our redis work is done
  150. await SnapshotManager.promises.recordSnapshot(
  151. projectId,
  152. docId,
  153. previousVersion,
  154. pathname,
  155. lines,
  156. ranges
  157. )
  158. }
  159. } catch (error) {
  160. RealTimeRedisManager.sendData({
  161. project_id: projectId,
  162. doc_id: docId,
  163. error: error instanceof Error ? error.message : error,
  164. })
  165. profile.log('sendData')
  166. throw error
  167. } finally {
  168. profile.end()
  169. }
  170. },
  171. // lockUpdatesAndDo can't be promisified yet because it expects a
  172. // callback-style function
  173. async lockUpdatesAndDo(method, projectId, docId, ...args) {
  174. const profile = new Profiler('lockUpdatesAndDo', {
  175. project_id: projectId,
  176. doc_id: docId,
  177. })
  178. const lockValue = await LockManager.promises.getLock(docId)
  179. profile.log('getLock')
  180. let responseArgs
  181. try {
  182. await UpdateManager.processOutstandingUpdates(projectId, docId)
  183. profile.log('processOutstandingUpdates')
  184. // TODO: method is still a callback-style function. Change this when promisifying DocumentManager
  185. responseArgs = await new Promise((resolve, reject) => {
  186. method(projectId, docId, ...args, (error, ...responseArgs) => {
  187. if (error) {
  188. reject(error)
  189. } else {
  190. resolve(responseArgs)
  191. }
  192. })
  193. })
  194. profile.log('method')
  195. } finally {
  196. await LockManager.promises.releaseLock(docId, lockValue)
  197. profile.log('releaseLock').end()
  198. }
  199. // We held the lock for a while so updates might have queued up
  200. UpdateManager.continueProcessingUpdatesWithLock(projectId, docId).catch(
  201. err => {
  202. // The processing may fail for invalid user updates.
  203. // This can be very noisy, put them on level DEBUG
  204. // and record a metric.
  205. Metrics.inc('background-processing-updates-error')
  206. logger.debug(
  207. { err, projectId, docId },
  208. 'error processing updates in background'
  209. )
  210. }
  211. )
  212. return responseArgs
  213. },
  214. _sanitizeUpdate(update) {
  215. // In Javascript, characters are 16-bits wide. It does not understand surrogates as characters.
  216. //
  217. // From Wikipedia (http://en.wikipedia.org/wiki/Plane_(Unicode)#Basic_Multilingual_Plane):
  218. // "The High Surrogates (U+D800–U+DBFF) and Low Surrogate (U+DC00–U+DFFF) codes are reserved
  219. // for encoding non-BMP characters in UTF-16 by using a pair of 16-bit codes: one High Surrogate
  220. // and one Low Surrogate. A single surrogate code point will never be assigned a character.""
  221. //
  222. // The main offender seems to be \uD835 as a stand alone character, which would be the first
  223. // 16-bit character of a blackboard bold character (http://www.fileformat.info/info/unicode/char/1d400/index.htm).
  224. // Something must be going on client side that is screwing up the encoding and splitting the
  225. // two 16-bit characters so that \uD835 is standalone.
  226. for (const op of update.op || []) {
  227. if (op.i != null) {
  228. // Replace high and low surrogate characters with 'replacement character' (\uFFFD)
  229. op.i = op.i.replace(/[\uD800-\uDFFF]/g, '\uFFFD')
  230. }
  231. }
  232. return update
  233. },
  234. _addProjectHistoryMetadataToOps(updates, pathname, projectHistoryId, lines) {
  235. let docLength = _.reduce(lines, (chars, line) => chars + line.length, 0)
  236. docLength += lines.length - 1 // count newline characters
  237. updates.forEach(function (update) {
  238. update.projectHistoryId = projectHistoryId
  239. if (!update.meta) {
  240. update.meta = {}
  241. }
  242. update.meta.pathname = pathname
  243. update.meta.doc_length = docLength
  244. // Each update may contain multiple ops, i.e.
  245. // [{
  246. // ops: [{i: "foo", p: 4}, {d: "bar", p:8}]
  247. // }, {
  248. // ops: [{d: "baz", p: 40}, {i: "qux", p:8}]
  249. // }]
  250. // We want to include the doc_length at the start of each update,
  251. // before it's ops are applied. However, we need to track any
  252. // changes to it for the next update.
  253. for (const op of update.op) {
  254. if (op.i != null) {
  255. docLength += op.i.length
  256. }
  257. if (op.d != null) {
  258. docLength -= op.d.length
  259. }
  260. }
  261. })
  262. },
  263. }
  264. const CallbackifiedUpdateManager = callbackifyAll(UpdateManager)
  265. module.exports = CallbackifiedUpdateManager
  266. module.exports.promises = UpdateManager
  267. module.exports.lockUpdatesAndDo = function lockUpdatesAndDo(
  268. method,
  269. projectId,
  270. docId,
  271. ...rest
  272. ) {
  273. const adjustedLength = Math.max(rest.length, 1)
  274. const args = rest.slice(0, adjustedLength - 1)
  275. const callback = rest[adjustedLength - 1]
  276. // TODO: During the transition to promises, UpdateManager.lockUpdatesAndDo
  277. // returns the potentially multiple arguments that must be provided to the
  278. // callback in an array.
  279. UpdateManager.lockUpdatesAndDo(method, projectId, docId, ...args)
  280. .then(responseArgs => {
  281. callback(null, ...responseArgs)
  282. })
  283. .catch(err => {
  284. callback(err)
  285. })
  286. }