UpdateManager.js 12 KB

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