UpdateManager.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  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 = await RealTimeRedisManager.promises.getPendingUpdatesForDoc(
  68. docId
  69. )
  70. logger.debug(
  71. { projectId, docId, count: updates.length },
  72. 'processing updates'
  73. )
  74. if (updates.length === 0) {
  75. return
  76. }
  77. profile.log('getPendingUpdatesForDoc')
  78. for (const update of updates) {
  79. await UpdateManager.applyUpdate(projectId, docId, update)
  80. profile.log('applyUpdate')
  81. }
  82. profile.log('async done').end()
  83. },
  84. /**
  85. * Apply an update to the given document
  86. *
  87. * @param {string} projectId
  88. * @param {string} docId
  89. * @param {Update} update
  90. */
  91. async applyUpdate(projectId, docId, update) {
  92. const profile = new Profiler('applyUpdate', {
  93. project_id: projectId,
  94. doc_id: docId,
  95. })
  96. UpdateManager._sanitizeUpdate(update)
  97. profile.log('sanitizeUpdate', { sync: true })
  98. try {
  99. let {
  100. lines,
  101. version,
  102. ranges,
  103. pathname,
  104. projectHistoryId,
  105. historyRangesSupport,
  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. const previousVersion = version
  112. const incomingUpdateVersion = update.v
  113. let updatedDocLines, appliedOps
  114. ;({ updatedDocLines, version, appliedOps } =
  115. await ShareJsUpdateManager.promises.applyUpdate(
  116. projectId,
  117. docId,
  118. update,
  119. lines,
  120. version
  121. ))
  122. profile.log('sharejs.applyUpdate', {
  123. // only synchronous when the update applies directly to the
  124. // doc version, otherwise getPreviousDocOps is called.
  125. sync: incomingUpdateVersion === previousVersion,
  126. })
  127. let { newRanges, rangesWereCollapsed, historyUpdates } =
  128. RangesManager.applyUpdate(
  129. projectId,
  130. docId,
  131. ranges,
  132. appliedOps,
  133. updatedDocLines
  134. )
  135. if (!historyRangesSupport) {
  136. // The document has not been transitioned to include comments and
  137. // tracked changes in its history. Send regular updates rather than the
  138. // full history updates.
  139. historyUpdates = appliedOps
  140. }
  141. profile.log('RangesManager.applyUpdate', { sync: true })
  142. await RedisManager.promises.updateDocument(
  143. projectId,
  144. docId,
  145. updatedDocLines,
  146. version,
  147. appliedOps,
  148. newRanges,
  149. update.meta
  150. )
  151. profile.log('RedisManager.updateDocument')
  152. UpdateManager._addMetadataToHistoryUpdates(
  153. historyUpdates,
  154. pathname,
  155. projectHistoryId,
  156. lines,
  157. ranges,
  158. historyRangesSupport
  159. )
  160. if (historyUpdates.length > 0) {
  161. Metrics.inc('history-queue', 1, { status: 'project-history' })
  162. try {
  163. const projectOpsLength =
  164. await ProjectHistoryRedisManager.promises.queueOps(
  165. projectId,
  166. ...historyUpdates.map(op => JSON.stringify(op))
  167. )
  168. HistoryManager.recordAndFlushHistoryOps(
  169. projectId,
  170. historyUpdates,
  171. projectOpsLength
  172. )
  173. profile.log('recordAndFlushHistoryOps')
  174. } catch (err) {
  175. // The full project history can re-sync a project in case
  176. // updates went missing.
  177. // Just record the error here and acknowledge the write-op.
  178. Metrics.inc('history-queue-error')
  179. }
  180. }
  181. if (rangesWereCollapsed) {
  182. Metrics.inc('doc-snapshot')
  183. logger.debug(
  184. {
  185. projectId,
  186. docId,
  187. previousVersion,
  188. lines,
  189. ranges,
  190. update,
  191. },
  192. 'update collapsed some ranges, snapshotting previous content'
  193. )
  194. // Do this last, since it's a mongo call, and so potentially longest running
  195. // If it overruns the lock, it's ok, since all of our redis work is done
  196. await SnapshotManager.promises.recordSnapshot(
  197. projectId,
  198. docId,
  199. previousVersion,
  200. pathname,
  201. lines,
  202. ranges
  203. )
  204. }
  205. } catch (error) {
  206. RealTimeRedisManager.sendData({
  207. project_id: projectId,
  208. doc_id: docId,
  209. error: error instanceof Error ? error.message : error,
  210. })
  211. profile.log('sendData')
  212. throw error
  213. } finally {
  214. profile.end()
  215. }
  216. },
  217. // lockUpdatesAndDo can't be promisified yet because it expects a
  218. // callback-style function
  219. async lockUpdatesAndDo(method, projectId, docId, ...args) {
  220. const profile = new Profiler('lockUpdatesAndDo', {
  221. project_id: projectId,
  222. doc_id: docId,
  223. })
  224. const lockValue = await LockManager.promises.getLock(docId)
  225. profile.log('getLock')
  226. let responseArgs
  227. try {
  228. await UpdateManager.processOutstandingUpdates(projectId, docId)
  229. profile.log('processOutstandingUpdates')
  230. // TODO: method is still a callback-style function. Change this when promisifying DocumentManager
  231. responseArgs = await new Promise((resolve, reject) => {
  232. method(projectId, docId, ...args, (error, ...responseArgs) => {
  233. if (error) {
  234. reject(error)
  235. } else {
  236. resolve(responseArgs)
  237. }
  238. })
  239. })
  240. profile.log('method')
  241. } finally {
  242. await LockManager.promises.releaseLock(docId, lockValue)
  243. profile.log('releaseLock').end()
  244. }
  245. // We held the lock for a while so updates might have queued up
  246. UpdateManager.continueProcessingUpdatesWithLock(projectId, docId).catch(
  247. err => {
  248. // The processing may fail for invalid user updates.
  249. // This can be very noisy, put them on level DEBUG
  250. // and record a metric.
  251. Metrics.inc('background-processing-updates-error')
  252. logger.debug(
  253. { err, projectId, docId },
  254. 'error processing updates in background'
  255. )
  256. }
  257. )
  258. return responseArgs
  259. },
  260. _sanitizeUpdate(update) {
  261. // In Javascript, characters are 16-bits wide. It does not understand surrogates as characters.
  262. //
  263. // From Wikipedia (http://en.wikipedia.org/wiki/Plane_(Unicode)#Basic_Multilingual_Plane):
  264. // "The High Surrogates (U+D800–U+DBFF) and Low Surrogate (U+DC00–U+DFFF) codes are reserved
  265. // for encoding non-BMP characters in UTF-16 by using a pair of 16-bit codes: one High Surrogate
  266. // and one Low Surrogate. A single surrogate code point will never be assigned a character.""
  267. //
  268. // The main offender seems to be \uD835 as a stand alone character, which would be the first
  269. // 16-bit character of a blackboard bold character (http://www.fileformat.info/info/unicode/char/1d400/index.htm).
  270. // Something must be going on client side that is screwing up the encoding and splitting the
  271. // two 16-bit characters so that \uD835 is standalone.
  272. for (const op of update.op || []) {
  273. if (op.i != null) {
  274. // Replace high and low surrogate characters with 'replacement character' (\uFFFD)
  275. op.i = op.i.replace(/[\uD800-\uDFFF]/g, '\uFFFD')
  276. }
  277. }
  278. return update
  279. },
  280. /**
  281. * Add metadata that will be useful to project history
  282. *
  283. * @param {HistoryUpdate[]} updates
  284. * @param {string} pathname
  285. * @param {string} projectHistoryId
  286. * @param {string[]} lines
  287. * @param {Ranges} ranges
  288. * @param {boolean} historyRangesSupport
  289. */
  290. _addMetadataToHistoryUpdates(
  291. updates,
  292. pathname,
  293. projectHistoryId,
  294. lines,
  295. ranges,
  296. historyRangesSupport
  297. ) {
  298. let docLength = _.reduce(lines, (chars, line) => chars + line.length, 0)
  299. docLength += lines.length - 1 // count newline characters
  300. let historyDocLength = docLength
  301. for (const change of ranges.changes ?? []) {
  302. if ('d' in change.op) {
  303. historyDocLength += change.op.d.length
  304. }
  305. }
  306. for (const update of updates) {
  307. update.projectHistoryId = projectHistoryId
  308. if (!update.meta) {
  309. update.meta = {}
  310. }
  311. update.meta.pathname = pathname
  312. update.meta.doc_length = docLength
  313. if (historyRangesSupport && historyDocLength !== docLength) {
  314. update.meta.history_doc_length = historyDocLength
  315. }
  316. // Each update may contain multiple ops, i.e.
  317. // [{
  318. // ops: [{i: "foo", p: 4}, {d: "bar", p:8}]
  319. // }, {
  320. // ops: [{d: "baz", p: 40}, {i: "qux", p:8}]
  321. // }]
  322. // We want to include the doc_length at the start of each update,
  323. // before it's ops are applied. However, we need to track any
  324. // changes to it for the next update.
  325. for (const op of update.op) {
  326. if (isInsert(op)) {
  327. docLength += op.i.length
  328. if (!op.trackedDeleteRejection) {
  329. // Tracked delete rejections end up retaining characters rather
  330. // than inserting
  331. historyDocLength += op.i.length
  332. }
  333. }
  334. if (isDelete(op)) {
  335. docLength -= op.d.length
  336. if (!update.meta.tc || op.u) {
  337. // This is either a regular delete or a tracked insert rejection.
  338. // It will be translated to a delete in history. Tracked deletes
  339. // are translated into retains and don't change the history doc
  340. // length.
  341. historyDocLength -= op.d.length
  342. }
  343. }
  344. }
  345. }
  346. },
  347. }
  348. const CallbackifiedUpdateManager = callbackifyAll(UpdateManager)
  349. module.exports = CallbackifiedUpdateManager
  350. module.exports.promises = UpdateManager
  351. module.exports.lockUpdatesAndDo = function lockUpdatesAndDo(
  352. method,
  353. projectId,
  354. docId,
  355. ...rest
  356. ) {
  357. const adjustedLength = Math.max(rest.length, 1)
  358. const args = rest.slice(0, adjustedLength - 1)
  359. const callback = rest[adjustedLength - 1]
  360. // TODO: During the transition to promises, UpdateManager.lockUpdatesAndDo
  361. // returns the potentially multiple arguments that must be provided to the
  362. // callback in an array.
  363. UpdateManager.lockUpdatesAndDo(method, projectId, docId, ...args)
  364. .then(responseArgs => {
  365. callback(null, ...responseArgs)
  366. })
  367. .catch(err => {
  368. callback(err)
  369. })
  370. }