DocumentManager.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  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
  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) {
  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. await ProjectHistoryRedisManager.promises.queueResyncDocContent(
  388. projectId,
  389. projectHistoryId,
  390. docId,
  391. lines,
  392. ranges,
  393. resolvedCommentIds,
  394. version,
  395. // use the path from the resyncProjectStructure update
  396. path,
  397. historyRangesSupport
  398. )
  399. },
  400. async getDocWithLock(projectId, docId) {
  401. const UpdateManager = require('./UpdateManager')
  402. return await UpdateManager.promises.lockUpdatesAndDo(
  403. DocumentManager.getDoc,
  404. projectId,
  405. docId
  406. )
  407. },
  408. async getDocAndRecentOpsWithLock(projectId, docId, fromVersion) {
  409. const UpdateManager = require('./UpdateManager')
  410. return await UpdateManager.promises.lockUpdatesAndDo(
  411. DocumentManager.getDocAndRecentOps,
  412. projectId,
  413. docId,
  414. fromVersion
  415. )
  416. },
  417. async getDocAndFlushIfOldWithLock(projectId, docId) {
  418. const UpdateManager = require('./UpdateManager')
  419. return await UpdateManager.promises.lockUpdatesAndDo(
  420. DocumentManager.getDocAndFlushIfOld,
  421. projectId,
  422. docId
  423. )
  424. },
  425. async setDocWithLock(projectId, docId, lines, source, userId, undoing) {
  426. const UpdateManager = require('./UpdateManager')
  427. return await UpdateManager.promises.lockUpdatesAndDo(
  428. DocumentManager.setDoc,
  429. projectId,
  430. docId,
  431. lines,
  432. source,
  433. userId,
  434. undoing
  435. )
  436. },
  437. async flushDocIfLoadedWithLock(projectId, docId) {
  438. const UpdateManager = require('./UpdateManager')
  439. return await UpdateManager.promises.lockUpdatesAndDo(
  440. DocumentManager.flushDocIfLoaded,
  441. projectId,
  442. docId
  443. )
  444. },
  445. async flushAndDeleteDocWithLock(projectId, docId, options) {
  446. const UpdateManager = require('./UpdateManager')
  447. return await UpdateManager.promises.lockUpdatesAndDo(
  448. DocumentManager.flushAndDeleteDoc,
  449. projectId,
  450. docId,
  451. options
  452. )
  453. },
  454. async acceptChangesWithLock(projectId, docId, changeIds) {
  455. const UpdateManager = require('./UpdateManager')
  456. await UpdateManager.promises.lockUpdatesAndDo(
  457. DocumentManager.acceptChanges,
  458. projectId,
  459. docId,
  460. changeIds
  461. )
  462. },
  463. async updateCommentStateWithLock(
  464. projectId,
  465. docId,
  466. threadId,
  467. userId,
  468. resolved
  469. ) {
  470. const UpdateManager = require('./UpdateManager')
  471. await UpdateManager.promises.lockUpdatesAndDo(
  472. DocumentManager.updateCommentState,
  473. projectId,
  474. docId,
  475. threadId,
  476. userId,
  477. resolved
  478. )
  479. },
  480. async deleteCommentWithLock(projectId, docId, threadId, userId) {
  481. const UpdateManager = require('./UpdateManager')
  482. await UpdateManager.promises.lockUpdatesAndDo(
  483. DocumentManager.deleteComment,
  484. projectId,
  485. docId,
  486. threadId,
  487. userId
  488. )
  489. },
  490. async renameDocWithLock(projectId, docId, userId, update, projectHistoryId) {
  491. const UpdateManager = require('./UpdateManager')
  492. await UpdateManager.promises.lockUpdatesAndDo(
  493. DocumentManager.renameDoc,
  494. projectId,
  495. docId,
  496. userId,
  497. update,
  498. projectHistoryId
  499. )
  500. },
  501. async resyncDocContentsWithLock(projectId, docId, path, callback) {
  502. const UpdateManager = require('./UpdateManager')
  503. await UpdateManager.promises.lockUpdatesAndDo(
  504. DocumentManager.resyncDocContents,
  505. projectId,
  506. docId,
  507. path,
  508. callback
  509. )
  510. },
  511. }
  512. module.exports = {
  513. ...callbackifyAll(DocumentManager, {
  514. multiResult: {
  515. getDoc: [
  516. 'lines',
  517. 'version',
  518. 'ranges',
  519. 'pathname',
  520. 'projectHistoryId',
  521. 'unflushedTime',
  522. 'alreadyLoaded',
  523. 'historyRangesSupport',
  524. ],
  525. getDocWithLock: [
  526. 'lines',
  527. 'version',
  528. 'ranges',
  529. 'pathname',
  530. 'projectHistoryId',
  531. 'unflushedTime',
  532. 'alreadyLoaded',
  533. 'historyRangesSupport',
  534. ],
  535. getDocAndFlushIfOld: ['lines', 'version'],
  536. getDocAndFlushIfOldWithLock: ['lines', 'version'],
  537. getDocAndRecentOps: [
  538. 'lines',
  539. 'version',
  540. 'ops',
  541. 'ranges',
  542. 'pathname',
  543. 'projectHistoryId',
  544. ],
  545. getDocAndRecentOpsWithLock: [
  546. 'lines',
  547. 'version',
  548. 'ops',
  549. 'ranges',
  550. 'pathname',
  551. 'projectHistoryId',
  552. ],
  553. },
  554. }),
  555. promises: DocumentManager,
  556. }