UpdateManager.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. // @ts-check
  2. const { callbackifyAll } = require('@overleaf/promise-utils')
  3. const LockManager = require('./LockManager')
  4. const RedisManager = require('./RedisManager')
  5. const ProjectHistoryRedisManager = require('./ProjectHistoryRedisManager')
  6. const RealTimeRedisManager = require('./RealTimeRedisManager')
  7. const ShareJsUpdateManager = require('./ShareJsUpdateManager')
  8. const HistoryManager = require('./HistoryManager')
  9. const _ = require('lodash')
  10. const logger = require('@overleaf/logger')
  11. const Metrics = require('./Metrics')
  12. const Errors = require('./Errors')
  13. const DocumentManager = require('./DocumentManager')
  14. const RangesManager = require('./RangesManager')
  15. const SnapshotManager = require('./SnapshotManager')
  16. const Profiler = require('./Profiler')
  17. const { isInsert, isDelete } = require('./Utils')
  18. /**
  19. * @typedef {import("./types").DeleteOp} DeleteOp
  20. * @typedef {import("./types").HistoryUpdate } HistoryUpdate
  21. * @typedef {import("./types").InsertOp} InsertOp
  22. * @typedef {import("./types").Op} Op
  23. * @typedef {import("./types").Ranges} Ranges
  24. * @typedef {import("./types").Update} Update
  25. */
  26. const UpdateManager = {
  27. async processOutstandingUpdates(projectId, docId) {
  28. const timer = new Metrics.Timer('updateManager.processOutstandingUpdates')
  29. try {
  30. await UpdateManager.fetchAndApplyUpdates(projectId, docId)
  31. timer.done({ status: 'success' })
  32. } catch (err) {
  33. timer.done({ status: 'error' })
  34. throw err
  35. }
  36. },
  37. async processOutstandingUpdatesWithLock(projectId, docId) {
  38. const profile = new Profiler('processOutstandingUpdatesWithLock', {
  39. project_id: projectId,
  40. doc_id: docId,
  41. })
  42. const lockValue = await LockManager.promises.tryLock(docId)
  43. if (lockValue == null) {
  44. return
  45. }
  46. profile.log('tryLock')
  47. try {
  48. await UpdateManager.processOutstandingUpdates(projectId, docId)
  49. profile.log('processOutstandingUpdates')
  50. } finally {
  51. await LockManager.promises.releaseLock(docId, lockValue)
  52. profile.log('releaseLock').end()
  53. }
  54. await UpdateManager.continueProcessingUpdatesWithLock(projectId, docId)
  55. },
  56. async continueProcessingUpdatesWithLock(projectId, docId) {
  57. const length = await RealTimeRedisManager.promises.getUpdatesLength(docId)
  58. if (length > 0) {
  59. await UpdateManager.processOutstandingUpdatesWithLock(projectId, docId)
  60. }
  61. },
  62. async fetchAndApplyUpdates(projectId, docId) {
  63. const profile = new Profiler('fetchAndApplyUpdates', {
  64. project_id: projectId,
  65. doc_id: docId,
  66. })
  67. const updates =
  68. await RealTimeRedisManager.promises.getPendingUpdatesForDoc(docId)
  69. logger.debug(
  70. { projectId, docId, count: updates.length },
  71. 'processing updates'
  72. )
  73. if (updates.length === 0) {
  74. return
  75. }
  76. profile.log('getPendingUpdatesForDoc')
  77. for (const update of updates) {
  78. await UpdateManager.applyUpdate(projectId, docId, update)
  79. profile.log('applyUpdate')
  80. }
  81. profile.log('async done').end()
  82. },
  83. /**
  84. * Apply an update to the given document
  85. *
  86. * @param {string} projectId
  87. * @param {string} docId
  88. * @param {Update} update
  89. */
  90. async applyUpdate(projectId, docId, update) {
  91. const profile = new Profiler('applyUpdate', {
  92. project_id: projectId,
  93. doc_id: docId,
  94. })
  95. UpdateManager._sanitizeUpdate(update)
  96. profile.log('sanitizeUpdate', { sync: true })
  97. try {
  98. let {
  99. lines,
  100. version,
  101. ranges,
  102. pathname,
  103. projectHistoryId,
  104. historyRangesSupport,
  105. } = await DocumentManager.promises.getDoc(projectId, docId)
  106. profile.log('getDoc')
  107. if (lines == null || version == null) {
  108. throw new Errors.NotFoundError(`document not found: ${docId}`)
  109. }
  110. const previousVersion = version
  111. const incomingUpdateVersion = update.v
  112. let updatedDocLines, appliedOps
  113. ;({ updatedDocLines, version, appliedOps } =
  114. await ShareJsUpdateManager.promises.applyUpdate(
  115. projectId,
  116. docId,
  117. update,
  118. lines,
  119. version
  120. ))
  121. profile.log('sharejs.applyUpdate', {
  122. // only synchronous when the update applies directly to the
  123. // doc version, otherwise getPreviousDocOps is called.
  124. sync: incomingUpdateVersion === previousVersion,
  125. })
  126. const { newRanges, rangesWereCollapsed, historyUpdates } =
  127. RangesManager.applyUpdate(
  128. projectId,
  129. docId,
  130. ranges,
  131. appliedOps,
  132. updatedDocLines,
  133. { historyRangesSupport }
  134. )
  135. profile.log('RangesManager.applyUpdate', { sync: true })
  136. await RedisManager.promises.updateDocument(
  137. projectId,
  138. docId,
  139. updatedDocLines,
  140. version,
  141. appliedOps,
  142. newRanges,
  143. update.meta
  144. )
  145. profile.log('RedisManager.updateDocument')
  146. UpdateManager._adjustHistoryUpdatesMetadata(
  147. historyUpdates,
  148. pathname,
  149. projectHistoryId,
  150. lines,
  151. ranges,
  152. historyRangesSupport
  153. )
  154. if (historyUpdates.length > 0) {
  155. Metrics.inc('history-queue', 1, { status: 'project-history' })
  156. try {
  157. const projectOpsLength =
  158. await ProjectHistoryRedisManager.promises.queueOps(
  159. projectId,
  160. ...historyUpdates.map(op => JSON.stringify(op))
  161. )
  162. HistoryManager.recordAndFlushHistoryOps(
  163. projectId,
  164. historyUpdates,
  165. projectOpsLength
  166. )
  167. profile.log('recordAndFlushHistoryOps')
  168. } catch (err) {
  169. // The full project history can re-sync a project in case
  170. // updates went missing.
  171. // Just record the error here and acknowledge the write-op.
  172. Metrics.inc('history-queue-error')
  173. }
  174. }
  175. if (rangesWereCollapsed) {
  176. Metrics.inc('doc-snapshot')
  177. logger.debug(
  178. {
  179. projectId,
  180. docId,
  181. previousVersion,
  182. lines,
  183. ranges,
  184. update,
  185. },
  186. 'update collapsed some ranges, snapshotting previous content'
  187. )
  188. // Do this last, since it's a mongo call, and so potentially longest running
  189. // If it overruns the lock, it's ok, since all of our redis work is done
  190. await SnapshotManager.promises.recordSnapshot(
  191. projectId,
  192. docId,
  193. previousVersion,
  194. pathname,
  195. lines,
  196. ranges
  197. )
  198. }
  199. } catch (error) {
  200. RealTimeRedisManager.sendData({
  201. project_id: projectId,
  202. doc_id: docId,
  203. error: error instanceof Error ? error.message : error,
  204. })
  205. profile.log('sendData')
  206. throw error
  207. } finally {
  208. profile.end()
  209. }
  210. },
  211. async lockUpdatesAndDo(method, projectId, docId, ...args) {
  212. const profile = new Profiler('lockUpdatesAndDo', {
  213. project_id: projectId,
  214. doc_id: docId,
  215. })
  216. const lockValue = await LockManager.promises.getLock(docId)
  217. profile.log('getLock')
  218. let result
  219. try {
  220. await UpdateManager.processOutstandingUpdates(projectId, docId)
  221. profile.log('processOutstandingUpdates')
  222. result = await method(projectId, docId, ...args)
  223. profile.log('method')
  224. } finally {
  225. await LockManager.promises.releaseLock(docId, lockValue)
  226. profile.log('releaseLock').end()
  227. }
  228. // We held the lock for a while so updates might have queued up
  229. UpdateManager.continueProcessingUpdatesWithLock(projectId, docId).catch(
  230. err => {
  231. // The processing may fail for invalid user updates.
  232. // This can be very noisy, put them on level DEBUG
  233. // and record a metric.
  234. Metrics.inc('background-processing-updates-error')
  235. logger.debug(
  236. { err, projectId, docId },
  237. 'error processing updates in background'
  238. )
  239. }
  240. )
  241. return result
  242. },
  243. _sanitizeUpdate(update) {
  244. // In Javascript, characters are 16-bits wide. It does not understand surrogates as characters.
  245. //
  246. // From Wikipedia (http://en.wikipedia.org/wiki/Plane_(Unicode)#Basic_Multilingual_Plane):
  247. // "The High Surrogates (U+D800–U+DBFF) and Low Surrogate (U+DC00–U+DFFF) codes are reserved
  248. // for encoding non-BMP characters in UTF-16 by using a pair of 16-bit codes: one High Surrogate
  249. // and one Low Surrogate. A single surrogate code point will never be assigned a character.""
  250. //
  251. // The main offender seems to be \uD835 as a stand alone character, which would be the first
  252. // 16-bit character of a blackboard bold character (http://www.fileformat.info/info/unicode/char/1d400/index.htm).
  253. // Something must be going on client side that is screwing up the encoding and splitting the
  254. // two 16-bit characters so that \uD835 is standalone.
  255. for (const op of update.op || []) {
  256. if (op.i != null) {
  257. // Replace high and low surrogate characters with 'replacement character' (\uFFFD)
  258. op.i = op.i.replace(/[\uD800-\uDFFF]/g, '\uFFFD')
  259. }
  260. }
  261. return update
  262. },
  263. /**
  264. * Add metadata that will be useful to project history
  265. *
  266. * @param {HistoryUpdate[]} updates
  267. * @param {string} pathname
  268. * @param {string} projectHistoryId
  269. * @param {string[]} lines
  270. * @param {Ranges} ranges
  271. * @param {boolean} historyRangesSupport
  272. */
  273. _adjustHistoryUpdatesMetadata(
  274. updates,
  275. pathname,
  276. projectHistoryId,
  277. lines,
  278. ranges,
  279. historyRangesSupport
  280. ) {
  281. let docLength = _.reduce(lines, (chars, line) => chars + line.length, 0)
  282. // Add newline characters. Lines are joined by newlines, but the last line
  283. // doesn't include a newline. We must make a special case for an empty list
  284. // so that it doesn't report a doc length of -1.
  285. docLength += Math.max(lines.length - 1, 0)
  286. let historyDocLength = docLength
  287. for (const change of ranges.changes ?? []) {
  288. if ('d' in change.op) {
  289. historyDocLength += change.op.d.length
  290. }
  291. }
  292. for (const update of updates) {
  293. update.projectHistoryId = projectHistoryId
  294. if (!update.meta) {
  295. update.meta = {}
  296. }
  297. update.meta.pathname = pathname
  298. update.meta.doc_length = docLength
  299. if (historyRangesSupport && historyDocLength !== docLength) {
  300. update.meta.history_doc_length = historyDocLength
  301. }
  302. // Each update may contain multiple ops, i.e.
  303. // [{
  304. // ops: [{i: "foo", p: 4}, {d: "bar", p:8}]
  305. // }, {
  306. // ops: [{d: "baz", p: 40}, {i: "qux", p:8}]
  307. // }]
  308. // We want to include the doc_length at the start of each update,
  309. // before it's ops are applied. However, we need to track any
  310. // changes to it for the next update.
  311. for (const op of update.op) {
  312. if (isInsert(op)) {
  313. docLength += op.i.length
  314. if (!op.trackedDeleteRejection) {
  315. // Tracked delete rejections end up retaining characters rather
  316. // than inserting
  317. historyDocLength += op.i.length
  318. }
  319. }
  320. if (isDelete(op)) {
  321. docLength -= op.d.length
  322. if (!update.meta.tc || op.u) {
  323. // This is either a regular delete or a tracked insert rejection.
  324. // It will be translated to a delete in history. Tracked deletes
  325. // are translated into retains and don't change the history doc
  326. // length.
  327. historyDocLength -= op.d.length
  328. }
  329. }
  330. }
  331. if (!historyRangesSupport) {
  332. // Prevent project-history from processing tracked changes
  333. delete update.meta.tc
  334. }
  335. }
  336. },
  337. }
  338. module.exports = { ...callbackifyAll(UpdateManager), promises: UpdateManager }