UpdateManager.js 11 KB

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