DocumentManager.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. const { callbackifyAll } = require('@overleaf/promise-utils')
  2. const RedisManager = require('./RedisManager')
  3. const ProjectHistoryRedisManager = require('./ProjectHistoryRedisManager')
  4. const PersistenceManager = require('./PersistenceManager')
  5. const DiffCodec = require('./DiffCodec')
  6. const logger = require('@overleaf/logger')
  7. const Metrics = require('./Metrics')
  8. const HistoryManager = require('./HistoryManager')
  9. const Errors = require('./Errors')
  10. const RangesManager = require('./RangesManager')
  11. const { extractOriginOrSource } = require('./Utils')
  12. const MAX_UNFLUSHED_AGE = 300 * 1000 // 5 mins, document should be flushed to mongo this time after a change
  13. const DocumentManager = {
  14. async getDoc(projectId, docId) {
  15. const {
  16. lines,
  17. version,
  18. ranges,
  19. resolvedCommentIds,
  20. pathname,
  21. projectHistoryId,
  22. unflushedTime,
  23. historyRangesSupport,
  24. } = await RedisManager.promises.getDoc(projectId, docId)
  25. if (lines == null || version == null) {
  26. logger.debug(
  27. { projectId, docId },
  28. 'doc not in redis so getting from persistence API'
  29. )
  30. const {
  31. lines,
  32. version,
  33. ranges,
  34. resolvedCommentIds,
  35. pathname,
  36. projectHistoryId,
  37. historyRangesSupport,
  38. } = await PersistenceManager.promises.getDoc(projectId, docId)
  39. logger.debug(
  40. {
  41. projectId,
  42. docId,
  43. lines,
  44. ranges,
  45. resolvedCommentIds,
  46. version,
  47. pathname,
  48. projectHistoryId,
  49. historyRangesSupport,
  50. },
  51. 'got doc from persistence API'
  52. )
  53. await RedisManager.promises.putDocInMemory(
  54. projectId,
  55. docId,
  56. lines,
  57. version,
  58. ranges,
  59. resolvedCommentIds,
  60. pathname,
  61. projectHistoryId,
  62. historyRangesSupport
  63. )
  64. return {
  65. lines,
  66. version,
  67. ranges: ranges || {},
  68. resolvedCommentIds,
  69. pathname,
  70. projectHistoryId,
  71. unflushedTime: null,
  72. alreadyLoaded: false,
  73. historyRangesSupport,
  74. }
  75. } else {
  76. return {
  77. lines,
  78. version,
  79. ranges,
  80. pathname,
  81. projectHistoryId,
  82. resolvedCommentIds,
  83. unflushedTime,
  84. alreadyLoaded: true,
  85. historyRangesSupport,
  86. }
  87. }
  88. },
  89. async getDocAndRecentOps(projectId, docId, fromVersion) {
  90. const { lines, version, ranges, pathname, projectHistoryId } =
  91. await DocumentManager.getDoc(projectId, docId)
  92. if (fromVersion === -1) {
  93. return { lines, version, ops: [], ranges, pathname, projectHistoryId }
  94. } else {
  95. const ops = await RedisManager.promises.getPreviousDocOps(
  96. docId,
  97. fromVersion,
  98. version
  99. )
  100. return {
  101. lines,
  102. version,
  103. ops,
  104. ranges,
  105. pathname,
  106. projectHistoryId,
  107. }
  108. }
  109. },
  110. async setDoc(projectId, docId, newLines, originOrSource, userId, undoing) {
  111. if (newLines == null) {
  112. throw new Error('No lines were provided to setDoc')
  113. }
  114. const UpdateManager = require('./UpdateManager')
  115. const {
  116. lines: oldLines,
  117. version,
  118. alreadyLoaded,
  119. } = await DocumentManager.getDoc(projectId, docId)
  120. if (oldLines != null && oldLines.length > 0 && oldLines[0].text != null) {
  121. logger.debug(
  122. { docId, projectId, oldLines, newLines },
  123. 'document is JSON so not updating'
  124. )
  125. return
  126. }
  127. logger.debug(
  128. { docId, projectId, oldLines, newLines },
  129. 'setting a document via http'
  130. )
  131. const op = DiffCodec.diffAsShareJsOp(oldLines, newLines)
  132. if (undoing) {
  133. for (const o of op || []) {
  134. o.u = true
  135. } // Turn on undo flag for each op for track changes
  136. }
  137. const { origin, source } = extractOriginOrSource(originOrSource)
  138. const update = {
  139. doc: docId,
  140. op,
  141. v: version,
  142. meta: {
  143. type: 'external',
  144. user_id: userId,
  145. },
  146. }
  147. if (origin) {
  148. update.meta.origin = origin
  149. } else if (source) {
  150. update.meta.source = source
  151. }
  152. // Keep track of external updates, whether they are for live documents
  153. // (flush) or unloaded documents (evict), and whether the update is a no-op.
  154. Metrics.inc('external-update', 1, {
  155. status: op.length > 0 ? 'diff' : 'noop',
  156. method: alreadyLoaded ? 'flush' : 'evict',
  157. path: source,
  158. })
  159. // Do not notify the frontend about a noop update.
  160. // We still want to execute the code below
  161. // to evict the doc if we loaded it into redis for
  162. // this update, otherwise the doc would never be
  163. // removed from redis.
  164. if (op.length > 0) {
  165. await UpdateManager.promises.applyUpdate(projectId, docId, update)
  166. }
  167. // If the document was loaded already, then someone has it open
  168. // in a project, and the usual flushing mechanism will happen.
  169. // Otherwise we should remove it immediately since nothing else
  170. // is using it.
  171. if (alreadyLoaded) {
  172. return await DocumentManager.flushDocIfLoaded(projectId, docId)
  173. } else {
  174. try {
  175. return await DocumentManager.flushAndDeleteDoc(projectId, docId, {})
  176. } finally {
  177. // There is no harm in flushing project history if the previous
  178. // call failed and sometimes it is required
  179. HistoryManager.flushProjectChangesAsync(projectId)
  180. }
  181. }
  182. },
  183. async flushDocIfLoaded(projectId, docId) {
  184. const {
  185. lines,
  186. version,
  187. ranges,
  188. unflushedTime,
  189. lastUpdatedAt,
  190. lastUpdatedBy,
  191. } = await RedisManager.promises.getDoc(projectId, docId)
  192. if (lines == null || version == null) {
  193. Metrics.inc('flush-doc-if-loaded', 1, { status: 'not-loaded' })
  194. logger.debug({ projectId, docId }, 'doc is not loaded so not flushing')
  195. // TODO: return a flag to bail out, as we go on to remove doc from memory?
  196. return
  197. } else if (unflushedTime == null) {
  198. Metrics.inc('flush-doc-if-loaded', 1, { status: 'unmodified' })
  199. logger.debug({ projectId, docId }, 'doc is not modified so not flushing')
  200. return
  201. }
  202. logger.debug({ projectId, docId, version }, 'flushing doc')
  203. Metrics.inc('flush-doc-if-loaded', 1, { status: 'modified' })
  204. const result = await PersistenceManager.promises.setDoc(
  205. projectId,
  206. docId,
  207. lines,
  208. version,
  209. ranges,
  210. lastUpdatedAt,
  211. lastUpdatedBy || null
  212. )
  213. await RedisManager.promises.clearUnflushedTime(docId)
  214. return result
  215. },
  216. async flushAndDeleteDoc(projectId, docId, options) {
  217. let result
  218. try {
  219. result = await DocumentManager.flushDocIfLoaded(projectId, docId)
  220. } catch (error) {
  221. if (options.ignoreFlushErrors) {
  222. logger.warn(
  223. { projectId, docId, err: error },
  224. 'ignoring flush error while deleting document'
  225. )
  226. } else {
  227. throw error
  228. }
  229. }
  230. await RedisManager.promises.removeDocFromMemory(projectId, docId)
  231. return result
  232. },
  233. async acceptChanges(projectId, docId, changeIds) {
  234. if (changeIds == null) {
  235. changeIds = []
  236. }
  237. const {
  238. lines,
  239. version,
  240. ranges,
  241. pathname,
  242. projectHistoryId,
  243. historyRangesSupport,
  244. } = await DocumentManager.getDoc(projectId, docId)
  245. if (lines == null || version == null) {
  246. throw new Errors.NotFoundError(`document not found: ${docId}`)
  247. }
  248. const newRanges = RangesManager.acceptChanges(changeIds, ranges)
  249. await RedisManager.promises.updateDocument(
  250. projectId,
  251. docId,
  252. lines,
  253. version,
  254. [],
  255. newRanges,
  256. {}
  257. )
  258. if (historyRangesSupport) {
  259. const historyUpdates = RangesManager.getHistoryUpdatesForAcceptedChanges({
  260. docId,
  261. acceptedChangeIds: changeIds,
  262. changes: ranges.changes || [],
  263. lines,
  264. pathname,
  265. projectHistoryId,
  266. })
  267. if (historyUpdates.length === 0) {
  268. return
  269. }
  270. await ProjectHistoryRedisManager.promises.queueOps(
  271. projectId,
  272. ...historyUpdates.map(op => JSON.stringify(op))
  273. )
  274. }
  275. },
  276. async updateCommentState(projectId, docId, commentId, userId, resolved) {
  277. const { lines, version, pathname, historyRangesSupport } =
  278. await DocumentManager.getDoc(projectId, docId)
  279. if (lines == null || version == null) {
  280. throw new Errors.NotFoundError(`document not found: ${docId}`)
  281. }
  282. if (historyRangesSupport) {
  283. await RedisManager.promises.updateCommentState(docId, commentId, resolved)
  284. await ProjectHistoryRedisManager.promises.queueOps(
  285. projectId,
  286. JSON.stringify({
  287. pathname,
  288. commentId,
  289. resolved,
  290. meta: {
  291. ts: new Date(),
  292. user_id: userId,
  293. },
  294. })
  295. )
  296. }
  297. },
  298. async deleteComment(projectId, docId, commentId, userId) {
  299. const { lines, version, ranges, pathname, historyRangesSupport } =
  300. await DocumentManager.getDoc(projectId, docId)
  301. if (lines == null || version == null) {
  302. throw new Errors.NotFoundError(`document not found: ${docId}`)
  303. }
  304. const newRanges = RangesManager.deleteComment(commentId, ranges)
  305. await RedisManager.promises.updateDocument(
  306. projectId,
  307. docId,
  308. lines,
  309. version,
  310. [],
  311. newRanges,
  312. {}
  313. )
  314. if (historyRangesSupport) {
  315. await RedisManager.promises.updateCommentState(docId, commentId, false)
  316. await ProjectHistoryRedisManager.promises.queueOps(
  317. projectId,
  318. JSON.stringify({
  319. pathname,
  320. deleteComment: commentId,
  321. meta: {
  322. ts: new Date(),
  323. user_id: userId,
  324. },
  325. })
  326. )
  327. }
  328. },
  329. async renameDoc(projectId, docId, userId, update, projectHistoryId) {
  330. await RedisManager.promises.renameDoc(
  331. projectId,
  332. docId,
  333. userId,
  334. update,
  335. projectHistoryId
  336. )
  337. },
  338. async getDocAndFlushIfOld(projectId, docId) {
  339. const { lines, version, unflushedTime, alreadyLoaded } =
  340. await DocumentManager.getDoc(projectId, docId)
  341. // if doc was already loaded see if it needs to be flushed
  342. if (
  343. alreadyLoaded &&
  344. unflushedTime != null &&
  345. Date.now() - unflushedTime > MAX_UNFLUSHED_AGE
  346. ) {
  347. await DocumentManager.flushDocIfLoaded(projectId, docId)
  348. }
  349. return { lines, version }
  350. },
  351. async resyncDocContents(projectId, docId, path, opts = {}) {
  352. logger.debug({ projectId, docId, path }, 'start resyncing doc contents')
  353. let {
  354. lines,
  355. ranges,
  356. resolvedCommentIds,
  357. version,
  358. projectHistoryId,
  359. historyRangesSupport,
  360. } = await RedisManager.promises.getDoc(projectId, docId)
  361. // To avoid issues where the same docId appears with different paths,
  362. // we use the path from the resyncProjectStructure update. If we used
  363. // the path from the getDoc call to web then the two occurences of the
  364. // docId would map to the same path, and this would be rejected by
  365. // project-history as an unexpected resyncDocContent update.
  366. if (lines == null || version == null) {
  367. logger.debug(
  368. { projectId, docId },
  369. 'resyncing doc contents - not found in redis - retrieving from web'
  370. )
  371. ;({
  372. lines,
  373. ranges,
  374. resolvedCommentIds,
  375. version,
  376. projectHistoryId,
  377. historyRangesSupport,
  378. } = await PersistenceManager.promises.getDoc(projectId, docId, {
  379. peek: true,
  380. }))
  381. } else {
  382. logger.debug(
  383. { projectId, docId },
  384. 'resyncing doc contents - doc in redis - will queue in redis'
  385. )
  386. }
  387. if (opts.historyRangesMigration) {
  388. historyRangesSupport = opts.historyRangesMigration === 'forwards'
  389. }
  390. await ProjectHistoryRedisManager.promises.queueResyncDocContent(
  391. projectId,
  392. projectHistoryId,
  393. docId,
  394. lines,
  395. ranges ?? {},
  396. resolvedCommentIds,
  397. version,
  398. // use the path from the resyncProjectStructure update
  399. path,
  400. historyRangesSupport
  401. )
  402. if (opts.historyRangesMigration) {
  403. await RedisManager.promises.setHistoryRangesSupportFlag(
  404. docId,
  405. historyRangesSupport
  406. )
  407. }
  408. },
  409. async getDocWithLock(projectId, docId) {
  410. const UpdateManager = require('./UpdateManager')
  411. return await UpdateManager.promises.lockUpdatesAndDo(
  412. DocumentManager.getDoc,
  413. projectId,
  414. docId
  415. )
  416. },
  417. async getDocAndRecentOpsWithLock(projectId, docId, fromVersion) {
  418. const UpdateManager = require('./UpdateManager')
  419. return await UpdateManager.promises.lockUpdatesAndDo(
  420. DocumentManager.getDocAndRecentOps,
  421. projectId,
  422. docId,
  423. fromVersion
  424. )
  425. },
  426. async getDocAndFlushIfOldWithLock(projectId, docId) {
  427. const UpdateManager = require('./UpdateManager')
  428. return await UpdateManager.promises.lockUpdatesAndDo(
  429. DocumentManager.getDocAndFlushIfOld,
  430. projectId,
  431. docId
  432. )
  433. },
  434. async setDocWithLock(projectId, docId, lines, source, userId, undoing) {
  435. const UpdateManager = require('./UpdateManager')
  436. return await UpdateManager.promises.lockUpdatesAndDo(
  437. DocumentManager.setDoc,
  438. projectId,
  439. docId,
  440. lines,
  441. source,
  442. userId,
  443. undoing
  444. )
  445. },
  446. async flushDocIfLoadedWithLock(projectId, docId) {
  447. const UpdateManager = require('./UpdateManager')
  448. return await UpdateManager.promises.lockUpdatesAndDo(
  449. DocumentManager.flushDocIfLoaded,
  450. projectId,
  451. docId
  452. )
  453. },
  454. async flushAndDeleteDocWithLock(projectId, docId, options) {
  455. const UpdateManager = require('./UpdateManager')
  456. return await UpdateManager.promises.lockUpdatesAndDo(
  457. DocumentManager.flushAndDeleteDoc,
  458. projectId,
  459. docId,
  460. options
  461. )
  462. },
  463. async acceptChangesWithLock(projectId, docId, changeIds) {
  464. const UpdateManager = require('./UpdateManager')
  465. await UpdateManager.promises.lockUpdatesAndDo(
  466. DocumentManager.acceptChanges,
  467. projectId,
  468. docId,
  469. changeIds
  470. )
  471. },
  472. async updateCommentStateWithLock(
  473. projectId,
  474. docId,
  475. threadId,
  476. userId,
  477. resolved
  478. ) {
  479. const UpdateManager = require('./UpdateManager')
  480. await UpdateManager.promises.lockUpdatesAndDo(
  481. DocumentManager.updateCommentState,
  482. projectId,
  483. docId,
  484. threadId,
  485. userId,
  486. resolved
  487. )
  488. },
  489. async deleteCommentWithLock(projectId, docId, threadId, userId) {
  490. const UpdateManager = require('./UpdateManager')
  491. await UpdateManager.promises.lockUpdatesAndDo(
  492. DocumentManager.deleteComment,
  493. projectId,
  494. docId,
  495. threadId,
  496. userId
  497. )
  498. },
  499. async renameDocWithLock(projectId, docId, userId, update, projectHistoryId) {
  500. const UpdateManager = require('./UpdateManager')
  501. await UpdateManager.promises.lockUpdatesAndDo(
  502. DocumentManager.renameDoc,
  503. projectId,
  504. docId,
  505. userId,
  506. update,
  507. projectHistoryId
  508. )
  509. },
  510. async resyncDocContentsWithLock(projectId, docId, path, opts) {
  511. const UpdateManager = require('./UpdateManager')
  512. await UpdateManager.promises.lockUpdatesAndDo(
  513. DocumentManager.resyncDocContents,
  514. projectId,
  515. docId,
  516. path,
  517. opts
  518. )
  519. },
  520. }
  521. module.exports = {
  522. ...callbackifyAll(DocumentManager, {
  523. multiResult: {
  524. getDoc: [
  525. 'lines',
  526. 'version',
  527. 'ranges',
  528. 'pathname',
  529. 'projectHistoryId',
  530. 'unflushedTime',
  531. 'alreadyLoaded',
  532. 'historyRangesSupport',
  533. ],
  534. getDocWithLock: [
  535. 'lines',
  536. 'version',
  537. 'ranges',
  538. 'pathname',
  539. 'projectHistoryId',
  540. 'unflushedTime',
  541. 'alreadyLoaded',
  542. 'historyRangesSupport',
  543. ],
  544. getDocAndFlushIfOld: ['lines', 'version'],
  545. getDocAndFlushIfOldWithLock: ['lines', 'version'],
  546. getDocAndRecentOps: [
  547. 'lines',
  548. 'version',
  549. 'ops',
  550. 'ranges',
  551. 'pathname',
  552. 'projectHistoryId',
  553. ],
  554. getDocAndRecentOpsWithLock: [
  555. 'lines',
  556. 'version',
  557. 'ops',
  558. 'ranges',
  559. 'pathname',
  560. 'projectHistoryId',
  561. ],
  562. },
  563. }),
  564. promises: DocumentManager,
  565. }