UpdateManager.js 13 KB

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