UpdateManager.js 12 KB

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