DocumentManager.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
  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(
  249. projectId,
  250. docId,
  251. changeIds,
  252. ranges
  253. )
  254. await RedisManager.promises.updateDocument(
  255. projectId,
  256. docId,
  257. lines,
  258. version,
  259. [],
  260. newRanges,
  261. {}
  262. )
  263. if (historyRangesSupport) {
  264. const historyUpdates = RangesManager.getHistoryUpdatesForAcceptedChanges({
  265. docId,
  266. acceptedChangeIds: changeIds,
  267. changes: ranges.changes || [],
  268. lines,
  269. pathname,
  270. projectHistoryId,
  271. })
  272. if (historyUpdates.length === 0) {
  273. return
  274. }
  275. await ProjectHistoryRedisManager.promises.queueOps(
  276. projectId,
  277. ...historyUpdates.map(op => JSON.stringify(op))
  278. )
  279. }
  280. },
  281. async updateCommentState(projectId, docId, commentId, userId, resolved) {
  282. const { lines, version, pathname, historyRangesSupport } =
  283. await DocumentManager.getDoc(projectId, docId)
  284. if (lines == null || version == null) {
  285. throw new Errors.NotFoundError(`document not found: ${docId}`)
  286. }
  287. if (historyRangesSupport) {
  288. await RedisManager.promises.updateCommentState(docId, commentId, resolved)
  289. await ProjectHistoryRedisManager.promises.queueOps(
  290. projectId,
  291. JSON.stringify({
  292. pathname,
  293. commentId,
  294. resolved,
  295. meta: {
  296. ts: new Date(),
  297. user_id: userId,
  298. },
  299. })
  300. )
  301. }
  302. },
  303. async deleteComment(projectId, docId, commentId, userId) {
  304. const { lines, version, ranges, pathname, historyRangesSupport } =
  305. await DocumentManager.getDoc(projectId, docId)
  306. if (lines == null || version == null) {
  307. throw new Errors.NotFoundError(`document not found: ${docId}`)
  308. }
  309. const newRanges = RangesManager.deleteComment(commentId, ranges)
  310. await RedisManager.promises.updateDocument(
  311. projectId,
  312. docId,
  313. lines,
  314. version,
  315. [],
  316. newRanges,
  317. {}
  318. )
  319. if (historyRangesSupport) {
  320. await RedisManager.promises.updateCommentState(docId, commentId, false)
  321. await ProjectHistoryRedisManager.promises.queueOps(
  322. projectId,
  323. JSON.stringify({
  324. pathname,
  325. deleteComment: commentId,
  326. meta: {
  327. ts: new Date(),
  328. user_id: userId,
  329. },
  330. })
  331. )
  332. }
  333. },
  334. async renameDoc(projectId, docId, userId, update, projectHistoryId) {
  335. await RedisManager.promises.renameDoc(
  336. projectId,
  337. docId,
  338. userId,
  339. update,
  340. projectHistoryId
  341. )
  342. },
  343. async getDocAndFlushIfOld(projectId, docId) {
  344. const { lines, version, unflushedTime, alreadyLoaded } =
  345. await DocumentManager.getDoc(projectId, docId)
  346. // if doc was already loaded see if it needs to be flushed
  347. if (
  348. alreadyLoaded &&
  349. unflushedTime != null &&
  350. Date.now() - unflushedTime > MAX_UNFLUSHED_AGE
  351. ) {
  352. await DocumentManager.flushDocIfLoaded(projectId, docId)
  353. }
  354. return { lines, version }
  355. },
  356. async resyncDocContents(projectId, docId, path, opts = {}) {
  357. logger.debug({ projectId, docId, path }, 'start resyncing doc contents')
  358. let {
  359. lines,
  360. ranges,
  361. resolvedCommentIds,
  362. version,
  363. projectHistoryId,
  364. historyRangesSupport,
  365. } = await RedisManager.promises.getDoc(projectId, docId)
  366. // To avoid issues where the same docId appears with different paths,
  367. // we use the path from the resyncProjectStructure update. If we used
  368. // the path from the getDoc call to web then the two occurences of the
  369. // docId would map to the same path, and this would be rejected by
  370. // project-history as an unexpected resyncDocContent update.
  371. if (lines == null || version == null) {
  372. logger.debug(
  373. { projectId, docId },
  374. 'resyncing doc contents - not found in redis - retrieving from web'
  375. )
  376. ;({
  377. lines,
  378. ranges,
  379. resolvedCommentIds,
  380. version,
  381. projectHistoryId,
  382. historyRangesSupport,
  383. } = await PersistenceManager.promises.getDoc(projectId, docId, {
  384. peek: true,
  385. }))
  386. } else {
  387. logger.debug(
  388. { projectId, docId },
  389. 'resyncing doc contents - doc in redis - will queue in redis'
  390. )
  391. }
  392. if (opts.historyRangesMigration) {
  393. historyRangesSupport = opts.historyRangesMigration === 'forwards'
  394. }
  395. await ProjectHistoryRedisManager.promises.queueResyncDocContent(
  396. projectId,
  397. projectHistoryId,
  398. docId,
  399. lines,
  400. ranges ?? {},
  401. resolvedCommentIds,
  402. version,
  403. // use the path from the resyncProjectStructure update
  404. path,
  405. historyRangesSupport
  406. )
  407. if (opts.historyRangesMigration) {
  408. await RedisManager.promises.setHistoryRangesSupportFlag(
  409. docId,
  410. historyRangesSupport
  411. )
  412. }
  413. },
  414. async getDocWithLock(projectId, docId) {
  415. const UpdateManager = require('./UpdateManager')
  416. return await UpdateManager.promises.lockUpdatesAndDo(
  417. DocumentManager.getDoc,
  418. projectId,
  419. docId
  420. )
  421. },
  422. async getDocAndRecentOpsWithLock(projectId, docId, fromVersion) {
  423. const UpdateManager = require('./UpdateManager')
  424. return await UpdateManager.promises.lockUpdatesAndDo(
  425. DocumentManager.getDocAndRecentOps,
  426. projectId,
  427. docId,
  428. fromVersion
  429. )
  430. },
  431. async getDocAndFlushIfOldWithLock(projectId, docId) {
  432. const UpdateManager = require('./UpdateManager')
  433. return await UpdateManager.promises.lockUpdatesAndDo(
  434. DocumentManager.getDocAndFlushIfOld,
  435. projectId,
  436. docId
  437. )
  438. },
  439. async setDocWithLock(projectId, docId, lines, source, userId, undoing) {
  440. const UpdateManager = require('./UpdateManager')
  441. return await UpdateManager.promises.lockUpdatesAndDo(
  442. DocumentManager.setDoc,
  443. projectId,
  444. docId,
  445. lines,
  446. source,
  447. userId,
  448. undoing
  449. )
  450. },
  451. async flushDocIfLoadedWithLock(projectId, docId) {
  452. const UpdateManager = require('./UpdateManager')
  453. return await UpdateManager.promises.lockUpdatesAndDo(
  454. DocumentManager.flushDocIfLoaded,
  455. projectId,
  456. docId
  457. )
  458. },
  459. async flushAndDeleteDocWithLock(projectId, docId, options) {
  460. const UpdateManager = require('./UpdateManager')
  461. return await UpdateManager.promises.lockUpdatesAndDo(
  462. DocumentManager.flushAndDeleteDoc,
  463. projectId,
  464. docId,
  465. options
  466. )
  467. },
  468. async acceptChangesWithLock(projectId, docId, changeIds) {
  469. const UpdateManager = require('./UpdateManager')
  470. await UpdateManager.promises.lockUpdatesAndDo(
  471. DocumentManager.acceptChanges,
  472. projectId,
  473. docId,
  474. changeIds
  475. )
  476. },
  477. async updateCommentStateWithLock(
  478. projectId,
  479. docId,
  480. threadId,
  481. userId,
  482. resolved
  483. ) {
  484. const UpdateManager = require('./UpdateManager')
  485. await UpdateManager.promises.lockUpdatesAndDo(
  486. DocumentManager.updateCommentState,
  487. projectId,
  488. docId,
  489. threadId,
  490. userId,
  491. resolved
  492. )
  493. },
  494. async deleteCommentWithLock(projectId, docId, threadId, userId) {
  495. const UpdateManager = require('./UpdateManager')
  496. await UpdateManager.promises.lockUpdatesAndDo(
  497. DocumentManager.deleteComment,
  498. projectId,
  499. docId,
  500. threadId,
  501. userId
  502. )
  503. },
  504. async renameDocWithLock(projectId, docId, userId, update, projectHistoryId) {
  505. const UpdateManager = require('./UpdateManager')
  506. await UpdateManager.promises.lockUpdatesAndDo(
  507. DocumentManager.renameDoc,
  508. projectId,
  509. docId,
  510. userId,
  511. update,
  512. projectHistoryId
  513. )
  514. },
  515. async resyncDocContentsWithLock(projectId, docId, path, opts) {
  516. const UpdateManager = require('./UpdateManager')
  517. await UpdateManager.promises.lockUpdatesAndDo(
  518. DocumentManager.resyncDocContents,
  519. projectId,
  520. docId,
  521. path,
  522. opts
  523. )
  524. },
  525. }
  526. module.exports = {
  527. ...callbackifyAll(DocumentManager, {
  528. multiResult: {
  529. getDoc: [
  530. 'lines',
  531. 'version',
  532. 'ranges',
  533. 'pathname',
  534. 'projectHistoryId',
  535. 'unflushedTime',
  536. 'alreadyLoaded',
  537. 'historyRangesSupport',
  538. ],
  539. getDocWithLock: [
  540. 'lines',
  541. 'version',
  542. 'ranges',
  543. 'pathname',
  544. 'projectHistoryId',
  545. 'unflushedTime',
  546. 'alreadyLoaded',
  547. 'historyRangesSupport',
  548. ],
  549. getDocAndFlushIfOld: ['lines', 'version'],
  550. getDocAndFlushIfOldWithLock: ['lines', 'version'],
  551. getDocAndRecentOps: [
  552. 'lines',
  553. 'version',
  554. 'ops',
  555. 'ranges',
  556. 'pathname',
  557. 'projectHistoryId',
  558. ],
  559. getDocAndRecentOpsWithLock: [
  560. 'lines',
  561. 'version',
  562. 'ops',
  563. 'ranges',
  564. 'pathname',
  565. 'projectHistoryId',
  566. ],
  567. },
  568. }),
  569. promises: DocumentManager,
  570. }